workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
233 lines (177 loc) • 8.86 kB
text/mdx
---
title: Rate Limiting & Retries
description: Handle 429 responses and transient failures with RetryableError and exponential backoff.
type: guide
summary: When an external API returns 429, throw RetryableError with the Retry-After value so the workflow runtime automatically reschedules the step after the specified delay.
---
<CopyPrompt
text="Add durable rate-limit handling to external API steps. Import `RetryableError`, `FatalError`, `getStepMetadata`, and `sleep` from `workflow` as needed. In each HTTP-calling "use step" helper, if the response status is 429, read the `Retry-After` header and throw `new RetryableError("Rate limited", { retryAfter })`. For transient 5xx/network failures, throw `RetryableError` with an exponential delay based on `getStepMetadata().attempt`. For permanent 4xx failures, throw `FatalError`. If the dependency is completely down, add a workflow-level circuit breaker that `await sleep("30s")` before probing again. Make requests idempotent with stable keys, and verify 429, 5xx, fatal 4xx, and circuit-breaker behavior."
/>
Use this pattern when calling external APIs that enforce rate limits. Instead of writing manual retry loops, throw `RetryableError` with a `retryAfter` value and let the workflow runtime handle rescheduling.
## When to use this
- Calling APIs that return 429 (Too Many Requests) with `Retry-After` headers
- Any step that hits transient failures and needs backoff
- Syncing data with third-party services (Stripe, CRMs, scrapers)
## Pattern: RetryableError with Retry-After
A step function calls an external API. On 429, it reads the `Retry-After` header and throws `RetryableError`. The runtime reschedules the step automatically.
```typescript
import { RetryableError } from "workflow";
declare function fetchFromCrm(contactId: string): Promise<unknown>; // @setup
declare function upsertToWarehouse(contactId: string, contact: unknown): Promise<void>; // @setup
export async function syncContact(contactId: string) {
"use workflow";
const contact = await fetchFromCrm(contactId);
await upsertToWarehouse(contactId, contact);
return { contactId, status: "synced" };
}
```
### Step function with rate limit handling
```typescript
import { RetryableError } from "workflow";
async function fetchFromCrm(contactId: string) {
"use step";
const res = await fetch(`https://crm.example.com/contacts/${contactId}`);
if (res.status === 429) { // [!code highlight]
const retryAfter = res.headers.get("Retry-After");
throw new RetryableError("Rate limited by CRM", { // [!code highlight]
retryAfter: retryAfter ? parseInt(retryAfter) * 1000 : "1m",
});
}
if (!res.ok) throw new Error(`CRM returned ${res.status}`);
return res.json();
}
async function upsertToWarehouse(contactId: string, contact: unknown) {
"use step";
await fetch(`https://warehouse.example.com/contacts/${contactId}`, {
method: "PUT",
body: JSON.stringify(contact),
});
}
```
## Pattern: exponential backoff
Use `getStepMetadata()` to access the current attempt number and calculate increasing delays:
```typescript
import { RetryableError, getStepMetadata } from "workflow";
async function callFlakeyApi(endpoint: string) {
"use step";
const { attempt } = getStepMetadata(); // [!code highlight]
const res = await fetch(endpoint);
if (res.status === 429 || res.status >= 500) {
throw new RetryableError(`Request failed (${res.status})`, { // [!code highlight]
retryAfter: (attempt ** 2) * 1000, // 1s, 4s, 9s... // [!code highlight]
});
}
return res.json();
}
```
## Pattern: circuit breaker with sleep
When a dependency is completely down, stop hitting it for a cooldown period using `sleep()`, then probe with a single test request:
```typescript
import { sleep } from "workflow";
export async function circuitBreaker(maxRequests: number = 10) {
"use workflow";
let state: "closed" | "open" | "half-open" = "closed";
let consecutiveFailures = 0;
const FAILURE_THRESHOLD = 3;
for (let i = 1; i <= maxRequests; i++) {
if (state === "open") {
await sleep("30s"); // Durable cooldown // [!code highlight]
state = "half-open";
}
const success = await callService(i);
if (success) {
consecutiveFailures = 0;
if (state === "half-open") state = "closed";
} else {
consecutiveFailures++;
if (consecutiveFailures >= FAILURE_THRESHOLD) {
state = "open";
consecutiveFailures = 0;
}
}
}
return { status: state === "closed" ? "recovered" : "failed" };
}
async function callService(requestNum: number): Promise<boolean> {
"use step";
try {
const res = await fetch("https://payment-gateway.example.com/charge");
return res.ok;
} catch {
return false;
}
}
```
## Pattern: custom max retries
Override the default retry count (3) for steps that need more or fewer attempts:
```typescript
async function fetchWithRetries(url: string) {
"use step";
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
// Allow up to 10 retry attempts
fetchWithRetries.maxRetries = 10; // [!code highlight]
```
## Application-level retry
Sometimes you need retry logic at the workflow level -- wrapping a step call with your own backoff instead of relying on the framework's built-in `RetryableError`. This is useful when you want full control over retry conditions, delays, and error filtering.
```typescript
interface RetryOptions {
maxRetries?: number;
baseDelay?: number;
maxDelay?: number;
shouldRetry?: (error: Error, attempt: number) => boolean;
}
async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const { maxRetries = 3, baseDelay = 2000, maxDelay = 10000, shouldRetry } = options;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
const isLastAttempt = attempt === maxRetries;
if (isLastAttempt || (shouldRetry && !shouldRetry(lastError, attempt + 1))) {
throw lastError;
}
// Exponential backoff with jitter
const delay = Math.min(baseDelay * 2 ** attempt * (0.5 + Math.random() * 0.5), maxDelay);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
```
Use it in a workflow to wrap step calls:
```typescript
declare function withRetry<T>(fn: () => Promise<T>, options?: { maxRetries?: number; shouldRetry?: (error: Error) => boolean }): Promise<T>; // @setup
declare function downloadFile(url: string): Promise<any>; // @setup
export async function downloadWithRetry(url: string) {
"use workflow";
const result = await withRetry(() => downloadFile(url), { // [!code highlight]
maxRetries: 5,
shouldRetry: (error) => error.message.includes("Timeout"),
});
return result;
}
```
**When to use this vs `RetryableError`/`FatalError`:**
- **`RetryableError`** runs inside a step -- the framework reschedules the step after the delay. Use it for transient HTTP errors (429, 503) where the runtime should handle backoff.
- **Application-level retry** wraps the step call from the workflow. Use it when you need custom retry conditions, want to retry across different steps, or when you're building a library and prefer not to depend on workflow-specific error classes.
## Tips
- **`RetryableError` is for transient failures.** Use it when the request might succeed on a later attempt (429, 503, network timeout).
- **`FatalError` is for permanent failures.** Use it when retrying won't help (404, 401, invalid input). This skips all remaining retries.
- **The `retryAfter` option accepts** a millisecond number, a duration string (`"1m"`, `"30s"`), or a `Date` object.
- **Steps retry up to 3 times by default.** Set `fn.maxRetries = N` to change this per step function.
- **Don't write manual sleep-retry loops.** The runtime handles scheduling natively with `RetryableError` -- it's more efficient and survives cold starts.
## Key APIs
- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
- [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the current attempt number and step ID
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns