Concepts

Retries & failure handling

Survive a flaky call without losing the run - only the failing step retries, and onFailure handlers plus replay cover the ones that run out.

When a step throws, Duraton retries that step - not the whole workflow. Steps that already succeeded keep their recorded results and are not re-executed. A workflow does not retry unless it opts in with retry.

const ticketCreated = defineWorkflow<TicketData>({
  name: "ticket.created",
  retry: { maxAttempts: 3 },
  handler: async (ctx) => {
    await ctx.step.run("triage", () => triageTicket(ctx.event.data));
  },
});
PropertyTypeDefaultDescription
maxAttemptsnumber1Total times a step may run, including the first. 1 means the step runs once and a throw fails the run; 3 means one run plus up to two retries.
backoff"fixed" | "linear" | "exponential""fixed"How the delay between attempts grows. fixed is constant; linear is initialDelay * attempt; exponential is initialDelay * 2^(attempt-1).
initialDelayMsnumber1000The base delay before the second attempt (and the unit the backoff shape multiplies).
maxDelayMsnumber30000An upper bound the computed delay is capped at, so exponential backoff cannot grow without limit.

What happens on failure

  1. A step throws.
  2. Duraton waits the backoff delay, then runs that one step again.
  3. This repeats until the step succeeds or reaches maxAttempts.
  4. If the step fails on its last attempt, the run fails.

Backoff

By default Duraton waits 1000 ms between step attempts, the same before every attempt. Set backoff to shape how the delay grows with each attempt, initialDelayMs to change the base delay, and maxDelayMs to cap it:

const syncInventory = defineWorkflow({
  name: "inventory.sync",
  // Back off 1s, 2s, 4s, 8s... capped at 30s, over five attempts.
  retry: { maxAttempts: 5, backoff: "exponential", initialDelayMs: 1000, maxDelayMs: 30000 },
  handler: async (ctx) => {
    await ctx.step.run("pull", () => pullInventory());
  },
});

To override the delay for a single attempt at runtime (for example honoring an upstream 429's Retry-After), throw RetryAfterError with the delay you want.

Two other delays exist and are not this one:

RetryShapeWhere
Step retrybackoff shape between attempts (fixed 1000 ms by default), up to maxAttempts.This page.
Runner unreachableFixed 1000 ms between attempts, 3 attempts, then the run fails.Transport errors below.
Outbound webhook deliveryExponential backoff, a separate attempt budget.Webhooks - a different subsystem, not step retries.

Per-step retry

A workflow's retry sets the default for every step. Pass a retry option to a single ctx.step.run to override it for that step alone - useful for a polling step that needs many short attempts without forcing that budget onto the rest of the run:

const deployApp = defineWorkflow({
  name: "app.deploy",
  retry: { maxAttempts: 3 },
  handler: async (ctx) => {
    await ctx.step.run("dispatch", () => dispatchDeploy());
    // Poll for readiness independently: up to 30 attempts, 5s apart.
    await ctx.step.run("verify-tls", () => checkTls(), {
      retry: { maxAttempts: 30, backoff: "fixed", initialDelayMs: 5000 },
    });
  },
});

The step's own policy governs its attempt budget and backoff; every other step keeps the workflow default. The per-step retry option and configurable backoff are available in the TypeScript SDK.

Controlling retries from a step

Two error types let a step override the default retry behavior. Import them from the Duraton SDK.

NonRetriableError

Fail the run immediately with NonRetriableError, skipping any remaining attempts. Use it for failures retrying cannot fix, such as a validation error or missing configuration.

import { NonRetriableError } from "@duraton/sdk";

await ctx.step.run("validate", () => {
  if (!apiKey) throw new NonRetriableError("missing API key");
});

RetryAfterError

Retry after a delay you choose with RetryAfterError instead of the policy's configured backoff, for example honoring an upstream 429's Retry-After.

import { RetryAfterError } from "@duraton/sdk";

await ctx.step.run("call-upstream", async () => {
  const res = await fetch(url);
  if (res.status === 429) throw new RetryAfterError("rate limited", "30s");
  return res.json();
});
ArgumentTypeDescription
messagestringThe error message recorded on the failed attempt.
retryAfterstring | number | DateWhen the next attempt runs: a duration string ("30s"), a number of milliseconds, or an absolute Date.

RetryAfterError does not grant extra attempts - it only changes when the next attempt runs. Once the step reaches maxAttempts the run fails as usual.

Transport errors

If Duraton cannot reach your runner at all (the process is down, or it returns a server error), the invoke is retried on its own budget: 3 attempts, 1000 ms apart, independent of maxAttempts. A network blip does not consume a step's retry budget. After the third failed invoke, the run fails.

onFailure

Declare an onFailure handler to run compensation or notification logic when a run fails. It fires once the run has exhausted its retries (or hit a non-retriable error) and been marked failed, and it receives the original event plus ctx.error.

const capturePayment = defineWorkflow<{ paymentId: string }>({
  name: "payment.capture",
  retry: { maxAttempts: 3 },
  handler: async (ctx) => {
    await ctx.step.run("capture", () => capture(ctx.event.data.paymentId));
  },
  onFailure: async (ctx) => {
    await ctx.step.run("void-hold", () => voidHold(ctx.event.data.paymentId));
  },
});
PropertyTypeDescription
ctx.error{ message, stack? }The terminal error that failed the run. Set only inside onFailure.

onFailure is itself a durable execution: its steps are recorded and retried like any handler. It cannot un-fail the run - the failed run stays failed. Use it to compensate (release a hold, reverse a write) or to notify, not to retry the work.

Failed runs

A run is marked failed only once it is terminal and out of retries - a run still retrying stays non-terminal - so the set of failed runs is exactly the set of runs that permanently failed, each carrying its terminal error.

curl "$DURATON_URL/runs?status=failed" -H "Authorization: Bearer $DURATON_API_KEY"
ActionEndpointDescription
ListGET /runs?status=failedEvery permanently failed run.
InspectGET /runs/{id}The run with its terminal error.
ReplayPOST /runs/{id}/replayA fresh run from the same trigger, from the first step.
Replay from a stepPOST /runs/{id}/retry-from-stepA fresh run that carries the completed steps before the chosen one, so the work that already succeeded is not repeated.
Replay in bulkPOST /runs/bulk-replayReplays every run matching a filter.

In the console, failed runs appear in the Runs view with their failure reason inline; the Failed stat tile filters the list to them in one click.

See the error handling example running end to end in Examples.

On this page