Skip to content

Guide

LLM failover patterns: retry vs fallback vs circuit breaker

Retry the same provider only for a brief, transient error and only inside the caller's deadline; fall back to another provider or a tested model when the provider stays unavailable or rate limited; and put a circuit breaker in front of each provider so one that keeps failing stops receiving traffic until a probe shows it has recovered. Use all three together, and never retry or fall back on a spend or quota error.

By INFRO Editorial

Last updated

Last checked

On this page
  1. The three patterns, and what each is for
  2. Classify the failure before you act on it
  3. Retry: few attempts, jittered, inside a deadline
  4. Fallback: to a target you have tested for this task
  5. Circuit breaker: stop paying to rediscover an outage
  6. Streaming: failover ends at the first token
  7. Repeated requests have side effects
  8. Which pattern, when
  9. The same policy, as configuration in INFRO
  10. Questions
  11. Sources

The three patterns, and what each is for

PatternWhat it doesUse it forNever use it for
RetrySends the same request to the same provider again, after a waitA brief network error, a 500, a slow_down, a 529 that clears in secondsSpend or quota errors, 400s, anything once the caller's deadline is near
FallbackSends the request to a different provider account or a different modelA provider that stays rate limited, overloaded or down while a user waitsSpend limits (it spends around them), untested substitute models
Circuit breakerStops calling a provider that keeps failing, then lets one probe throughOutages longer than one request, so each request stops paying to rediscover them429 rate limits: a busy provider is not a broken one
The three are layers of one policy, not alternatives to each other.

Retries alone make outages worse: every layer that retries a few times multiplies traffic into a provider that is already struggling, and failed requests still count toward your rate limit at OpenAI. Fallback alone wastes time: every request waits for the primary to fail before it moves. The breaker is what makes fallback fast during a long outage.

Classify the failure before you act on it

The question for every failure is whether waiting, or a different target, could plausibly succeed.

FailureRetry same target?Fall back?Counts toward the breaker?
Connection error, DNS, TLS, timeout before a responseOnce, if the deadline allowsYesYes
500, 502, OpenAI 503 server_is_overloadedOnce, after retry-afterYesYes
Anthropic 529 overloaded_errorOnce, brieflyYesYes
429 rate limit or slow_downAfter retry-after, if the caller can waitYes, to another accountNo
429 spend, quota or balance code (OpenAI)NoNoNo
Anthropic tier spend cap (429, no retry-after)NoNoNo
A spend limit you set on Anthropic (400)NoNoNo
400, 422 invalid requestNoUsually noNo
401, 403NoYes, and alertYes: the key is broken
From OpenAI's error-codes guide and Anthropic's errors and rate-limit pages. Check each provider's own documentation for the account you connect.

Two rows trip people up. A 429 is not always a rate limit: OpenAI uses it for six different errors, four of which are about money (the 429 guide lists them). And Anthropic signals a spend limit you set with a 400, so "400 means a malformed request" is not safe either.

Retry: few attempts, jittered, inside a deadline

Both official OpenAI SDKs and Anthropic's SDKs retry twice by default. OpenAI's retry every 429, whatever its code. That default stacks with any retry loop you write and any gateway you put in front, so decide where retries live and turn the others off.

Three rules keep retries honest:

  1. Read the error before you retry, and honour retry-after. Otherwise use exponential backoff with jitter, so a thousand clients do not retry in the same instant.
  2. Fit every attempt inside the caller's deadline. If a chat request gives up after 30 seconds, a 20-second per-attempt timeout leaves the fallback 10 seconds; a 12-second timeout leaves it 18. Choose the per-attempt timeout from the deadline, not the other way round.
  3. Budget retries: cap them as a share of normal traffic, so a widespread failure cannot turn into a retry storm.
import OpenAI, { APIError } from "openai";

// Turn the SDK's own retries off: it retries every 429, including the ones
// that cannot succeed. The loop below reads the error code first.
const client = new OpenAI({ maxRetries: 0, timeout: 30_000 });

// 429s that no amount of waiting fixes: billing, spend limits and quota.
const QUOTA_CODES = new Set([
  "credit_balance_exhausted",
  "organization_spend_limit_exceeded",
  "project_spend_limit_exceeded",
  "organization_usage_limit_exceeded",
  "insufficient_quota",
]);

export function isRetryable(err: unknown): err is APIError {
  if (!(err instanceof APIError)) return false;
  if (err.status === undefined) return true; // connection error or timeout
  if (err.status === 429) return !QUOTA_CODES.has(String(err.code)) && !QUOTA_CODES.has(String(err.type));
  return err.status === 408 || err.status === 409 || err.status >= 500;
}

// Wait what the server asks for; otherwise exponential backoff with jitter.
export function retryDelayMs(err: APIError, attempt: number): number {
  const header = (name: string) => Number(err.headers?.get(name));
  if (header("retry-after-ms") > 0) return header("retry-after-ms");
  if (header("retry-after") > 0) return header("retry-after") * 1000;
  const ceiling = Math.min(500 * 2 ** attempt, 8_000);
  return ceiling / 2 + Math.random() * (ceiling / 2);
}

export async function withRetries<T>(
  call: () => Promise<T>,
  { maxAttempts = 5, maxWaitMs = 20_000 } = {},
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      if (!isRetryable(err) || attempt + 1 >= maxAttempts) throw err;
      const wait = retryDelayMs(err, attempt);
      // A user is waiting: past this, fail over or fail fast instead of sleeping.
      if (wait > maxWaitMs) throw err;
      await new Promise((resolve) => setTimeout(resolve, wait));
    }
  }
}

try {
  const completion = await withRetries(() =>
    client.chat.completions.create({
      model: "gpt-5.4-mini",
      messages: [{ role: "user", content: "Summarize this ticket in one line." }],
    }),
  );
  console.log(completion.choices[0]?.message.content);
} catch (err) {
  if (err instanceof APIError) {
    console.error(`gave up: ${err.status} ${err.code ?? err.type} (request ${err.requestID})`);
    process.exitCode = 1;
  } else {
    throw err;
  }
}
The same retry loop as the 429 guide. Tested: transient errors are retried, spend errors are not, five 5xx end in a clear error.

Fallback: to a target you have tested for this task

Fallback changes who answers. To another account for the same model, behaviour stays the same. To a different model, output length, formatting, tool-call reliability and refusals all shift, so allow it per feature and only for models that passed the same evaluation as the primary. When nothing tested is available, a clear degraded result ("drafting is temporarily unavailable") beats a silent quality change.

import OpenAI, { APIError } from "openai";
import Anthropic from "@anthropic-ai/sdk";

// No retries on OpenAI: a rate-limited provider stays limited for seconds, so
// the fallback IS the retry. Both clients read their keys from the environment.
const openai = new OpenAI({ maxRetries: 0, timeout: 20_000 });
const anthropic = new Anthropic({ maxRetries: 1, timeout: 20_000 });

// Never fail over on these: your own spend or quota limit said stop, and
// moving the traffic to another provider would spend around it.
const QUOTA_CODES = new Set([
  "credit_balance_exhausted",
  "organization_spend_limit_exceeded",
  "project_spend_limit_exceeded",
  "organization_usage_limit_exceeded",
  "insufficient_quota",
]);

function shouldFailOver(err: unknown): boolean {
  if (!(err instanceof APIError)) return false;
  if (err.status === undefined) return true; // connection error or timeout
  if (err.status === 429) return !QUOTA_CODES.has(String(err.code)) && !QUOTA_CODES.has(String(err.type));
  return err.status === 408 || err.status >= 500;
}

export async function answer(prompt: string): Promise<{ text: string; servedBy: string }> {
  try {
    const res = await openai.chat.completions.create({
      model: "gpt-5.4-mini",
      messages: [{ role: "user", content: prompt }],
    });
    return { text: res.choices[0]?.message.content ?? "", servedBy: "openai" };
  } catch (err) {
    if (!shouldFailOver(err)) throw err;
    const res = await anthropic.messages.create({
      model: "claude-haiku-5-5",
      max_tokens: 1024,
      messages: [{ role: "user", content: prompt }],
    });
    const text = res.content.map((block) => (block.type === "text" ? block.text : "")).join("");
    return { text, servedBy: "anthropic" };
  }
}

const { text, servedBy } = await answer("Draft a two-line status update for a delayed order.");
// Log who served it: a fallback that nobody can see becomes a silent quality change.
console.log(`[${servedBy}] ${text}`);
Tested: a 429 moves the request to Anthropic after one OpenAI attempt; a spend-limit 429 does not.

Circuit breaker: stop paying to rediscover an outage

A breaker has three states. Closed: traffic flows and failures are counted in a time window. Open: after enough failures, the provider gets no traffic and requests go straight to the fallback. Half-open: after a cool-down, exactly one probe request goes through; success closes the breaker, failure opens it again.

Starting values that work for most chat traffic: open after 3 to 5 failures within 30 seconds, cool down for 30 seconds, and grow the cool-down if the probe keeps failing. Keep one breaker per provider account, not one per model, and never count a 429: a rate-limited account is busy, and ejecting it for being busy moves its load onto everything else.

import OpenAI, { APIError } from "openai";
import Anthropic from "@anthropic-ai/sdk";

type State = "closed" | "open" | "half-open";

/**
 * Stops sending traffic to a provider that keeps failing, then lets one probe
 * through after a cool-down. Counts only failures that say the provider is
 * unhealthy: a 429 means "busy", not "broken", and must not open the breaker.
 */
export class CircuitBreaker {
  state: State = "closed";
  private failures: number[] = [];
  private openedAt = 0;
  private probing = false;
  private readonly threshold: number;
  private readonly windowMs: number;
  private readonly cooldownMs: number;

  constructor(opts: { threshold: number; windowMs: number; cooldownMs: number }) {
    this.threshold = opts.threshold;
    this.windowMs = opts.windowMs;
    this.cooldownMs = opts.cooldownMs;
  }

  allowRequest(now = Date.now()): boolean {
    if (this.state === "open" && now - this.openedAt >= this.cooldownMs) {
      this.state = "half-open";
      this.probing = false;
    }
    if (this.state === "closed") return true;
    if (this.state === "half-open" && !this.probing) {
      this.probing = true; // exactly one probe at a time
      return true;
    }
    return false;
  }

  recordSuccess() {
    this.state = "closed";
    this.failures = [];
    this.probing = false;
  }

  recordFailure(now = Date.now()) {
    this.failures = this.failures.filter((t) => now - t < this.windowMs);
    this.failures.push(now);
    if (this.state === "half-open" || this.failures.length >= this.threshold) {
      this.state = "open";
      this.openedAt = now;
      this.probing = false;
    }
  }
}

// Unhealthy: server errors, timeouts, broken connections. Not 4xx, not 429.
export function isProviderFailure(err: unknown): boolean {
  if (!(err instanceof APIError)) return false;
  return err.status === undefined || err.status >= 500;
}

const openai = new OpenAI({ maxRetries: 0, timeout: 20_000 });
const anthropic = new Anthropic({ maxRetries: 1, timeout: 20_000 });
// Production values are seconds to minutes; 100 ms keeps this demo fast.
const openaiBreaker = new CircuitBreaker({ threshold: 3, windowMs: 30_000, cooldownMs: 100 });

export async function answer(prompt: string): Promise<string> {
  if (openaiBreaker.allowRequest()) {
    try {
      const res = await openai.chat.completions.create({
        model: "gpt-5.4-mini",
        messages: [{ role: "user", content: prompt }],
      });
      openaiBreaker.recordSuccess();
      return `[openai] ${res.choices[0]?.message.content ?? ""}`;
    } catch (err) {
      if (isProviderFailure(err)) openaiBreaker.recordFailure();
      else if (!(err instanceof APIError && err.status === 429)) throw err;
    }
  }
  const res = await anthropic.messages.create({
    model: "claude-haiku-5-5",
    max_tokens: 1024,
    messages: [{ role: "user", content: prompt }],
  });
  return `[anthropic] ${res.content.map((b) => (b.type === "text" ? b.text : "")).join("")}`;
}

for (let i = 1; i <= 5; i++) {
  console.log(`request ${i}: ${await answer("Tag this ticket.")} (breaker ${openaiBreaker.state})`);
}
await new Promise((r) => setTimeout(r, 150)); // the cool-down passes
console.log(`request 6: ${await answer("Tag this ticket.")} (breaker ${openaiBreaker.state})`);
Tested: three 5xx open the breaker, the next requests skip OpenAI entirely, and one successful probe closes it; five 429s fail over without ever opening it.

The in-memory breaker is right for one process. With many instances, each keeps its own state, which is usually acceptable: every instance discovers the outage after a few requests. Share the state (Redis, or your gateway) only when each discovery is expensive.

Streaming: failover ends at the first token

Before the first token reaches the user, a failed attempt can be dropped and another target tried invisibly. After it, switching targets starts a second, different answer halfway through the first. So the rule is absolute: once a stream has started, a failure ends it with an error the client can detect, and the application decides whether to discard the partial answer and start again. Anthropic can send an overloaded_error mid-stream after a 200; the 529 guide has tested code for exactly that case.

Watch time to first token as well as total duration: a provider that regularly stalls before its first event is failing, even when it eventually answers.

Repeated requests have side effects

Model APIs are POST requests, which HTTP does not treat as safe to repeat. A request that timed out on your side may have completed and been billed on the provider's. Keep side effects (sending a message, writing a record, calling a tool that changes data) out of the model call, behind your own idempotency key, so a late or repeated answer cannot run them twice.

Which pattern, when

Retry once
if the error is transient (connection, 500, 529, slow_down) and the caller's deadline leaves room.
Fall back
if the provider stays rate limited or unavailable and a tested target exists.
Open the breaker
if a provider has failed several times in a short window; skip it until a probe succeeds.
Stop and alert
if the error is a spend, quota or credential problem; no other target should spend around it.
Degrade clearly
if no tested target is available; say what still works.

Questions

What is the difference between a retry and a fallback?
A retry sends the same request to the same provider again. A fallback sends it to a different provider account or a different model. Retries help with brief glitches; fallbacks help when a provider stays unavailable.
Should a 429 open a circuit breaker?
No. A rate-limited account is busy, not broken. Count only server errors, timeouts and connection failures toward the breaker, and route a rate-limited request to another account instead.
How many times should I retry an LLM request?
Usually once, or not at all, when a user is waiting. Every attempt has to fit inside the caller's deadline, and the official SDKs already retry twice by default unless you turn that off.

Sources

Every statement about another company on this page comes from that company's own page, opened on the date shown. Prices and defaults change; check the source before you decide.

  1. 1.developers.openai.com/api/docs/guides/error-codes (opens in a new tab) — checked Oct 24, 2026.
    • OpenAI documents six distinct 429 errors: the plain rate limit, slow_down, and four billing or quota codes.
    • A 503 with code server_is_overloaded means the model is temporarily overloaded; OpenAI says to follow Retry-After.
  2. 2.developers.openai.com/api/docs/guides/rate-limits (opens in a new tab) — checked Oct 24, 2026.
    • Failed requests count toward the per-minute limit, so resending in a tight loop makes a 429 last longer.
  3. 3.github.com/openai/openai-python/blob/main/README.md (opens in a new tab) — checked Oct 24, 2026.
    • The official SDKs retry connection errors, 408, 409, 429 and 5xx twice by default, with a short exponential backoff.
  4. 4.github.com/openai/openai-node/blob/master/src/client.ts (opens in a new tab) — checked Oct 24, 2026.
    • The SDKs retry every 429, whatever its code, unless the response carries x-should-retry: false.
  5. 5.platform.claude.com/docs/en/api/errors (opens in a new tab) — checked Oct 24, 2026.
    • Anthropic returns 529 overloaded_error when the API is temporarily overloaded.
    • Anthropic's SDKs retry transient failures twice by default with exponential backoff, honouring retry-after.
  6. 6.platform.claude.com/docs/en/api/rate-limits (opens in a new tab) — checked Oct 24, 2026.
    • When an organization or workspace spend limit you set is reached, Anthropic returns 400 invalid_request_error, not 429.

The team building INFRO, a BYOK-first control plane for production AI. Articles are written from the engineering work on the gateway and cite their external sources.