Skip to content
Français

Parallel tasks and retries

Control task concurrency, retries, timeouts and what happens after a failure.

In this example, flaky and lint start in parallel. The first task fails once, waits 100 ms and succeeds on its next attempt. Its entry in result.tasks then reports attempts: 2.

import { reportValue } from "./reporter.ts";
import { defineTask, defineWorkflow } from "@elie-laloum/outpost";

const flaky = defineTask({
  key: "flaky",
  retry: { attempts: 3, delayMs: 100 },
  perform: ({ attempt }) => {
    if (attempt < 2) throw new Error("Temporary failure");
    return { attempt };
  },
});
const lint = defineTask({ key: "lint", perform: () => "clean" });

const result = await defineWorkflow("checks", [flaky, lint]).start({
  concurrency: 2,
});
result.unwrap();
reportValue(result.value(flaky));
// Example output: { attempt: 2 }

start({ concurrency }) sets how many tasks run at once. The default is 1: tasks run one after another. A task still waits for every task in its after list.

A task runs once unless you give it a retry policy.

API reference: WorkflowOptions.

import { defineTask, OutpostError } from "@elie-laloum/outpost";

export const request = defineTask({
  key: "request",
  retry: {
    attempts: 4,
    delayMs: 500,
    backoff: "exponential",
    maxDelayMs: 10_000,
    jitter: "full",
    accepts: (error) =>
      error instanceof OutpostError &&
      [429, 503].includes(Number(error.details.status)),
  },
  perform: ({ signal }) => {
    signal.throwIfAborted();
    return "Replace with your cancellable request";
  },
});
import { reportValue } from "./reporter.ts";
import { defineWorkflow } from "@elie-laloum/outpost";
import { request } from "./request.ts";

export const result = await defineWorkflow("requests", [request]).start();
result.unwrap();
reportValue(result.tasks[0]?.attempts);
// Example output: 1

Each retry emits a retry event with its delayMs (see Follow progress). Retries count against budget.attempts when you set a budget.

Model providers copy a valid Retry-After header into OutpostError.details.retryAfterMs. The retry then waits at least that long, even beyond maxDelayMs and whatever the jitter. Your own code can throw an OutpostError with details.retryAfterMs in milliseconds to get the same behaviour.

API reference: TaskOptions and DispatchOptions.

Set separate deadlines for each task attempt and the whole workflow. In this example, the first attempt expires after 200 ms, and the workflow deadline interrupts the retry at 300 ms.

import { reportValue } from "./reporter.ts";
import { setTimeout as sleep } from "node:timers/promises";
import { OutpostError, defineTask, defineWorkflow } from "@elie-laloum/outpost";

const slow = defineTask({
  key: "slow",
  timeoutMs: 200,
  retry: { attempts: 2 },
  perform: ({ signal }) => sleep(5_000, "late", { signal }),
});
const result = await defineWorkflow("deadline", [slow]).start({
  timeoutMs: 300,
});
reportValue(
  result.status,
  result.errors.map((error) =>
    error instanceof OutpostError ? error.code : error,
  ),
);
// Example output: failed [ 'timeout' ]

The first attempt times out after 200 ms, the second is cancelled by the workflow deadline at 300 ms. Both values are positive integers of at most 2,147,483,647 ms. Each resumed start() gets a fresh deadline; the time between calls does not count.

A task fails when its last attempt fails. What happens next depends on stopOnError.

Other tasksstopOnError: true (default)stopOnError: false
RunningTheir signal aborts; they end cancelled.Continue.
Not startedEnd cancelled.Dependents of the failed task end skipped; the others run.
Workflow status"failed""failed"

unwrap() throws a WorkflowFailure unless status is "done". Read result.tasks for each task’s status, attempts and error, and result.errors for the failures themselves.

condition runs once, before the first attempt. When it returns false, the task ends skipped and so do the tasks that depend on it.

import { reportValue } from "./reporter.ts";
import { defineTask, defineWorkflow } from "@elie-laloum/outpost";

const changes = defineTask({ key: "changes", perform: () => ["README.md"] });
const tests = defineTask({
  key: "tests",
  after: [changes],
  condition: (context) =>
    context.value(changes).some((file) => file.endsWith(".ts")),
  perform: () => "Tests passed",
});
const result = await defineWorkflow("docs-only", [changes, tests]).start();
result.unwrap();
reportValue(result.tasks.map((task) => `${task.key}: ${task.status}`));
// Example output: [ 'changes: done', 'tests: skipped' ]
// [ 'changes: done', 'tests: skipped' ]

A skipped task has no value: result.value(tests) throws.

Pass an AbortSignal as start({ signal }). It aborts every running task’s context.signal, and the workflow ends with status: "cancelled".

Agent, command and isolated tasks forward context.signal for you. In defineTask(), pass it to every command, request and wait your code starts.

  • Cancellation is cooperative: code that ignores context.signal keeps running until it returns, even past the workflow deadline; its value is then discarded.
  • A retry runs the whole task again and can repeat its side effects. Deduplicate them with context.idempotencyKey, which stays the same across retries: see Job queues and workers.
  • Each start() call, such as a checkpoint resume or a resume after a quota pause, allows retry.attempts again and restarts the backoff from delayMs; attempt numbers stay cumulative in a checkpoint.
  • With quota pauses enabled, a quota error pauses the task instead of retrying it.
  • Retry settings, task timeouts and the presence of a condition are part of the checkpoint identity: changing them rejects an existing checkpoint (see Durable runs).

API: defineTask · Retry · TaskOptions · WorkflowOptions · WorkflowResult · OutpostError