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
Use stepModule.retry(...) when you want retry attempts to appear as separate step attempts in the dashboard:
import { workflow, step, stepModule } from '@voltro/workflow'
import { Effect, Schedule, Schema } from 'effect'
class ProviderDown extends Schema.TaggedError<ProviderDown>()('ProviderDown', {
message: Schema.String,
}) {}
const summary = yield* stepModule.retry(
step({
name: 'summarise-with-llm',
input: { noteId },
success: Schema.String,
error: ProviderDown,
retry: {
strategy: 'exponential',
maxAttempts: 5,
baseDelay: '500 millis',
},
execute: callLlm(note.body),
}),
Schedule.exponential('500 millis').pipe(Schedule.recurs(4)),
)The retry field on step({...}) is metadata for inspection. The actual retry behaviour comes from stepModule.retry(...), Effect.retry(...), or another Effect retry combinator.
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 |
| Cancel/suspend by operator | 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...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.
- Putting retry metadata on a step without
Effect.retryorstepModule.retry. Metadata only explains intent; it does not execute a policy. - Expecting a DLQ table. The current triage surface is failed runs plus retry controls.