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.retry or stepModule.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.