Task options
Every builder method takes options as its second argument:
.task(config, { retry: { attempts: 3, delay: 100 }, timeout: 5_000 })A definition can carry its own options too. When both exist, the call site wins: options are merged per key, and the builder's object overrides the definition's.
const reserve = defineTask<In, Ctx>()({
name: "reserve",
options: { retry: { attempts: 3, delay: 20, backoff: "exponential" } },
execute: /* ... */,
});
.task(reserve, { retry: { attempts: 5, delay: 10, backoff: "linear" } })
// runs with 5 attempts, linear; the definition's retry block is replaced wholeNote "replaced whole": the merge is shallow. A call-site retry supersedes the definition's retry entirely rather than merging field by field.
Where each option can live:
| Option | In a definition's options | At the call site | Notes |
|---|---|---|---|
retry | ✓ | ✓ | |
timeout | ✓ | ✓ | |
optional | ✓ | ||
when | ✓ | ||
adapt | ✓ | ||
failFast | ✓ | parallel methods only | |
concurrency | ✓ | parallel methods only |
retry
{ retry: { attempts: 3, delay: 100, backoff: "exponential", maxDelay: 5_000 } }attempts is the total number of tries, not the number of retries, so 3 means one attempt plus two more. delay is in milliseconds. backoff defaults to "linear".
| Backoff | Waits before attempt 2, 3, 4 |
|---|---|
"linear" | delay, 2×delay, 3×delay |
"exponential" | delay, 2×delay, 4×delay |
maxDelay caps a single wait. Worth setting whenever backoff is "exponential", which is otherwise unbounded: { attempts: 10, delay: 1000 } waits 512 seconds before the last try.
jitter
{ retry: { attempts: 5, delay: 200, backoff: "exponential", maxDelay: 5_000, jitter: true } }Backoff on its own is deterministic. Every caller knocked over by the same outage computes the same schedule and retries in lockstep, so the service that just came back gets hit by the whole herd at once, and the collision repeats on every subsequent attempt.
jitter: true spreads each wait randomly over [0, wait], so those callers scatter. It applies after maxDelay, which still caps: with the config above, a wait the backoff put at 5,000ms lands somewhere in 0–5,000ms, never higher.
Worth turning on whenever the failure you are retrying is one many processes hit at the same moment, such as a shared dependency going down. Leave it off when the wait needs to be predictable, in a test or when timing a single client's traffic.
shouldRetry
Not every failure is worth repeating. A 503 is; a 400 will fail identically four times and just delay the rollback.
{
retry: {
attempts: 4,
delay: 200,
shouldRetry: (error, attempt) => !/^4\d\d/.test(error.message),
},
}Called after each failed attempt with the error and the 1-based attempt number. Return false and that error propagates immediately, with no further tries. Omit it and everything is retried, which is the default.
A timed-out attempt arrives here as a TimeoutError, so you can tell "too slow" from "rejected" without reading the message:
{ retry: { attempts: 3, delay: 200, shouldRetry: (error) => error.name === "TimeoutError" } }If every attempt fails, the last error is what propagates.
Cancelling the run ends the retry loop regardless of what shouldRetry says; see Cancellation.
Retries apply to execute only. A compensate that throws is not retried; see Compensation.
Retries re-run side effects
A task that half-succeeded before throwing will run its execute again from the top. Make the work idempotent, or don't retry it.
timeout
{ timeout: 5_000 }Rejects with Task timed out after 5000ms if execute has not settled in time, which then counts as a failed attempt. With retry set, the next attempt gets its own fresh timeout.
The rejection is a TimeoutError, the same shape AbortSignal.timeout() produces, so error.name === "TimeoutError" identifies it in shouldRetry, in onTaskError, or on the saga's error.
The timeout races the promise, but it also aborts the signal handed to execute, so a task that forwards that signal really does stop. A task that ignores it keeps running to completion, and since it never "succeeded" as far as the saga is concerned, it is never compensated. See Cancellation.
optional
.task({ name: "sendReceipt", execute: /* ... */ }, { optional: true })A failing optional task does not fail the saga. onTaskError still fires, the task contributes nothing to results, and the saga carries on to the next step. Retries still apply first; optional only decides what happens once every attempt is spent.
It covers every callback the task owns, not just execute: a when, an iterator or a key that throws is reported and stepped over the same way. Which callback failed does not change whether the step was optional.
- A throwing
whenoriteratorskips the whole step, exactly as awhenreturningfalsewould. - A throwing
keyskips that one iteration, exactly as a failing iteration'sexecutewould, and the map carries on to the next value.
The one thing optional never swallows is cancellation; see Cancellation.
Because the task might not have produced anything, its key becomes optional in the result type: results.sendReceipt is Receipt | undefined and TypeScript makes you check.
when
.task(config, { when: ({ input, results, context }) => input.customerId.startsWith("vip") })A gate, evaluated just before the task runs. It may be async. Return false and the task is skipped entirely: no execution, no hooks, nothing in results, and nothing registered for rollback.
As with optional, a gated task's key is optional in the result type.
Gate the whole group
On .parallelTasks(), when gates the entire group, since it is a property of the node, not of each branch. To skip one branch and keep its siblings, split it out into its own step.
adapt
.task(charge, { adapt: ({ input, results, context }) => ({ amount: results.cart.total }) })For a task defined elsewhere, against its own input rather than this saga's. adapt maps one into the other, and the task's callbacks see only what it returns. Without it, a reused definition only fits sagas whose input already covers what the definition declared:
const charge = defineTask<{ amount: number }>()({ name: "charge", execute: /* ... */ });
// The saga's input has no `amount`, so this does not compile.
defineSaga<{ cartId: string }>("checkout").task(charge);TypeScript requires adapt exactly when the shapes do not already line up, and checks that it returns what the definition declared. It is the same option .saga() takes and it behaves the same way: it runs per callback rather than once up front, so it sees results as of the moment that callback runs, and it covers execute, compensate, iterator and key.
One adapt per step, not per task
On .parallelTasks(), adapt covers the whole group, so every task in it must expect the same input. when is not adapted either way: it decides whether the parent runs the step at all, so it reads the parent's input.
failFast
Only on .parallelTasks() and .mapParallelTask(). See Parallel work.
concurrency
Only on .parallelTasks() and .mapParallelTask(). Caps how many branches run at once. See Parallel work.
Validation
defineTask, defineMapTask and the builder all check what TypeScript cannot, namely value ranges, and throw at definition time rather than mid-run:
namemust be non-emptyretry.attemptsmust be a finite number ≥ 1retry.delaymust be a finite number ≥ 0retry.maxDelaymust be a finite number > 0timeoutmust be a finite number > 0concurrencymust be a positive integer.parallelTasks()needs at least two tasksdefineSaganeeds a non-empty string name