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.
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 anonErrorthat handles the give-up case gracefully. - Branch on a connector failure. A connector’s
onErrorcan set a variable, then a downstreambranchesstep decides whether to compensate, alert, or fail. - Bounded polling. A
loopwith aretry_count < maxcondition and abreakNextavoids unbounded retries when polling an external job.
Next: Connectors overview.