Skip to Content
Fusee Docs
Guides

Errors and retries

A step's function talks to the world, and the world fails. This guide is about deciding, for each failure, whether to try again, give up, or undo. The reference is errors; the codes and the failure envelope are there.

What is retried by default

A step that throws is retried: three attempts, about 100 ms and then 200 ms apart (each delay drawn between half of it and all of it, so many runs that fail together do not retry together). The delay taken is recorded, so a replay after a crash waits for the same time.

const charge = await ctx.step("charge", (s) => payments.charge(order, { idempotencyKey: s.key }), {
  retry: { attempts: 5, initial: "200ms", max: "1m", maxElapsed: "10m" },
});

Pass the step's key to the service as its idempotency key. A step's function can run more than once (a retry, or a crash between the call and its recording), and the key is how the service tells a repeat from a new request. fusee.testing's crash_everywhere finds steps that forget it (testing your workflows).

Failures that must not be retried

A declined card stays declined. Mark it final and the step fails at once:

class CardDeclined extends FatalError {}
throw new CardDeclined("card declined", { code: "card_declined", details: { last4 } });

In Rust, return Failure::fatal("card declined").kind("CardDeclined").code("card_declined"); in Go, fusee.Fatal(err); in Java, throw a FatalException. Or keep the error as it is and say which types the policy does not retry: nonRetryable: [CardDeclined] (TypeScript), non_retryable=(CardDeclined,) (Python), NonRetryable: []string{"CardDeclined"} (Go), .nonRetryable("CardDeclined") (Java). retryIf(failure, attempt) decides case by case.

What the caller sees

The failure travels as an envelope: its message, type, code and details reach the caller in any language. A step that failed for good raises StepFailed in the workflow, with the step's failure as its cause:

try {
  await ctx.step("charge", chargeCard);
} catch (e) {
  if (e instanceof StepError && e.cause.code === "card_declined") return { status: "declined" };
  throw e;
}

A workflow that throws fails with WorkflowFailed, its failure as the cause; a child's failure reaches its parent as ChildFailed. Each wraps the one below, so the original type and code survive any number of levels, across languages: a Go workflow's CardDeclined is the TypeScript caller's e.cause.cause.type.

Timeouts

timeout bounds one attempt; heartbeatTimeout fails an attempt that has not reported progress (s.heartbeat(progress)) for that long, so a long step can be cut short when it hangs rather than when it ends. Both are retried under the policy. A workflow's timeout bounds the whole run and ends it TimedOut.

Pausing instead of failing

Some failures outlast any retry policy but not a fix: a bug, an expired credential, a service that is down for the afternoon. Pause the run when a step's retries run out, instead of failing it, and resume it once the cause is fixed. The run continues as if the step had succeeded the first time; its parent and callers keep waiting meanwhile.

await ctx.step("charge", (s) => payments.charge(order, { idempotencyKey: s.key }), {
  retry: { attempts: 5, onExhausted: "pause" },
});
// Once payments are back:
await engine.run("order-1").resume();

onExhausted: "pause" on the engine makes it every step's default. Final failures still fail at once. Paused runs show as paused in listings and on the UI's Needs attention screen, and fusee runs bulk resume --status paused resumes them all; see pausing when retries run out.

When retrying is not enough

Retries cover a failure that goes away. For one that does not, undo what the run already did: see sagas. For a step that must not run twice at all, make the service idempotent with the key; Fusee cannot make the outside world exactly-once by itself, only make the attempts and their order exact.

On this page