Agent Surface

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:

AttemptExponentialJitter (max)Total (max)
01s1s2s
12s1s3s
24s1s5s
38s1s9s
416s1s17s

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=30

r 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=60

q 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-After header value as the wait time
  • RateLimit / RateLimit-Policy (or X-RateLimit-Remaining as 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_retriable from the problem body overrides status code heuristics

On this page