---
title: Errors & Retrying
description: Customize retry behavior with FatalError and RetryableError for robust error handling.
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
---

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="/docs/foundations/idempotency">Idempotency</a> for
  more information.
</Callout>

## Intentional Errors

When your step needs to intentionally throw an error and skip retrying, simply throw a [`FatalError`](/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`](/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`](/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>

## 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); // "USER_ERROR", "RUNTIME_ERROR", 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`) |
| `RUNTIME_ERROR` | An internal runtime error such as a corrupted event log or missing data. 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 via the CLI (`npx workflow inspect runs <runId>`) in the `error.code` field, and as an OTEL 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](/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;
  }
}
```
