---
title: Errors & Retrying
description: Customize retry behavior with FatalError and RetryableError.
type: conceptual
summary: Control how steps handle failures and customize retry behavior.
prerequisites:
  - /docs/foundations/workflows-and-steps
related:
  - /docs/api-reference/workflow/fatal-error
  - /docs/api-reference/workflow/retryable-error
---

# Errors & Retrying



By default, errors thrown inside steps are retried. Additionally, Workflow SDK provides two new types of errors you can use to customize retries.

## Default retrying

By default, steps retry up to 3 times on arbitrary errors. You can customize the number of retries by adding a `maxRetries` property to the step function.

```typescript lineNumbers
async function callApi(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    // Any uncaught error gets retried
    throw new Error("Uncaught exceptions get retried!"); // [!code highlight]
  }

  return response.json();
}

callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
```

Steps get enqueued immediately after a failure. Read on to see how this can be customized.

<Callout type="info">
  When a retried step performs external side effects (payments, emails, API
  writes), ensure those calls are <strong>idempotent</strong> to avoid duplicate
  side effects. See <a href="/v5/docs/foundations/idempotency">Idempotency</a> for
  more information.
</Callout>

## Intentional errors

When your step needs to intentionally throw an error and skip retrying, throw a [`FatalError`](/v5/docs/api-reference/workflow/fatal-error).

```typescript lineNumbers
import { FatalError } from "workflow";

async function callApi(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    // Any uncaught error gets retried
    throw new Error("Uncaught exceptions get retried!");
  }

  if (response.status === 404) {
    throw new FatalError("Resource not found. Skipping retries."); // [!code highlight]
  }

  return response.json();
}
```

## Customize retry behavior

When you need to customize the delay on a retry, use [`RetryableError`](/v5/docs/api-reference/workflow/retryable-error) and set the `retryAfter` property.

```typescript lineNumbers
import { FatalError, RetryableError } from "workflow";

async function callApi(endpoint: string) {
  "use step";

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    throw new Error("Uncaught exceptions get retried!");
  }

  if (response.status === 404) {
    throw new FatalError("Resource not found. Skipping retries.");
  }

  if (response.status === 429) {
    throw new RetryableError("Rate limited. Retrying...", { // [!code highlight]
      retryAfter: "1m", // Duration string // [!code highlight]
    }); // [!code highlight]
  }

  return response.json();
}
```

## Advanced example

This final example combines everything we've learned, along with [`getStepMetadata`](/v5/docs/api-reference/workflow/get-step-metadata).

```typescript lineNumbers
import { FatalError, RetryableError, getStepMetadata } from "workflow";

async function callApi(endpoint: string) {
  "use step";

  const metadata = getStepMetadata();

  const response = await fetch(endpoint);

  if (response.status >= 500) {
    // Exponential backoffs
    throw new RetryableError("Backing off...", {
      retryAfter: (metadata.attempt ** 2) * 1000,  // [!code highlight]
    });
  }

  if (response.status === 404) {
    throw new FatalError("Resource not found. Skipping retries.");
  }

  if (response.status === 429) {
    throw new RetryableError("Rate limited. Retrying...", {
      retryAfter: new Date(Date.now() + 60000),  // Date instance // [!code highlight]
    });
  }

  return response.json();
}
callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
```

<Callout type="info">
  Setting <code>maxRetries = 0</code> means the step will run once but will not
  be retried on failure. The default is <code>maxRetries = 3</code>, meaning the
  step can run up to 4 times total (1 initial attempt + 3 retries).
</Callout>

## Serialization failures

A step whose arguments or return value cannot be [serialized](/v5/docs/foundations/serialization) fails like a step whose body threw a `FatalError`: the failure is deterministic, so it skips the retry loop, and a `try/catch` around the step call observes the `SerializationError`:

```typescript lineNumbers
async function someStep(input: unknown) {
  "use step";
  return input;
}

export async function myWorkflow(input: unknown) {
  "use workflow";

  try {
    await someStep(input);
  } catch (err) {
    if ((err as Error).name === "SerializationError") {
      // e.g. `Failed to serialize step arguments at path "..."`
    }
  }
}
```

Uncaught, the run fails immediately with the `USER_ERROR` code, without retrying. See [serialization-failed](/v5/docs/errors/serialization-failed) for common causes and fixes.

## Backend connection failures

On Vercel, backend connection failures and interrupted event streams use the existing retry policies, even when their error codes are unrecognized. This lets workflows recover from network failures instead of immediately failing with `USER_ERROR`. Persistent failures can still exhaust the retry budget.

The SDK also replaces its shared events connection pool after repeated HTTP/2 session failures. Invalid backend URLs, including unsupported protocols and embedded credentials, fail immediately. Fetch requests to blocked ports or with unsupported headers (such as `Expect`) also fail without retrying. Interrupted event writes retain their existing retries; caller cancellations do not trigger another write.

A connection failure does not prove that the backend rejected a write: it may have accepted it before the response was lost. Continue to make step side effects [idempotent](/v5/docs/foundations/idempotency).

Failed-run logs include the underlying error causes and their codes, exposing socket, DNS, or TLS errors behind messages such as `TypeError: fetch failed`. If a cause cannot be read, the log includes `[unavailable cause]` and the run can still be recorded as failed.

## Error codes

When a workflow run fails, the error includes an `errorCode` that classifies the failure, alongside the original thrown value (preserved as `cause`):

```typescript lineNumbers
import { WorkflowRunFailedError } from "@workflow/errors";
import { start } from "workflow/api";

const run = await start(myWorkflow, [input]);

try {
  const result = await run.returnValue;
} catch (err) {
  if (WorkflowRunFailedError.is(err)) {
    console.log(err.errorCode); // e.g. "USER_ERROR", "MAX_EVENTS_EXCEEDED", or undefined
    // `cause` is the original thrown value, hydrated through the workflow
    // serialization pipeline. It can be any thrown value, so check shape.
    if (err.cause instanceof Error) {
      console.log(err.cause.message); // The error message
    }
  }
}
```

| Code                      | Meaning                                                                                                                                                                                                                                       |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `USER_ERROR`              | An error thrown in your workflow or step code (including propagated step failures like `FatalError`)                                                                                                                                          |
| `MAX_EVENTS_EXCEEDED`     | The run reached the World's per-run event ceiling (25,000 on the Local and Vercel Worlds). Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows); see [Limits](/v5/docs/configuration/runtime-tuning#limits)          |
| `MAX_DELIVERIES_EXCEEDED` | The run exceeded the maximum number of queue deliveries                                                                                                                                                                                       |
| `REPLAY_TIMEOUT`          | A workflow replay exceeded the maximum allowed duration                                                                                                                                                                                       |
| `REPLAY_DIVERGENCE`       | A replay could not consume the event log deterministically, usually because of non-deterministic workflow code.                                                                                                                               |
| `CORRUPTED_EVENT_LOG`     | The event log cannot be replayed: it contains orphaned or mismatched events, or one of its stored payloads is no longer readable from the World's storage. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) |
| `WORLD_CONTRACT_ERROR`    | A World response violated the SDK contract; points at a World implementation bug                                                                                                                                                              |
| `RUNTIME_ERROR`           | An internal runtime error. If you see this, please [file an issue](https://github.com/vercel/workflow/issues)                                                                                                                                 |

<Callout type="info">
  The error code is also available on the run entity through the Workflow CLI (`npx workflow inspect runs <runId>`) in the `error.code` field, and as an OpenTelemetry span attribute (`workflow.error.code`) for observability.
</Callout>

## Rolling back failed steps

When a workflow fails partway through, it can leave the system in an inconsistent state.
A common pattern to address this is "rollbacks": for each successful step, record a corresponding rollback action that can undo it.
If a later step fails, run the rollbacks in reverse order to roll back.

Key guidelines:

* Make rollbacks steps as well, so they are durable and benefit from retries.
* Ensure rollbacks are [idempotent](/v5/docs/foundations/idempotency); they may run more than once.
* Only enqueue a compensation after its forward step succeeds.

```typescript lineNumbers
// Forward steps
async function reserveInventory(orderId: string) {
  "use step";
  // ... call inventory service to reserve ...
}

async function chargePayment(orderId: string) {
  "use step";
  // ... charge the customer ...
}

// Rollback steps
async function releaseInventory(orderId: string) {
  "use step";
  // ... undo inventory reservation ...
}

async function refundPayment(orderId: string) {
  "use step";
  // ... refund the charge ...
}

export async function placeOrderSaga(orderId: string) {
  "use workflow";

  const rollbacks: Array<() => Promise<void>> = [];

  try {
    await reserveInventory(orderId);
    rollbacks.push(() => releaseInventory(orderId));

    await chargePayment(orderId);
    rollbacks.push(() => refundPayment(orderId));

    // ... more steps & rollbacks ...
  } catch (e) {
    for (const rollback of rollbacks.reverse()) {
      await rollback();
    }
    // Rethrow so the workflow records the failure after rollbacks
    throw e;
  }
}
```


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)