Retries & failure handling
Per-step retries, failed workflow runs, retry controls, compensation, and timeouts.
Workflow durability and retry are related, but not identical. The engine checkpoints completed step({...}) activities so an interrupted run can resume without re-running successful steps. Transient failures still need an explicit retry policy around the step that can fail.
Per-step retries
Declare a retry: policy on step({...}) and the framework enforces it — it compiles the policy to an Effect Schedule and retries execute for you. No hand-written retry needed:
import { workflow, step } from '@voltro/workflow'
import { Schema } from 'effect'
class ProviderDown extends Schema.TaggedError<ProviderDown>()('ProviderDown', {
message: Schema.String,
}) {}
const summary = yield* step({
name: 'summarise-with-llm',
input: { noteId },
success: Schema.String,
error: ProviderDown,
retry: {
maxAttempts: 5,
strategy: 'exponential',
baseDelay: '500 millis',
},
execute: callLlm(note.body),
})Retries run inside the one step and are transparent to the durable engine — completed steps still checkpoint; the step's final outcome is recorded. retry: { maxAttempts: 5 } is already a good policy (exponential backoff, jittered).
The conditions you actually want
The useful retry question is rarely "how many times" — it's which failures, for how long, and how spread out. The policy covers all of it:
retry: {
maxAttempts: 5, // total attempts including the first (default 3)
strategy: 'exponential', // 'exponential' | 'fixed' | 'linear'
baseDelay: '500 millis',
maxDelay: '30 seconds', // ceiling so exponential growth can't run away
factor: 2, // exponential growth factor
jitter: true, // full jitter (default true) — anti-thundering-herd
maxElapsed: '5 minutes', // a total time BUDGET — stop retrying after this
retryableErrors: ['ProviderDown'], // retry ONLY these typed errors; others fail fast
respectRetryAfter: true, // honor a 429: retryAfter REPLACES this attempt's backoff
}retryableErrors(or aretryable: (error) => booleanpredicate) is the important one: retry the transient failures, fail fast on the permanent ones. AValidationErrorshould never be retried; aProviderDownshould.maxElapsedis a deadline across all attempts, not another count — the right bound when "keep trying for up to 5 minutes" matters more than "try 8 times".respectRetryAfteruses exactly the delay a provider asked for (aretryAfterMillisnumber, orretryAfterin seconds, on the thrown error) as the next delay, replacing the computed backoff for that attempt; falls back to backoff when there is no hint.
Retries here run inside the one step attempt, so the step is recorded as a SINGLE row in _voltro_workflow_run_steps with its final outcome — the individual in-step retries are not separate rows. respectRetryAfter uses the provider's delay AS the next delay (replacing the computed backoff for that attempt), falling back to backoff when the error carries no hint. For per-attempt rows in the dashboard, or a bespoke Schedule, use stepModule.retry(step({...}), schedule) instead. Do NOT set retry: on a step you ALSO wrap in stepModule.retry / Effect.retry: it would retry twice.
What to retry
| Failure kind | Recommended handling |
|---|---|
| HTTP 5xx, connection reset, provider 429 | Retry the step with backoff and jitter |
| Validation error, malformed payload | Fail the workflow |
| Permission error | Fail the workflow; fix caller or payload |
| Timeout | Convert to a typed transient error, then retry if safe |
| Cancellation interruption | Do not catch unless you are deliberately cleaning up |
Keep retry windows close to the side effect. A flaky LLM call should retry inside the LLM step, not by restarting the whole workflow body.
Failed runs
When a workflow ultimately fails, Voltro records the run as failed. Query failed runs from any handler, action, or mutation with the typed SDK — no raw SQL, no knowledge of internal tables:
const failed = await ctx.workflows.listRuns({
status: 'failed',
workflowName: 'notes.summarise',
})
// failed: WorkflowRunSummary[] — id, workflowName, status, payload,
// errorTag, errorMessage, startedAt, completedAt, durationMs, …listRuns(filter?) accepts { workflowName / tag, status, limit, offset } (all optional) and returns the most-recent runs first. The inspect dashboard surfaces the same data live, but the SDK is the primary path — it's typed and reactive-friendly.
There is no separate voltro_workflow_dlq table in the current runtime. Failed runs are the triage queue.
Retrying a failed run
Re-run a failed run by its id from a handler, action, or mutation:
for (const run of failed) {
// Re-run against the original payload …
await ctx.workflows.retry(run.id)
// … or replay against a corrected input after fixing bad data:
await ctx.workflows.retry(run.id, { payloadOverride: { ...run.payload, retries: 1 } })
}retry(runId, options?) resolves the workflow by its tag, re-executes it, and returns { executionId } — the engine-assigned id of the fresh run. By default it uses the original run's recorded payload; pass payloadOverride to replay against a different input.
For ad-hoc ops, the same operation is available manually — the CLI (voltro workflows retry <runId>) and the dashboard's "Retry" button both delegate to the same runtime code:
voltro workflows retry wfrun_01H...retry starts a fresh execution — a new execution id, an empty journal, every step runs again. That is the right tool for a short, idempotent job, or when the input itself was wrong (payloadOverride). For a long multi-step pipeline where re-doing steps 1…N‑1 is expensive or unsafe, you want the opposite: resume from where it failed.
Resume from where it failed — suspendOnFailure
Declare suspendOnFailure: true on a workflow and a failure no longer becomes a terminal failed run — it suspends with the durable journal intact:
export default workflow({
name: 'billing.close-month',
payload: { orgId: Schema.String },
success: Schema.Void,
error: Schema.Unknown,
idempotencyKey: ({ orgId }) => `billing.close-month:${orgId}`,
suspendOnFailure: true, // a failure suspends (recoverable), not fails (terminal)
execute: ({ orgId }) => Effect.gen(function* () {
yield* step('snapshot-ledger', /* … */) // completed steps are journaled
yield* step('call-tax-provider', /* … */) // ← a transient 503 here …
yield* step('finalise-invoices', /* … */)
}),
})When call-tax-provider fails, the run goes to suspended (not failed), carrying the failure reason. Fix the cause, then resume — the engine replays snapshot-ledger from the journal (it does not re-run) and continues from the failed step:
voltro workflows resume wfrun_01H... # re-drives from the failure pointawait ctx.workflows.resume(run.id) // the same, from a handlerBecause a suspended-on-failure run is recoverable, not dead, it shows up under --status suspended, NOT in the dead-letter view (voltro workflows list --dead-letter, which is failed-and-unhandled). Choose per workflow: suspendOnFailure: true for a long pipeline where prior work must not be redone; the default (retry from scratch) for short idempotent jobs.
Restart a failed run with its journal
A run that already failed can restart without repeating its successful steps. Fix the cause first, then submit an immutable restart request. Both the SDK and CLI use the same resident reservation and engine-entry receipt as the dashboard.
voltro workflows restart wf_01H... --request-id incident-1 --failed-steps
voltro workflows restart wf_01H... --request-id incident-1 --observe// Save this identity and selection before submitting. Reuse them after a lost reply.
const intent = { requestId: 'incident-1', selection: { kind: 'failed-steps' as const } }
const result = await ctx.workflows.restart(run.id, intent)
const observed = await ctx.workflows.readRestart(run.id, intent.requestId)not-reserved reports a reservation refusal, not a permanent rejection of the
identity. pending means the reservation is durable; it does not prove engine
entry. entered confirms the next generation started, not that the run succeeded.
Check the run and step records for completion. Unknown outcomes retain the same
request ID and selection; status reads never submit another restart.
To abandon an unresolved request, call ctx.workflows.withdrawRestart(run.id, intent). A withdrawn receipt permanently prevents that identity from starting.
If reservation already won, the existing pending or entered receipt is returned
unchanged and the work continues. Confirm withdrawal before creating a different
request. These methods operate outside mutation transactions: invoke them from an
action or workflow after the application's mutation has committed.
Completed steps replay from the durable journal; only failed activities execute
again. A missing journal refuses reservation. Admission limits, resident placement
and the original execution remain authoritative in dev and production. Engine
entry records a run-redriven event. Memory storage cannot provide durable restart.
| Tool | Use when | Journal |
|---|---|---|
retry |
Input was wrong, or the job can safely run from the beginning | Fresh execution; every step runs again |
resume |
The run is suspended | Completed steps replay |
restart with failed-steps |
The run failed and earlier side effects must not repeat | Completed steps replay; failed steps run again |
restart with from-step |
Recovery must include an earlier step | Steps before the selected step replay; selected and later steps run again |
Rewind further back — restart --step
restart with failed-steps continues from the step that failed. Sometimes that isn't the right place to restart: step 5 failed, but the real problem is that step 3 completed against stale external state, and re-running from the failure point would carry that bad result forward. restart --step rewinds to a step you choose:
voltro workflows restart wf_01H... --request-id incident-1 --step charge-card
voltro workflows restart wf_01H... --request-id incident-1 --observeIt resets the chosen step and every step after it — including steps that succeeded — so they re-execute, while the steps before your target replay from the journal unchanged. restart with failed-steps is the special case where your chosen step is exactly the one that failed. Like restart with failed-steps, it works under both voltro serve and voltro dev, refuses a run that isn't a not-yet-discarded failure, refuses an unknown step name, and declines cleanly on a run with no durable journal.
The CLI sends the same durable restart command as the dashboard. Save the request ID before submitting; reuse it after an uncertain response. --observe only reads its receipt and never submits another restart. pending means reserved; entered confirms engine entry, not completion. Select exactly one API with --process or --url when multiple APIs are available. Use --failed-steps instead of --step NAME to restart only failed activities.
Compensation
For saga-style workflows, model compensation explicitly with Effect.catchAll around the step that can fail after an earlier side effect:
yield* step({
name: 'reserve-inventory',
success: Schema.Void,
execute: reserveInventory(orderId),
})
yield* step({
name: 'charge-card',
success: Schema.Void,
execute: chargeCard(orderId).pipe(
Effect.catchAll((error) =>
releaseInventory(orderId).pipe(
Effect.zipRight(Effect.fail(error)),
),
),
),
})There is no defineSaga(...) helper today. Compensation is ordinary Effect code, checkpointed when you put it inside a step.
Timeouts
A hung external call is not a failure until it errors. Wrap it:
const response = yield* step({
name: 'fetch-provider',
success: ProviderResponse,
error: ProviderDown,
execute: fetchProvider(input).pipe(
Effect.timeoutFail({
duration: '30 seconds',
onTimeout: () => new ProviderDown({ message: 'provider timed out' }),
}),
),
})If the operation is not idempotent, pass an idempotency key to the provider and also enforce a unique key in your own database.
Failure observability
Every run has three inspectable layers:
_voltro_workflow_runs— run status, payload, output, top-level error, subject, start source, timing, trace id, parent execution id, parent-close policy._voltro_workflow_run_steps— each step attempt, recorded input/output/error, retry metadata, duration._voltro_workflow_run_events— run lifecycle, timers, signals, suspend/resume/cancel events.
The dashboard and voltro workflows show <runId> read these same tables through inspect endpoints.
Anti-patterns
- Catching errors and returning success. Operators lose the failed run.
- Retrying non-idempotent side effects. Double charges and duplicate emails are workflow bugs, not retry bugs.
- Declaring
retry:on a step you ALSO wrap inEffect.retry/stepModule.retry.retry:is enforced now — the step would retry twice. Keep one. - Expecting a DLQ table. The current triage surface is failed runs plus retry controls.