Error Handling
Retry and Recovery Patterns
Exponential backoff, circuit breakers, and idempotency for resilient agent-driven workflows
Last verified 2026-09-25
Summary
Agents must retry safely: exponential backoff with jitter spreads retries over time instead of hammering a degraded service. Circuit breakers stop retrying after N failures. Idempotency keys prevent double-submission on retries. Distinguish retriable (429, 5xx) from non-retriable errors (4xx validation) and trust is_retriable from the problem body.
delay = base * 2^attempt + random(0, base) (capped at 60s)- Exponential backoff: 1s, 2s, 4s, 8s, 16s (with jitter and 60s cap)
- Full jitter: random(0, base * 2^attempt) for large populations
- Circuit breaker: stop after N consecutive failures, cool down, probe
- Rate limit handling: use Retry-After, plus the IETF draft's RateLimit and RateLimit-Policy fields where available (X-RateLimit-Remaining is the older, unstandardized convention)
- Idempotency-Key required for write operations
- is_retriable from problem body overrides status code heuristics
Agents retry. Unlike humans, who might manually retry a failed request once or twice, an agent following a retry loop will retry at whatever cadence its logic allows - which, without constraints, can become a thundering herd that makes an already-degraded service worse. Retry patterns for agent systems must fail safely without amplifying failures.
Exponential Backoff with Jitter
The baseline retry algorithm for transient failures is exponential backoff: each attempt waits longer than the previous one, so a sustained failure results in increasingly infrequent retries rather than a constant bombardment.
The formula:
delay = base * 2^attempt + random(0, base)The random(0, base) term is jitter - a random offset added to prevent synchronized retries when multiple agents encounter the same error at the same time.
function backoffDelay(attempt: number, base = 1000): number {
const exponential = base * Math.pow(2, attempt);
const jitter = Math.random() * base;
const delay = exponential + jitter;
const maxDelay = 60 * 1000; // Cap at 60 seconds
return Math.min(delay, maxDelay);
}
async function withRetry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; base?: number } = {},
): Promise<T> {
const { maxAttempts = 4, base = 1000 } = options;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
const isLast = attempt === maxAttempts - 1;
const retriable = isRetriable(err);
if (isLast || !retriable) throw err;
const delay = backoffDelay(attempt, base);
await sleep(delay);
}
}
throw new Error("Unreachable");
}import asyncio
import random
from typing import TypeVar, Callable, Awaitable
T = TypeVar("T")
def backoff_delay(attempt: int, base: float = 1.0, max_delay: float = 60.0) -> float:
exponential = base * (2 ** attempt)
jitter = random.uniform(0, base)
return min(exponential + jitter, max_delay)
async def with_retry(
fn: Callable[[], Awaitable[T]],
max_attempts: int = 4,
base: float = 1.0,
) -> T:
for attempt in range(max_attempts):
try:
return await fn()
except Exception as err:
is_last = attempt == max_attempts - 1
if is_last or not is_retriable(err):
raise
delay = backoff_delay(attempt, base)
await asyncio.sleep(delay)
raise RuntimeError("Unreachable")The delay sequence for a 1-second base:
| Attempt | Exponential | Jitter (max) | Total (max) |
|---|---|---|---|
| 0 | 1s | 1s | 2s |
| 1 | 2s | 1s | 3s |
| 2 | 4s | 1s | 5s |
| 3 | 8s | 1s | 9s |
| 4 | 16s | 1s | 17s |
With a 60-second cap, delays plateau rather than growing indefinitely.
Thundering Herd Prevention
When a service goes down and comes back up, every agent that accumulated retries during the outage fires simultaneously. The recovered service receives a traffic spike larger than the one that caused the outage.
Jitter alone is not sufficient when the population of retrying agents is large. Full jitter - using a random value across the entire backoff range rather than adding jitter to an exponential value - spreads retries more evenly:
// Exponential backoff with jitter: base * 2^n + random(0, base)
// Good for small retry populations
// Full jitter: random(0, base * 2^n)
// Better for large retry populations - fully randomizes within the window
function fullJitterDelay(attempt: number, base = 1000): number {
const cap = base * Math.pow(2, attempt);
return Math.random() * Math.min(cap, 60 * 1000);
}Full jitter is appropriate when many independent agents might be retrying the same endpoint concurrently. For a single agent retrying its own request, standard exponential backoff with jitter is sufficient.
Circuit Breakers
A circuit breaker stops retrying entirely after a configured number of consecutive failures. Rather than continuing to send requests to a service that has failed N times in a row, the circuit "opens" and all further calls fail immediately for a cooldown period. After the cooldown, a single probe request tests whether the service has recovered.
type CircuitState = "closed" | "open" | "half-open";
class CircuitBreaker {
private state: CircuitState = "closed";
private failureCount = 0;
private lastFailureTime: number | null = null;
constructor(
private readonly failureThreshold: number = 5,
private readonly cooldownMs: number = 30 * 1000,
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
const elapsed = Date.now() - (this.lastFailureTime ?? 0);
if (elapsed < this.cooldownMs) {
throw new CircuitOpenError(
`Circuit open. Retry after ${Math.ceil((this.cooldownMs - elapsed) / 1000)}s`,
);
}
// Cooldown elapsed - allow one probe request
this.state = "half-open";
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = "closed";
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = "open";
}
}
}
// Usage
const invoiceServiceBreaker = new CircuitBreaker(5, 30_000);
async function getInvoice(id: string) {
return invoiceServiceBreaker.call(() => fetch(`https://invoices.example.com/invoices/${id}`));
}When the circuit is open, CircuitOpenError carries the remaining cooldown time. Agents should treat an open circuit as a non-retriable error - they should escalate or return a partial result rather than sitting in a wait loop.
Checking Status Before Escalating
No formal spec governs status pages, but a machine-readable one changes what an agent does after a retry budget is exhausted. Instead of escalating every 5xx run identically, an agent that can check status first tells the difference between "your integration broke" and "the provider is down; back off and retry later without paging anyone."
Many providers already expose this. Status pages built on Atlassian Statuspage - used by OpenAI, and widely elsewhere - serve a public, unauthenticated /api/v2/summary.json alongside the human-readable page, with an overall status.indicator/status.description and a components array giving per-component status:
curl -s https://status.openai.com/api/v2/summary.json | jq '.status, .components[0]'{ "description": "All Systems Operational", "indicator": "none" }
{ "id": "...", "name": "Ads Manager", "status": "operational" }This is a convention, not a standard, and it varies by status-page vendor. If you publish one, link it from your API catalog so an agent doesn't have to guess the URL, and prefer a machine-readable summary endpoint over a status page that only renders as HTML - the same no-JS-content principle that applies to every other agent-facing surface.
Rate Limit Handling
Rate limits are a specific class of retriable error with explicit timing information. The Retry-After header on a 429 response specifies exactly how long to wait.
async function withRateLimitHandling(fn: () => Promise<Response>): Promise<Response> {
const response = await fn();
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const waitSeconds = parseInt(retryAfter, 10);
if (!isNaN(waitSeconds)) {
await sleep(waitSeconds * 1000);
return fn(); // Single retry after the specified wait
}
}
// No Retry-After - fall back to exponential backoff
await sleep(backoffDelay(0));
return fn();
}
return response;
}Read X-RateLimit-Remaining on every successful response to detect approaching limits before hitting them:
class RateLimitAwareClient {
private remaining = Infinity;
private resetAt: Date | null = null;
async request(url: string, options?: RequestInit): Promise<Response> {
// If near limit, wait proactively
if (this.remaining < 5 && this.resetAt) {
const waitMs = this.resetAt.getTime() - Date.now();
if (waitMs > 0) await sleep(waitMs);
}
const response = await fetch(url, options);
// Update remaining quota from headers
const remaining = response.headers.get("X-RateLimit-Remaining");
const reset = response.headers.get("X-RateLimit-Reset");
if (remaining !== null) this.remaining = parseInt(remaining, 10);
if (reset !== null) this.resetAt = new Date(parseInt(reset, 10) * 1000);
return response;
}
}The IETF RateLimit Header Fields
X-RateLimit-* above is a widespread but unstandardized convention - every picks its own field names and semantics. The IETF HTTPAPI working group's draft-ietf-httpapi-ratelimit-headers standardizes two fields instead. The latest revision is draft -11 (23 May 2026); it remains an Internet-Draft, not an RFC - field names could still change before it ships, so treat it as the emerging convention rather than a settled contract.
RateLimit - current quota state, as an HTTP structured field:
RateLimit: "default"; r=42; t=30r is the remaining quota, t is the number of seconds until the window resets.
RateLimit-Policy - the policy itself, independent of current state:
RateLimit-Policy: "default"; q=100; w=60q is the quota, w is the window in seconds.
Emit both alongside Retry-After on a 429, and on successful responses so clients can throttle proactively:
function setRateLimitHeaders(
res: Response,
remaining: number,
windowSeconds: number,
quota: number,
) {
res.headers.set("RateLimit", `"default"; r=${remaining}; t=${windowSeconds}`);
res.headers.set("RateLimit-Policy", `"default"; q=${quota}; w=${windowSeconds}`);
}Clients parse r and t out of the structured field the same way they'd read any other rate limit header:
function parseRateLimit(header: string | null): { remaining: number; resetSeconds: number } | null {
if (!header) return null;
const r = /r=(\d+)/.exec(header);
const t = /t=(\d+)/.exec(header);
if (!r || !t) return null;
return { remaining: Number(r[1]), resetSeconds: Number(t[1]) };
}Support both while the draft is unsettled: read RateLimit when present, fall back to X-RateLimit-Remaining for APIs that haven't migrated.
Idempotency Keys
A retried write without an guarantee can create duplicate resources; see Idempotency for the full contract. The rule for any retry loop is that every write it may re-issue must carry an Idempotency-Key, generated once per logical operation and sent unchanged on each attempt, so the server can deduplicate.
Idempotency keys matter most for payments, invoice creation, and any operation that consumes a limited resource or triggers an external side effect (email, SMS, ).
Distinguishing Retriable from Non-Retriable Errors
Not all errors should be retried. Retrying a 400 Validation Error or a 403 Forbidden will never succeed - the request itself is wrong, not the timing.
function isRetriable(err: unknown): boolean {
if (err instanceof ApiError) {
// These are never retriable
if ([400, 401, 403, 404, 410, 422].includes(err.status)) {
return false;
}
// Check the problem body's explicit flag
if (err.problem?.is_retriable === false) {
return false;
}
// 429 and 5xx are retriable
if (err.status === 429 || err.status >= 500) {
return true;
}
}
// Network errors (ECONNRESET, ETIMEDOUT) are retriable
if (err instanceof TypeError && err.message.includes("fetch")) {
return true;
}
return false;
}The is_retriable field in the Problem Details body takes precedence over the status code heuristic. A 422 with is_retriable: true indicates the server believes the request could succeed after modification. A 503 with is_retriable: false means the service is permanently unavailable (decommissioned, not transient).
Checklist
- Retry logic uses exponential backoff:
base * 2^attempt + random(0, base) - Jitter is applied on every retry
- Maximum delay is capped (60 seconds recommended)
- Circuit breakers stop retrying after N consecutive failures
- A machine-readable status endpoint exists and is linked from the , so agents can distinguish an outage from a broken integration
- 429 responses use the
Retry-Afterheader value as the wait time -
RateLimit/RateLimit-Policy(orX-RateLimit-Remainingas a fallback) are tracked to throttle proactively before hitting limits - All write operations that may be retried use idempotency keys
- Idempotency key is generated once per logical operation - not per attempt
- Non-retriable errors (4xx validation, auth failures) are not retried
-
is_retriablefrom the problem body overrides status code heuristics
Related Pages
- RFC 9457 Problem Details - the standard error format
- Agent extensions -
is_retriableandretry_after_msfields - Idempotency - safe retries for write operations
- Designing Errors for Agent Recovery - structuring errors to enable this logic
- CLI Error Design - applying these patterns to command-line tools