Retry & timeout
const flaky = defineStep({ type: 'call-api', workflowInputSchema, outputSchema, retry: { maxAttempts: 4, backoff: 'exponential', initialDelayMs: 500, maxDelayMs: 30_000 }, timeoutMs: 10_000, // aborts + retries on expiry handler: async (ctx) => { /* throw a retryable error to retry within budget */ },});A failure is retried (with backoff via the dispatcher’s startAfterSeconds) up to maxAttempts;
after that the step fails terminally. → examples/03-retry-timeout.ts
| Field | Default | Meaning |
|---|---|---|
maxAttempts |
1 |
Total attempts including the first |
backoff |
'fixed' |
'fixed' or 'exponential' (initialDelayMs × 2^(n-1)) |
initialDelayMs |
1000 |
Delay before the 2nd attempt |
maxDelayMs |
60000 |
Cap on the computed delay |
Backoff is delivered as the dispatcher’s startAfterSeconds, so it is rounded up to whole
seconds — an initialDelayMs under 1000 still waits a second, and a dispatcher that ignores
the option retries immediately.
timeoutMs is a wall-clock bound on one attempt. On expiry the engine fires ctx.signal and
produces a retryable failure, so the step retries within its budget; a handler that ignores
the signal keeps running in the background, so pass ctx.signal to anything that accepts one.
Which failures count as retryable is decided in this order:
- An explicit marker on the error — always wins.
A marker is found throughimport { retryableError, nonRetryableError, markRetryable } from 'octaflow';throw retryableError('encoder busy'); // retry, whatever the message saysthrow nonRetryableError('timeout must be > 0'); // never retry — a bug, not a blipthrow markRetryable(await client.readError(), true); // tag an error you didn't construct
cause, so wrapping doesn’t lose it:new Error('upstream failed', { cause: retryableError('busy') })still retries. - The step’s own predicate, for classifying a whole family of errors at once:
(isRetryable: (e) => e instanceof HttpError && e.status >= 500,
defineMapSteptakesitemIsRetryablefor its per-item children.) isRetryableError— the zero-config default. It reads structured fields first:code(ECONNRESET,ECONNREFUSED,ETIMEDOUT,EAI_AGAIN, …) and HTTP status fromstatus/statusCode/response.status(408, 425, 429 and 5xx except 501/505). Only then does it fall back to matching the message against a small vocabulary (rate limit,timeout,fetch failed,service unavailable, …).
That last fallback is a convenience, not a classifier — it can only judge wording. A genuine
bug reading 'timeout must be > 0' looks transient to it. When the answer matters, mark the
error rather than phrasing it to suit the heuristic.
To stop guessing entirely, give the engine a defaultRetryable:
createWorkflowEngine({ …, config: { defaultRetryable: false } }); // strict: never guessIt replaces the classifier’s answer only where the classifier guessed. Explicitly marked
errors, steps with their own isRetryable, and engine-generated failures like a step timeout
are unaffected.
The other retry layer
Section titled “The other retry layer”The step’s retry policy is not the only thing that re-runs work. With pg-boss there are
two independent mechanisms, and they trigger on different things:
Step retry (RetryPolicy) |
Queue retry (StepQueueConfig) |
|
|---|---|---|
| Owned by | the engine | pg-boss |
| Fires when | the handler fails and the failure is retryable | executeStep throws out of the worker |
| Budget | maxAttempts, default 1 |
retryLimit, default 2 |
| Delay | backoff / initialDelayMs |
retryDelay, default 30 s |
| Exhaustion | step marked failed, workflow cascades |
job → dead-letter queue → your DLQ worker calls handleStepExhausted |
A handler that throws is caught by defineStep and turned into a step failure, so it goes down
the first column — the queue never sees it. The second column is for failures around the
handler: the store is unreachable, the process is killed, the worker itself throws. That’s why
production wiring needs a
DLQ worker: without one, a job that exhausts its
queue retries leaves its step stuck in running until the
stuck-step sweeper picks it up.
Being deferred by the step gate is neither: a throttled step is re-enqueued without consuming an attempt from either budget.