> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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`.

```ts theme={null}
const ticketCreated = workflow<TicketData>({
  name: "ticket.created",
  retry: { maxAttempts: 3 },
  handler: async (ctx) => {
    await ctx.step.run("triage", () => triageTicket(ctx.event.data));
  },
});
```

| Property         | Type                                   | Default   | Description                                                                                                                                        |
| ---------------- | -------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maxAttempts`    | `number`                               | `1`       | Total 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)`.  |
| `initialDelayMs` | `number`                               | `1000`    | The base delay before the second attempt (and the unit the backoff shape multiplies).                                                              |
| `maxDelayMs`     | `number`                               | `30000`   | An 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:

```ts theme={null}
const syncInventory = workflow({
  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`](#retryaftererror) with the delay you want.

Two other delays exist and are not this one:

| Retry                     | Shape                                                                             | Where                                                                         |
| ------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Step retry                | `backoff` shape between attempts (fixed 1000 ms by default), up to `maxAttempts`. | This page.                                                                    |
| Runner unreachable        | Fixed 1000 ms between attempts, 3 attempts, then the run fails.                   | [Transport errors](#transport-errors) below.                                  |
| Outbound webhook delivery | Exponential backoff, a separate attempt budget.                                   | [Webhooks](/integrations/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:

```ts theme={null}
const deployApp = workflow({
  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.

```ts theme={null}
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`.

```ts theme={null}
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();
});
```

| Argument     | Type                       | Description                                                                                               |
| ------------ | -------------------------- | --------------------------------------------------------------------------------------------------------- |
| `message`    | `string`                   | The error message recorded on the failed attempt.                                                         |
| `retryAfter` | `string \| number \| Date` | When 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`.

```ts theme={null}
const capturePayment = workflow<{ 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));
  },
});
```

| Property    | Type                  | Description                                                          |
| ----------- | --------------------- | -------------------------------------------------------------------- |
| `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.

```sh theme={null}
curl "$DURATON_URL/runs?status=failed" -H "Authorization: Bearer $DURATON_API_KEY"
```

| Action             | Endpoint                                                                      | Description                                                                                                             |
| ------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| List               | [`GET /runs?status=failed`](/reference/api/runs)                              | Every permanently failed run.                                                                                           |
| Inspect            | [`GET /runs/{id}`](/reference/api/runs)                                       | The run with its terminal error.                                                                                        |
| Replay             | [`POST /runs/{id}/replay`](/reference/api/control#replay-semantics)           | A fresh run from the same trigger, from the first step.                                                                 |
| Replay from a step | [`POST /runs/{id}/retry-from-step`](/reference/api/control#retry-from-a-step) | A fresh run that carries the completed steps before the chosen one, so the work that already succeeded is not repeated. |
| Replay in bulk     | [`POST /runs/bulk-replay`](/reference/api/control#bulk-replay)                | Replays 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.

<Note>
  See the **error handling** example running end to end in [Examples](/start/recipes#retry-a-flaky-call-fail-fast-on-a-bad-one).
</Note>
