Errors, retries & recovery

retryPolicy, onError handlers, ctx.error, action.fail, and why side effects must be idempotent.

flowwork gives a step two independent tools for failure: retry the handler (retryPolicy) and route on failure (onError). Underneath, the engine adds crash recovery for free.

Retry a handler

When a handler throws, retryPolicy retries it with exponential backoff before giving up.

step("charge", {
  next: "confirm",
  retryPolicy: {
    maxAttempts: 3,
    initialInterval: 1,        // seconds before the first retry
    backoffCoefficient: 2.0,   // delay = initialInterval * coefficient^attempt
    // maximumInterval: 60,    // optional cap (seconds)
  },
  onError: "charge_failed",    // where to go once retries are exhausted
}, charge);

The run goes to RETRYING between attempts (it parks, costing nothing, until the next attempt is due) and re-runs the same step. retryPolicy is per step — set it only where it’s needed.

Route on failure

If retries are exhausted (or there’s no retryPolicy), an onError target receives control with ctx.error populated:

step("charge_failed", { next: "end" }, function chargeFailed(ctx) {
  // ctx.error = { message, step_name, exception }
  return { failure: ctx.error.message, status: "charge_failed" };
});

The handler runs as a normal step and must route onward (next, branches, or an action directive). To deliberately fail a run, route to a terminal step that returns action.fail("reason")action.fail marks the run FAILED only when returned from a step whose flow resolves to "end" (on a step that routes onward it’s silently dropped).

The failure decision

handler throws
   ├─ retryPolicy present & attempts remain ─▶ RETRYING ─▶ re-run the step
   ├─ else onError set ───────────────────────▶ RUNNING at the onError step (ctx.error set)
   └─ else ────────────────────────────────────▶ FAILED

Crash recovery (free)

State advances exactly once — each step’s commit is transactional and version-guarded. If a worker dies mid-step, the run’s lease expires and another worker reclaims and continues it; a result already computed but not committed is replayed rather than recomputed.

Side effects are at-least-once — make them idempotent

Recovery protects state, not side effects. An external call (HTTP POST, email, payment) can run more than once if a worker crashes after the call but before the commit. Use idempotency keys, upserts, or “create-if-absent” so a repeat is harmless. Put external I/O in a connector() step rather than a plain handler, so the engine can journal and retry it properly.

Patterns

  • Retry transient calls, route permanent ones. Pair retryPolicy (for timeouts/blips) with an onError that handles the give-up case gracefully.
  • Branch on a connector failure. A connector’s onError can set a variable, then a downstream branches step decides whether to compensate, alert, or fail.
  • Bounded polling. A loop with a retry_count < max condition and a breakNext avoids unbounded retries when polling an external job.

Next: Connectors overview.