Errors & Retrying
By default, errors thrown inside steps are retried. Additionally, Workflow DevKit 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
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!");
}
return response.json();
}
callApi.maxRetries = 5; // Set a custom number of retriesSteps get enqueued immediately after the failure. Read on to see how this can be customized.
When a retried step performs external side effects (payments, emails, API writes), ensure those calls are idempotent to avoid duplicate side effects. See Idempotency for more information.
Intentional Errors
When your step needs to intentionally throw an error and skip retrying, simply throw a FatalError.
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.");
}
return response.json();
}Customize Retry Behavior
When you need to customize the delay on the retry, use RetryableErrorand set the retryAfter property.
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) {
const retryAfter = response.headers.get("Retry-After");
// Delay the retry until after a timeout
throw new RetryableError("Too many requests. Retrying...", {
retryAfter: parseInt(retryAfter),
});
}
return response.json();
}Advanced Example
This final example combines everything we've learnt, along with getStepMetadata.
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,
});
}
if (response.status === 404) {
throw new FatalError("Resource not found. Skipping retries.");
}
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
// Delay the retry until after a timeout
throw new RetryableError("Too many requests. Retrying...", {
retryAfter: parseInt(retryAfter),
});
}
return response.json();
}
callApi.maxRetries = 5;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; they may run more than once.
- Only enqueue a compensation after its forward step succeeds.
// 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;
}
}