workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
228 lines (169 loc) • 6.92 kB
text/mdx
---
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 may include a `code` that classifies the failure. You can access it programmatically via the `Run` class:
```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.cause.code); // "USER_ERROR", "RUNTIME_ERROR", or undefined
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;
}
}
```