Skip to content

Courses 1 open · 3 courses

AI EngineeringStart with Day 1 · The AI Engineer: Role, Skills & How to Use This Course, 0 of 100 doneStart →
Machine LearningBeing written · opens 2027Coming soon
System DesignBeing written · opens 2027Coming soon

AIDay 84 of 100Production

Reliability

Backoff, Fallbacks & LLM Gateways

45 min read

At 14:02 the provider starts returning 503s for the model Atlas uses. Every request retries 3 times inside the SDK, then your wrapper retries 3 more, so each user waits about 40 seconds, upstream traffic is 16× normal, and the outage lasts longer for everyone. Providers will have bad hours. Today Atlas learns to bend instead of break.

Short on time? The 30-second version
  • Retries multiply across layers (4 SDK attempts × 4 wrapper attempts = 16 calls): keep one retry layer and set max_retries=0 on the client it wraps.
  • Classify before retrying: connection errors, timeouts, 408/409, rate-limit 429s and 5xx are retryable; other 4xx and billing 429s never are. Honour Retry-After as a minimum, else use full jitter.
  • A per-model circuit breaker counts only retryable failures, opens after N, and lets exactly one half-open probe through; it turned 40 calls to a dead model into 3.
  • Token buckets for RPM and TPM keep you under limits you'd otherwise discover as 429s; share or divide them across workers.
  • Fall back to an evaluated same-provider model, then to a labelled degraded mode, and prove it with a seeded chaos test.
Jump to the cheat sheet →

At a Glance

  • Time: ~2 h core (+ optional Go Deeper) · Depth: working
  • Prerequisites: Day 17: Handling Semantic Failures: Validate, Re-ask, Bound, Day 77: Serving LLM Apps with FastAPI, Day 82: Containerize & Deploy with CI/CD (for Settings.fallback_models), primer A10: Backend & production vocabulary
  • You'll build: atlas/resilience.py: a resilient async client (full-jitter backoff, per-model circuit breakers, token buckets, same-provider fallbacks, a retrieval-only degraded mode), a seeded chaos test and six pytest cases
  • Stops at: SRE theory, multi-region failover, service meshes
  • Fast path: Already run Resilience4j or Polly in production? Read §"Fallbacks and Degraded Modes" and §"Chaos Testing", then do Exercise 3 (~45 min)
  • Cost: $0 offline; ≈ $0.05 for the live smoke test and Exercise 3 on gpt-6-luna plus a few gpt-6.1-terra calls (prices as of 2026-11, verify)
  • New terms: full-jitter backoff, circuit breaker, client-side rate limiting (token bucket), fallback chain & degraded mode, LLM gateway

Retries Can Make Outages Worse

The SDK from Day 12 already retries failed calls. Suppose someone raised max_retries to 3 (4 attempts per call) and wrapped the call in a loop that also tries 4 times. One user question now causes 4 × 4 = 16 upstream requests. With both layers' waits plus slow failures, the user waits about 40 seconds for an error.

Every client does the same at once, so a service 20% over capacity gets 16× the traffic and stays overloaded longer. This is a retry storm: retry code turning a five-minute blip into an hour-long incident.

The fix has five parts: one retry layer that respects the server's hints, random spread in the waits, a fuse that stops calling a dead model, a second model to fall back to, and a useful answer when every model is down.

Fuses and Torches

Think of a fuse box. When a circuit keeps shorting, the fuse trips so the house doesn't burn, and you try again later. Meanwhile you light the room with a torch. The fuse is a circuit breaker (stop calling a failing service for a while). The torch is a degraded mode (a lower-quality answer that needs no model, such as relevant doc links).

By hand: ten users ask Atlas a question while gpt-6-luna is down. The policy allows 4 attempts per model, with full-jitter waits averaging 0.25 s, 0.5 s and 1 s. The breaker trips after 3 failures.

Without a breaker With a breaker (trips at 3)
Calls to the dead model 10 × 4 = 40 3 (all during user 1)
User 1 waits ~1.75 s backoff + 4 failed calls, then an error ~0.75 s backoff + 3 failed calls, then the fallback answers
Users 2–10 wait the same, each almost nothing: the open breaker skips to the fallback
Users answered 0 10

Name the Parts: the Failure Map

Sort errors into three bins before writing retry code: retry, never retry, fall back. (Retry-After is the response header where the server says how long to wait.) The error-codes guide adds a code to each status that separates look-alikes.

Error Example code Retry? Why
APIConnectionError, APITimeoutError – yes The network or server hiccuped
408, 409 – yes Request or lock timeout
429 RateLimitError slow_down, or "rate limit reached" yes, after Retry-After Too fast; waiting fixes it
429 billing credit_balance_exhausted, organization_spend_limit_exceeded (type insufficient_quota) never Money, not load: alert a human
500, 503 InternalServerError server_is_overloaded yes, then fall back The model is struggling; another may not be
400, 401, 403, 404, 422 invalid_value never Your request or key is wrong every time

What the SDK already does (openai 3.46): it retries connection errors, 408, 409, 429 and 5xx twice, waiting 0.5–8 s, honours retry-after-ms/retry-after up to two minutes, and never retries a stream you've started reading. Its jitter only shaves 0–25% off each wait, and it decides on the status code (plus an x-should-retry header when the server sends one), never on the error code, so it can retry billing 429s too.

Five terms for the rest of the day:

  • Full-jitter backoff: wait a random time between 0 and an exponentially growing ceiling. Jitter is the random spread that keeps clients from retrying in sync.
  • Thundering herd: many clients retrying at the same instant.
  • Circuit breaker: stop calling a failing model for a while, then let one probe test it.
  • Token bucket: a counter refilled at a fixed rate that each call spends from; used for client-side rate limiting.
  • Gateway: a proxy that routes, retries and meters calls to model providers.

Minimal Code: Backoff That Respects the Server

Wait a random time between 0 and base × 2^(attempt-1), capped. If the server sent Retry-After, treat it as a minimum plus a little spread. Always bound the attempts.

# script_id: day_084_reliability_fallbacks/backoff_first
# Install: nothing (stdlib only). Env: none. Services: none.
import random
import time


def backoff_s(attempt: int, base: float = 0.5, cap: float = 8.0, retry_after: float | None = None) -> float:
    if retry_after is not None:                       # the server knows its own load: wait at least that
        return retry_after + random.uniform(0, base)  # plus a little spread so clients don't sync up
    return random.uniform(0, min(cap, base * 2 ** (attempt - 1)))   # full jitter


def flaky_call(attempt: int) -> str:                  # fails twice; the 2nd failure carries a server hint
    if attempt == 1:
        raise ConnectionError("connection reset by peer")
    if attempt == 2:
        raise RuntimeError("429 slow_down, retry-after=1.5")
    return "ok"


random.seed(7)
for attempt in range(1, 5):                           # bounded: never retry forever
    try:
        print(f"attempt {attempt}: {flaky_call(attempt)}")
        break
    except Exception as exc:
        wait = backoff_s(attempt, retry_after=1.5 if "retry-after" in str(exc) else None)
        print(f"attempt {attempt}: {exc} -> sleeping {wait:.2f}s")
        time.sleep(wait)
attempt 1: connection reset by peer -> sleeping 0.16s
attempt 2: 429 slow_down, retry-after=1.5 -> sleeping 1.58s
attempt 3: ok

In real code this loop replaces the SDK's, so build the client with max_retries=0.

Break It: Stacked Retries and Synchronised Clients

Knob 1: leave the SDK's retries on. This script makes no network calls. An httpx2.MockTransport (the SDK's HTTP layer since openai 3.0) answers every request with a 503, and we count how many requests reach it.

# script_id: day_084_reliability_fallbacks/stacked_retries
# Install: uv add "openai>=3.46,<4"   Env: none (fake key)   Services: none
# Offline: httpx2.MockTransport plays an overloaded API, so nothing leaves your machine.
import httpx2
import openai
from openai import OpenAI

calls = 0


def always_503(request: httpx2.Request) -> httpx2.Response:
    global calls
    calls += 1
    return httpx2.Response(503, headers={"retry-after-ms": "1"},       # tiny hint keeps the demo fast
                           json={"error": {"message": "The requested model is temporarily overloaded.",
                                           "type": "service_unavailable_error", "code": "server_is_overloaded"}})


def my_retry_loop(client: OpenAI, attempts: int = 4) -> openai.APIStatusError:
    for _ in range(attempts):                        # "our" retries, stacked on top of the SDK's
        try:
            client.responses.create(model="gpt-6-luna", input="hi")
        except openai.InternalServerError as exc:
            last = exc
    return last


for sdk_retries in (3, 0):
    calls = 0
    client = OpenAI(api_key="sk-test", max_retries=sdk_retries,
                    http_client=openai.DefaultHttpxClient(transport=httpx2.MockTransport(always_503)))
    err = my_retry_loop(client)
    print(f"SDK max_retries={sdk_retries}: 4 wrapper attempts -> {calls} upstream calls (code={err.code})")
SDK max_retries=3: 4 wrapper attempts -> 16 upstream calls (code=server_is_overloaded)
SDK max_retries=0: 4 wrapper attempts -> 4 upstream calls (code=server_is_overloaded)

Retries multiply. Keep one layer, yours, because only it knows about breakers, fallbacks and budgets.

Knob 2: drop the jitter. Fifty Atlas workers hit the same 503 at the same moment. With fixed exponential waits they all come back at 0.5 s, then 1.5 s, then 3.5 s: three spikes of 50 requests.

# script_id: day_084_reliability_fallbacks/herd_simulation
# Install: nothing (stdlib only). Simulates 50 clients that all fail at t=0.
import random
from collections import Counter

CLIENTS, ATTEMPTS, BASE, CAP, BIN = 50, 3, 0.5, 8.0, 0.5


def retry_times(jitter: bool, rng: random.Random) -> list[float]:
    t, times = 0.0, []
    for attempt in range(1, ATTEMPTS + 1):
        ceiling = min(CAP, BASE * 2 ** (attempt - 1))
        t += rng.uniform(0, ceiling) if jitter else ceiling
        times.append(t)
    return times


def histogram(jitter: bool) -> None:
    rng = random.Random(42)
    bins = Counter(int(t / BIN) for _ in range(CLIENTS) for t in retry_times(jitter, rng))
    print("full jitter" if jitter else "no jitter (fixed exponential)")
    for b in range(8):                                        # 0-4 s in half-second bins
        n = bins.get(b, 0)
        print(f"  {b * BIN:3.1f}-{(b + 1) * BIN:3.1f}s {n:3d} {'#' * (n // 2)}")


histogram(jitter=False)
histogram(jitter=True)
no jitter (fixed exponential)
  0.0-0.5s   0
  0.5-1.0s  50 #########################
  1.0-1.5s   0
  1.5-2.0s  50 #########################
  2.0-2.5s   0
  2.5-3.0s   0
  3.0-3.5s   0
  3.5-4.0s  50 #########################
full jitter
  0.0-0.5s  70 ###################################
  0.5-1.0s  29 ##############
  1.0-1.5s  23 ###########
  1.5-2.0s   9 ####
  2.0-2.5s  13 ######
  2.5-3.0s   6 ###
  3.0-3.5s   0
  3.5-4.0s   0

No jitter is a thundering herd; full jitter is a stream a recovering service can absorb.

Circuit Breakers

Now build atlas/resilience.py, one concern per block; all blocks share one script_id and stitch into one file. The first block holds settings and the failure map as code. The rule that matters most: only retryable failures count against a model. A 400 means your request is wrong; a breaker that trips on it takes a healthy model out of service.

# script_id: day_084_reliability_fallbacks/atlas_resilience
# atlas/resilience.py: retries, breakers, rate limits, fallbacks and chaos tools (Day 84).
# Install: uv add "openai>=3.46,<4" pydantic    Env (optional): ATLAS_MODEL, ATLAS_FALLBACK_MODELS, ORBIT_DATA
# Services: none. Every demo in this file runs offline against FakeAsyncClient (defined below).
from __future__ import annotations

import asyncio
import email.utils
import enum
import json
import os
import random
import re
import time
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable, Sequence

import httpx2
import openai
from pydantic import BaseModel

MODEL = os.environ.get("ATLAS_MODEL", "gpt-6-luna")
# Day 82's Settings.fallback_models reads this env var: comma-separated, same provider by default
FALLBACK_MODELS = tuple(m for m in os.environ.get("ATLAS_FALLBACK_MODELS", "gpt-6.1-terra").split(",") if m)
DATA_DIR = Path(os.environ.get("ORBIT_DATA", "datasets/orbit"))
# 429s about money, not load (error-codes guide): retrying can't fix them
BILLING_CODES = {"insufficient_quota", "credit_balance_exhausted", "organization_spend_limit_exceeded",
                 "project_spend_limit_exceeded", "organization_usage_limit_exceeded"}


@dataclass(frozen=True)
class RetryPolicy:
    max_attempts: int = 4          # per model, counting the first try
    base_s: float = 0.5
    cap_s: float = 8.0
    honor_retry_after: bool = True
    max_wait_s: float = 20.0       # longest single sleep a user-facing call accepts; beyond it, fall back

The second block classifies errors, reads the server's hint and computes the wait. fake_error() builds the exception the SDK would raise, so tests exercise the real classification code.

# script_id: day_084_reliability_fallbacks/atlas_resilience
def is_retryable(exc: BaseException) -> bool:
    if isinstance(exc, (openai.APIConnectionError, TimeoutError)):   # APITimeoutError is a subclass
        return True
    if isinstance(exc, openai.APIStatusError):
        if exc.code in BILLING_CODES or exc.type == "insufficient_quota":
            return False                                             # billing: alert, don't retry
        return exc.status_code in (408, 409, 429) or exc.status_code >= 500
    return False


def retry_after_s(exc: BaseException) -> float | None:
    """The server's wait hint in seconds: retry-after-ms, then retry-after (seconds or an HTTP date)."""
    if not isinstance(exc, openai.APIStatusError):
        return None
    headers = exc.response.headers
    for name, scale in (("retry-after-ms", 0.001), ("retry-after", 1.0)):
        try:
            return max(0.0, float(headers[name]) * scale)
        except (KeyError, ValueError):
            continue
    try:
        return max(0.0, email.utils.parsedate_to_datetime(headers["retry-after"]).timestamp() - time.time())
    except (KeyError, TypeError, ValueError):
        return None


def backoff_s(attempt: int, policy: RetryPolicy, retry_after: float | None = None) -> float:
    if retry_after is not None and policy.honor_retry_after:
        return retry_after + random.uniform(0, policy.base_s)       # a minimum, plus a little spread
    return random.uniform(0, min(policy.cap_s, policy.base_s * 2 ** (attempt - 1)))   # full jitter


def fake_error(status: int, code: str, *, retry_after: str | None = None,
               type_: str = "rate_limit_error") -> openai.APIStatusError:
    """The exception the SDK would raise for this status, for tests and chaos runs."""
    cls = {400: openai.BadRequestError, 429: openai.RateLimitError}.get(
        status, openai.InternalServerError if status >= 500 else openai.APIStatusError)
    request = httpx2.Request("POST", "https://api.openai.com/v1/responses")
    response = httpx2.Response(status, headers={"retry-after": retry_after} if retry_after else {}, request=request)
    return cls(f"Error code: {status} - {code}", response=response, body={"code": code, "type": type_})


if __name__ == "__main__":
    random.seed(0)
    for label, exc in [("429 slow_down", fake_error(429, "slow_down", retry_after="2")),
                       ("429 billing", fake_error(429, "credit_balance_exhausted", type_="insufficient_quota")),
                       ("503 overloaded", fake_error(503, "server_is_overloaded")),
                       ("400 bad request", fake_error(400, "invalid_value"))]:
        hint, ok = retry_after_s(exc), is_retryable(exc)
        wait = f"{backoff_s(1, RetryPolicy(), hint):.2f}s" if ok else "-"
        print(f"{label:16} retry={ok!s:5} hint={hint} wait={wait}")
429 slow_down    retry=True  hint=2.0 wait=2.42s
429 billing      retry=False hint=None wait=-
503 overloaded   retry=True  hint=None wait=0.38s
400 bad request  retry=False hint=None wait=-

The breaker has three states. Closed: calls flow and failures are counted. After failure_threshold retryable failures in a row it goes open: calls to that model are skipped instantly. After reset_after_s it goes half-open and lets exactly one probe through; success closes it, failure re-opens it. If every waiting request probed at once, a recovering model would get the stampede the breaker exists to prevent.

# script_id: day_084_reliability_fallbacks/atlas_resilience
class CircuitState(enum.Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class CircuitBreaker:
    """Per-model fuse. Methods never await, so under asyncio each call is atomic (add a Lock for threads)."""

    def __init__(self, name: str, failure_threshold: int = 5, reset_after_s: float = 30.0,
                 clock: Callable[[], float] = time.monotonic) -> None:
        self.name, self.failure_threshold, self.reset_after_s, self.clock = name, failure_threshold, reset_after_s, clock
        self.state, self.failures, self.opened_at = CircuitState.CLOSED, 0, 0.0
        self._probe_at: float | None = None          # when the half-open probe went out

    def allow(self) -> bool:
        if self.state is CircuitState.OPEN and self.clock() - self.opened_at >= self.reset_after_s:
            self._set_state(CircuitState.HALF_OPEN)
        if self.state is CircuitState.HALF_OPEN:
            # one probe at a time; a probe lost to a crash or cancel expires after reset_after_s
            if self._probe_at is not None and self.clock() - self._probe_at < self.reset_after_s:
                return False
            self._probe_at = self.clock()
            return True
        return self.state is CircuitState.CLOSED

    def record_success(self) -> None:
        self.failures, self._probe_at = 0, None
        if self.state is not CircuitState.CLOSED:
            self._set_state(CircuitState.CLOSED)

    def record_failure(self) -> None:                # call it ONLY for retryable failures
        self.failures, self._probe_at = self.failures + 1, None
        if self.state is CircuitState.HALF_OPEN or self.failures >= self.failure_threshold:
            self.opened_at = self.clock()
            if self.state is not CircuitState.OPEN:
                self._set_state(CircuitState.OPEN)

    def _set_state(self, new: CircuitState) -> None:   # one place to hook logs and metrics (Exercise 1)
        self.state = new

Watch it with a fake clock: a call every 5 s against a model that is dead until t = 45 s.

# script_id: day_084_reliability_fallbacks/atlas_resilience
if __name__ == "__main__":
    now = [0.0]
    breaker = CircuitBreaker("gpt-6-luna", failure_threshold=3, reset_after_s=30, clock=lambda: now[0])
    for call in range(1, 11):
        now[0] += 5
        if not breaker.allow():
            result = "skipped"
        elif now[0] < 45:
            breaker.record_failure(); result = "sent, failed"
        else:
            breaker.record_success(); result = "sent, ok"
        print(f"t={now[0]:>3.0f}s call {call:>2}: {result:13} -> {breaker.state.value}")
t=  5s call  1: sent, failed  -> closed
t= 10s call  2: sent, failed  -> closed
t= 15s call  3: sent, failed  -> open
t= 20s call  4: skipped       -> open
t= 25s call  5: skipped       -> open
t= 30s call  6: skipped       -> open
t= 35s call  7: skipped       -> open
t= 40s call  8: skipped       -> open
t= 45s call  9: sent, ok      -> closed
t= 50s call 10: sent, ok      -> closed

Four calls reached the model instead of ten, and the breaker noticed the recovery by itself.

Staying Under the Limits

Most 429s are self-inflicted. OpenAI meters requests per minute (RPM) and tokens per minute (TPM), among others, and you hit whichever runs out first. So Atlas keeps two token buckets: RPM charges 1 per call; TPM charges estimated input tokens plus max_output_tokens. The estimate is rough; Day 4's count_tokens() is closer.

A token bucket is a jar that refills at a steady rate up to a maximum. Each call takes some out; if the jar is short, the call waits. Capacity sets the burst size; the refill rate sets the long-run average. It's the outbound twin of Day 83's RateLimiter: that one protects you from tenants, this one protects the provider (and your bill) from you.

# script_id: day_084_reliability_fallbacks/atlas_resilience
class TokenBucket:
    """Refills at rate_per_s up to capacity; each call spends from it. One for RPM, one for TPM."""

    def __init__(self, rate_per_s: float, capacity: float) -> None:
        self.rate, self.capacity = rate_per_s, capacity
        self.tokens, self.updated = capacity, time.monotonic()
        self._lock = asyncio.Lock()                  # waiters queue in order instead of racing

    def _refill(self) -> None:
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
        self.updated = now

    async def acquire(self, amount: float = 1) -> None:
        if amount > self.capacity:
            raise ValueError(f"call needs {amount:.0f} but the bucket only holds {self.capacity:.0f}")
        async with self._lock:
            self._refill()
            while self.tokens < amount:
                await asyncio.sleep((amount - self.tokens) / self.rate)
                self._refill()
            self.tokens -= amount


def estimate_tokens(kwargs: dict[str, Any]) -> int:
    """Rough TPM charge: ~4 characters per input token plus the whole output ceiling."""
    text = json.dumps(kwargs.get("input", "")) + str(kwargs.get("instructions") or "")
    return len(text) // 4 + int(kwargs.get("max_output_tokens") or 1_000)


if __name__ == "__main__":
    async def burst_demo() -> None:
        lines = (DATA_DIR / "eval" / "qa_gold.jsonl").read_text(encoding="utf-8").splitlines()
        questions = [json.loads(line)["question"] for line in lines]
        rpm = TokenBucket(rate_per_s=600 / 60, capacity=5)           # 600 RPM, bursts of 5
        tpm = TokenBucket(rate_per_s=120_000 / 60, capacity=2_000)   # 120K TPM
        start = time.monotonic()

        async def send(q: str) -> None:
            await rpm.acquire(1)
            await tpm.acquire(estimate_tokens({"input": q, "max_output_tokens": 300}))

        await asyncio.gather(*(send(q) for q in questions))
        print(f"{len(questions)} questions released in {time.monotonic() - start:.1f}s, paced under both limits")

    asyncio.run(burst_demo())
30 questions released in 3.8s, paced under both limits

Watch concurrency: five Day 78 workers, each with a bucket set to your full TPM, send 5× your limit. Give each limit ÷ N, or share one bucket in Redis.

Fallbacks and Degraded Modes

When the primary's breaker is open or its attempts run out, try the next model in Settings.fallback_models. Default to the same provider (same SDK, request shapes and structured-output behaviour), so the fallback is config, not a new code path. Cross-provider fallback covers a whole-provider outage, but prompts, tools and schemas behave differently there, so add it only after an eval.

A fallback model is a different program. gpt-6.1-terra costs about 10× gpt-6-luna per token ($1.00 vs $0.10 per 1M input, $5.00 vs $0.50 output, as of 2026-11, verify), so count fallback calls against Day 83 quotas. It also answers differently, so it must pass the Day 47 gate: an unevaluated fallback is an untested code path that only runs during incidents.

When every model is down, answer something. For ask, that's a retrieval-only answer: the most relevant doc sections with quotes, flagged degraded=true. Keyword search (Day 27) needs no model call, so it keeps working; vector search needs a query embedding from the same provider. Other degraded modes: queue the work (Day 78, Exercise 4) or serve a cached answer (Day 85).

Every response carries a CallMeta (model, attempts, fallback, degraded); log it for Day 86's alerts. The next block holds per-model state and two hooks that Exercise 2 overrides. The client is injected: the real async client (max_retries=0) in production, a fake in tests.

# script_id: day_084_reliability_fallbacks/atlas_resilience
@dataclass
class CallMeta:
    model: str
    attempts: int
    fallback: bool
    degraded: bool
    latency_ms: float


class AllModelsUnavailable(RuntimeError):
    """Every model was skipped (open breaker) or ran out of attempts: switch to a degraded mode."""

    def __init__(self, models: Sequence[str], attempts: int) -> None:
        super().__init__(f"no model available after {attempts} attempts: {', '.join(models)}")
        self.attempts = attempts


class _ModelPool:
    """Per-model breakers, rate limits and counters, plus the hooks subclasses override."""

    def __init__(self, client: Any, models: Sequence[str], *, policy: RetryPolicy | None = None,
                 rpm: TokenBucket | None = None, tpm: TokenBucket | None = None,
                 failure_threshold: int = 5, reset_after_s: float = 30.0) -> None:
        self._api = client                   # the real async client (max_retries=0) in prod, a fake in tests
        self.models, self.policy = list(models), policy or RetryPolicy()
        self.rpm, self.tpm = rpm, tpm
        self.breakers = {m: CircuitBreaker(m, failure_threshold, reset_after_s) for m in self.models}
        self.upstream_calls = self.retries = 0

    async def _throttle(self, kwargs: dict[str, Any]) -> None:
        if self.rpm:
            await self.rpm.acquire(1)
        if self.tpm:
            await self.tpm.acquire(estimate_tokens(kwargs))

    async def _call_upstream(self, model: str, kwargs: dict[str, Any], is_retry: bool) -> Any:
        self.upstream_calls += 1
        self.retries += is_retry
        return await self._api.responses.create(model=model, **kwargs)

    def _allow_retry(self) -> bool:          # hook: a retry budget plugs in here (Exercise 2)
        return True

The loop walks the models in order, asks the breaker before every attempt, and moves on as soon as the breaker opens or the server wants a longer wait than a user should sit through.

# script_id: day_084_reliability_fallbacks/atlas_resilience
class ResilientLLM(_ModelPool):
    async def create(self, **kwargs: Any) -> tuple[Any, CallMeta]:
        """responses.create with retries, breakers and fallbacks. Not for streams (see Pitfalls)."""
        start, attempts, last = time.perf_counter(), 0, None
        for i, model in enumerate(self.models):
            breaker = self.breakers[model]
            for attempt in range(1, self.policy.max_attempts + 1):
                # check the budget BEFORE allow(): allow() may hand out the single half-open probe
                if (attempt > 1 and not self._allow_retry()) or not breaker.allow():
                    break                                       # open fuse or no budget: next model
                await self._throttle(kwargs)
                attempts += 1
                try:
                    resp = await self._call_upstream(model, kwargs, is_retry=attempt > 1)
                except Exception as exc:
                    if not is_retryable(exc):
                        breaker.record_success()                # the server answered; the request is the problem
                        raise
                    breaker.record_failure()
                    last = exc
                    wait = backoff_s(attempt, self.policy, retry_after_s(exc))
                    if (breaker.state is CircuitState.OPEN or attempt == self.policy.max_attempts
                            or wait > self.policy.max_wait_s):
                        break                                   # don't sleep for nothing: next model now
                    await asyncio.sleep(wait)
                    continue
                breaker.record_success()
                ms = round((time.perf_counter() - start) * 1000, 1)
                return resp, CallMeta(model, attempts, fallback=i > 0, degraded=False, latency_ms=ms)
        raise AllModelsUnavailable(self.models, attempts) from last

To test without an API key, add a seeded FaultInjector (a fault plan such as "30% of calls get a 429") and a FakeAsyncClient with the same responses.create shape. The seed makes runs repeatable, so a chaos result is a test, not an anecdote.

# script_id: day_084_reliability_fallbacks/atlas_resilience
class FaultInjector:
    """Seeded fault plan, e.g. {"429": 0.3, "503": 0.2, "timeout": 0.1}. Models in `down` always 503."""
    CODES = {"429": "slow_down", "503": "server_is_overloaded", "500": "server_error", "400": "invalid_value"}

    def __init__(self, rates: dict[str, float], *, seed: int = 0, down: set[str] = frozenset()) -> None:
        self.rates, self.down, self.rng = rates, set(down), random.Random(seed)

    def pick(self, model: str) -> BaseException | None:
        if model in self.down:
            return fake_error(503, "server_is_overloaded", type_="service_unavailable_error")
        roll = self.rng.random()
        for kind, rate in self.rates.items():
            if roll < rate:
                if kind == "timeout":
                    return openai.APITimeoutError(request=httpx2.Request("POST", "https://api.openai.com/v1/responses"))
                return fake_error(int(kind), self.CODES.get(kind, "error"), retry_after="0.05" if kind == "429" else None)
            roll -= rate
        return None


class FakeAsyncClient:
    """Same call shape as the async OpenAI client's responses.create, offline; counts calls per model."""

    def __init__(self, faults: FaultInjector, latency_s: float = 0.01) -> None:
        self.faults, self.latency_s, self.calls = faults, latency_s, Counter()
        self.responses = self                          # so callers write fake.responses.create(...)

    async def create(self, *, model: str, **kwargs: Any) -> SimpleNamespace:
        self.calls[model] += 1
        fault = self.faults.pick(model)
        await asyncio.sleep(self.latency_s * (10 if isinstance(fault, openai.APITimeoutError) else 1))
        if fault:
            raise fault
        return SimpleNamespace(model=model, output_text=f"[{model}] answer to: {str(kwargs.get('input'))[:40]}")


if __name__ == "__main__":
    async def fallback_demo() -> None:
        fake = FakeAsyncClient(FaultInjector({"429": 0.3}, seed=3, down={MODEL}))
        llm = ResilientLLM(fake, [MODEL, *FALLBACK_MODELS], policy=RetryPolicy(base_s=0.01, cap_s=0.1),
                           failure_threshold=3)
        for q in ["How do leases work?", "What does ORB-1301 mean?", "How do I tune heartbeat_seconds?"]:
            _, meta = await llm.create(input=q, max_output_tokens=300)
            print(f"{meta.model:14} attempts={meta.attempts} fallback={meta.fallback} "
                  f"luna_breaker={llm.breakers[MODEL].state.value} upstream={dict(fake.calls)}")

    asyncio.run(fallback_demo())
gpt-6.1-terra  attempts=5 fallback=True luna_breaker=open upstream={'gpt-6-luna': 3, 'gpt-6.1-terra': 2}
gpt-6.1-terra  attempts=1 fallback=True luna_breaker=open upstream={'gpt-6-luna': 3, 'gpt-6.1-terra': 3}
gpt-6.1-terra  attempts=1 fallback=True luna_breaker=open upstream={'gpt-6-luna': 3, 'gpt-6.1-terra': 4}

The first request paid for discovering the outage (three failed calls to luna); later requests skipped luna.

# script_id: day_084_reliability_fallbacks/atlas_resilience
class Citation(BaseModel):            # compact copies of Day 15's schemas; R5 imports atlas.schemas
    doc_id: str
    quote: str


class Answer(BaseModel):
    text: str
    citations: list[Citation]
    degraded: bool = False            # new today: tells the UI to show a "limited answer" banner


class DocChunk(BaseModel):
    id: str
    doc_id: str
    section: str
    applies_to: str
    text: str


STOP = {"the", "and", "how", "what", "does", "for", "with", "when", "which", "can", "you", "are", "orbit"}


def terms(text: str) -> set[str]:
    return {w for w in re.findall(r"[a-z0-9_]+(?:[.-][a-z0-9_]+)*", text.lower()) if len(w) >= 3} - STOP


def load_chunks(data_dir: Path = DATA_DIR) -> list[DocChunk]:
    """One chunk per '## ' section of each Orbit doc (Day 25 chunks better; this is enough to cite)."""
    chunks = []
    for path in sorted((data_dir / "docs").glob("*.md")):
        _, front, body = path.read_text(encoding="utf-8").split("---\n", 2)
        meta = dict(line.split(": ", 1) for line in front.strip().splitlines() if ": " in line)
        for n, part in enumerate(body.split("\n## ")):
            title, _, text = part.strip().partition("\n")
            chunks.append(DocChunk(id=f"{meta['id']}#{n}", doc_id=meta["id"], section=title.strip("# \n"),
                                   applies_to=meta.get("applies_to", ""), text=text.strip()))
    return chunks


def keyword_search(question: str, chunks: list[DocChunk], k: int = 3) -> list[DocChunk]:
    """Term overlap needs no model call, so it still works when every provider is down."""
    q = terms(question)

    def score(c: DocChunk) -> int:               # a match in the doc id or heading counts double
        return 2 * len(q & terms(c.doc_id.replace("-", " ") + " " + c.section)) + len(q & terms(c.text))

    return sorted(chunks, key=score, reverse=True)[:k]
# script_id: day_084_reliability_fallbacks/atlas_resilience
def degraded_answer(question: str, hits: list[DocChunk]) -> Answer:
    """Retrieval-only answer: no generation, only quotes and links the user can check."""
    hits = [h for h in hits if terms(question) & terms(h.section + " " + h.text)]
    if not hits:
        return Answer(text="Atlas can't reach its language models right now. Please try again in a few minutes.",
                      citations=[], degraded=True)
    def first_paragraph(text: str) -> str:      # skip tables, code and headings
        prose = (p for p in text.split("\n\n") if p.strip() and p.lstrip()[0] not in "|`#-*>")
        return next(prose, text).replace("\n", " ")[:160]

    cites = [Citation(doc_id=h.doc_id, quote=first_paragraph(h.text)) for h in hits]
    lines = [f"- {h.doc_id} > {h.section}: {c.quote}" for h, c in zip(hits, cites)]
    return Answer(text="Atlas can't generate an answer right now. The most relevant Orbit docs:\n" + "\n".join(lines),
                  citations=cites, degraded=True)


async def resilient_ask(llm: ResilientLLM, question: str, chunks: list[DocChunk]) -> tuple[Answer, CallMeta]:
    """Compact stand-in for Day 28's ask(): a model answer if possible, retrieval-only if not."""
    start = time.perf_counter()
    try:
        resp, meta = await llm.create(input=question, max_output_tokens=300)
        return Answer(text=resp.output_text, citations=[]), meta
    except AllModelsUnavailable as exc:
        ms = round((time.perf_counter() - start) * 1000, 1)
        meta = CallMeta("none", exc.attempts, fallback=True, degraded=True, latency_ms=ms)
        return degraded_answer(question, keyword_search(question, chunks)), meta


if __name__ == "__main__":
    async def degraded_demo() -> None:
        fake = FakeAsyncClient(FaultInjector({}, down={MODEL, *FALLBACK_MODELS}))
        llm = ResilientLLM(fake, [MODEL, *FALLBACK_MODELS], policy=RetryPolicy(base_s=0.01, cap_s=0.1))
        answer, meta = await resilient_ask(llm, "How do leases and heartbeats work?", load_chunks())
        print(meta)
        print(answer.text)

    asyncio.run(degraded_demo())
CallMeta(model='none', attempts=8, fallback=True, degraded=True, latency_ms=159.7)
Atlas can't generate an answer right now. The most relevant Orbit docs:
- leases-and-heartbeats > Heartbeat loss: ORB-1102: If the broker stops receiving a worker's heartbeats, Orbit records **ORB-1102 WorkerHeartbeatLost** and the dashboard marks the worker as lost. Its jobs are red
- leases-and-heartbeats > Leases and heartbeats: Leases let Orbit recover jobs from workers that crash, freeze or lose the network. They are also the main reason a job can **run twice**.
- leases-and-heartbeats > Heartbeats: A fixed deadline would force you to set the lease longer than your slowest job. Heartbeats avoid that. Every `heartbeat_seconds`, the worker supervisor renews t

Streams and the Flex tier need extra care; see Go Deeper.

Chaos Testing

A resilience layer that has never seen a failure is a hypothesis. Chaos testing injects failures on purpose and measures what users would see. Run five seeded scenarios over the 30 qa_gold questions and report the share answered by a model, p95 latency (the time 95% of requests beat), upstream calls, and the fallback and degraded shares. Waits are scaled down (base_s=0.02) so the run takes seconds.

# script_id: day_084_reliability_fallbacks/chaos_test
# Install: uv add "openai>=3.46,<4" pydantic    Env: ORBIT_DATA (optional)    Services: none
# Save the atlas_resilience blocks as atlas_resilience.py next to this file (R5: atlas/resilience.py).
import asyncio
import json
import statistics

from atlas_resilience import (DATA_DIR, FALLBACK_MODELS, MODEL, FakeAsyncClient, FaultInjector,
                              ResilientLLM, RetryPolicy, load_chunks, resilient_ask)

SCENARIOS = {                                   # name -> (fault rates, models that are hard-down)
    "429s 30% + retry-after": ({"429": 0.3}, set()),
    "503s 20%": ({"503": 0.2}, set()),
    "timeouts 10%": ({"timeout": 0.1}, set()),
    "primary hard-down": ({}, {MODEL}),
    "everything down": ({}, {MODEL, *FALLBACK_MODELS}),
}
FAST = RetryPolicy(base_s=0.02, cap_s=0.2)      # production shape, scaled to run in seconds


async def run(rates: dict, down: set, questions: list[str], chunks: list) -> dict:
    fake = FakeAsyncClient(FaultInjector(rates, seed=7, down=down))
    llm = ResilientLLM(fake, [MODEL, *FALLBACK_MODELS], policy=FAST, failure_threshold=5, reset_after_s=0.5)
    metas = [(await resilient_ask(llm, q, chunks))[1] for q in questions]
    n = len(metas)
    return {"ok": sum(not m.degraded for m in metas) / n,
            "p95": statistics.quantiles([m.latency_ms for m in metas], n=20)[-1],
            "upstream": sum(fake.calls.values()),
            "fallback": sum(m.fallback and not m.degraded for m in metas) / n,
            "degraded": sum(m.degraded for m in metas) / n}


async def main() -> None:
    lines = (DATA_DIR / "eval" / "qa_gold.jsonl").read_text(encoding="utf-8").splitlines()
    questions, chunks = [json.loads(line)["question"] for line in lines], load_chunks()
    print(f"{'scenario':24} {'model ok':>8} {'p95 ms':>7} {'upstream':>8} {'fallback':>8} {'degraded':>8}")
    for name, (rates, down) in SCENARIOS.items():
        r = await run(rates, down, questions, chunks)
        print(f"{name:24} {r['ok']:8.0%} {r['p95']:7.0f} {r['upstream']:8d} {r['fallback']:8.0%} {r['degraded']:8.0%}")


if __name__ == "__main__":
    asyncio.run(main())
scenario                 model ok  p95 ms upstream fallback degraded
429s 30% + retry-after       100%     224       48       0%       0%
503s 20%                     100%      71       43       0%       0%
timeouts 10%                 100%     259       38       0%       0%
primary hard-down            100%      62       35     100%       0%
everything down                0%     122       10       0%     100%

Read the last two rows. With the primary down, terra answered every request. With everything down, users got degraded answers in about a tenth of a second, and the provider saw 10 calls instead of 30 × 8 = 240. Lock these in with unit tests:

# script_id: day_084_reliability_fallbacks/test_resilience
# Install: uv add pytest "openai>=3.46,<4" pydantic    Run: pytest test_resilience.py -q
# Imports atlas_resilience.py from the same folder (R5: from atlas.resilience import ...).
import asyncio

import openai
import pytest

from atlas_resilience import (AllModelsUnavailable, CircuitBreaker, CircuitState, FakeAsyncClient,
                              FaultInjector, ResilientLLM, RetryPolicy, backoff_s, degraded_answer,
                              fake_error, keyword_search, load_chunks, retry_after_s)

FAST = RetryPolicy(max_attempts=3, base_s=0.001, cap_s=0.01)


def make(rates: dict | None = None, down: set = frozenset()) -> tuple[FakeAsyncClient, ResilientLLM]:
    fake = FakeAsyncClient(FaultInjector(rates or {}, down=set(down)), latency_s=0)
    return fake, ResilientLLM(fake, ["primary", "backup"], policy=FAST, failure_threshold=3)


def test_breaker_opens_and_stops_traffic():
    fake, llm = make(down={"primary"})
    for _ in range(5):
        asyncio.run(llm.create(input="q"))
    assert llm.breakers["primary"].state is CircuitState.OPEN
    assert fake.calls["primary"] == 3              # all three during request 1; later requests skip it


def test_half_open_allows_exactly_one_probe():
    now = [0.0]
    breaker = CircuitBreaker("m", failure_threshold=1, reset_after_s=10, clock=lambda: now[0])
    breaker.allow(); breaker.record_failure()
    now[0] = 10
    assert [breaker.allow() for _ in range(3)] == [True, False, False]


def test_no_retry_and_no_breaker_trip_on_400():
    fake, llm = make(rates={"400": 1.0})
    with pytest.raises(openai.BadRequestError):
        asyncio.run(llm.create(input="q"))
    assert fake.calls == {"primary": 1} and llm.breakers["primary"].state is CircuitState.CLOSED


def test_retry_after_is_honoured():
    exc = fake_error(429, "slow_down", retry_after="2")
    assert retry_after_s(exc) == 2.0
    assert 2.0 <= backoff_s(1, FAST, retry_after_s(exc)) <= 2.0 + FAST.base_s


def test_fallback_is_used_and_labelled():
    _, llm = make(down={"primary"})
    _, meta = asyncio.run(llm.create(input="q"))
    assert meta.model == "backup" and meta.fallback and not meta.degraded


def test_degraded_when_everything_is_down():
    _, llm = make(down={"primary", "backup"})
    with pytest.raises(AllModelsUnavailable):
        asyncio.run(llm.create(input="q"))
    answer = degraded_answer("How do leases work?", keyword_search("How do leases work?", load_chunks()))
    assert answer.degraded and answer.citations


if __name__ == "__main__":
    raise SystemExit(pytest.main([__file__, "-q", "-p", "no:cacheprovider"]))
......                                                                   [100%]
6 passed

Finally, one short live run proves the wiring. It's the only code today that needs OPENAI_API_KEY.

# script_id: day_084_reliability_fallbacks/live_smoke
# Install: uv add "openai>=3.46,<4" pydantic    Env: OPENAI_API_KEY, ATLAS_MODEL, ATLAS_FALLBACK_MODELS (optional)
# Cost: one short call, well under $0.001 on gpt-6-luna (as of 2026-11, verify at the pricing page).
import asyncio

from openai import AsyncOpenAI

from atlas_resilience import FALLBACK_MODELS, MODEL, ResilientLLM, TokenBucket


async def main() -> None:
    # max_retries=0: ResilientLLM owns retries. timeout=30: a user-facing call shouldn't wait the default 600 s.
    async with AsyncOpenAI(max_retries=0, timeout=30.0) as client:
        llm = ResilientLLM(client, [MODEL, *FALLBACK_MODELS],
                           rpm=TokenBucket(rate_per_s=500 / 60, capacity=10),       # example limits: use yours
                           tpm=TokenBucket(rate_per_s=200_000 / 60, capacity=20_000))
        resp, meta = await llm.create(input="In one sentence, why do job queues use leases?",
                                      max_output_tokens=200, reasoning={"effort": "none"})
        print(resp.output_text)
        print(meta)


asyncio.run(main())
A lease lets a worker claim a job for a limited time, so if the worker dies the job is released and retried.
CallMeta(model='gpt-6-luna', attempts=1, fallback=False, degraded=False, latency_ms=812.4)

Gateways: Buy Some of This

An LLM gateway moves some of this into a proxy that every service calls instead of the provider. It centralises what's painful to repeat: keys, per-team spend caps, routing, fallbacks, logs and cost reports. LiteLLM is a popular open-source option (an embeddable Router or a deployable proxy). Like your own code, probe it before trusting it. This probe runs offline: the primary is a closed port, the backup is mocked.

# script_id: day_084_reliability_fallbacks/gateway_litellm
# Install: uv add "litellm==1.108.1" mcp   Pin it: 1.82.7 and 1.82.8 were compromised on PyPI. Commit uv.lock.
# (In our 1.108.1 test the Responses path imported `mcp`, hence the extra package.)
# Env: none. Services: none. Offline: the primary is a closed port, the backup is mocked.
import sys

try:
    from litellm import Router
except ImportError:
    print("litellm is not installed: uv add 'litellm==1.108.1' mcp")
    sys.exit(0)

router = Router(
    model_list=[
        {"model_name": "primary", "litellm_params": {"model": "openai/gpt-6-luna", "api_key": "sk-test",
                                                     "api_base": "http://127.0.0.1:9/v1"}},   # nothing listens
        {"model_name": "backup", "litellm_params": {"model": "openai/gpt-6.1-terra", "api_key": "sk-test",
                                                    "mock_response": "answer from backup"}},
    ],
    fallbacks=[{"primary": ["backup"]}], num_retries=0, timeout=5)

for name in ("backup", "primary"):
    try:
        print(f"router.responses(model={name!r}) -> {router.responses(model=name, input='hi').output_text}")
    except Exception as exc:
        print(f"router.responses(model={name!r}) -> no fallback: {type(exc).__name__}")
router.responses(model='backup') -> answer from backup
router.responses(model='primary') -> no fallback: InternalServerError

In our offline test with litellm 1.108.1, fallbacks did not fire for router.responses() when the primary was unreachable, while the same Router's chat-style completion() did fall back (⚠ verify on the version you pin). Gateways also see every key and prompt, and litellm 1.82.7 and 1.82.8 on PyPI were compromised in March 2026, so pin exact versions and commit the lockfile. Rule of thumb: one service on one provider keeps resilience in process (build-vs-buy table in Go Deeper).

Trade-offs and When Not to Retry

Situation Do this Not this
Tool call with side effects Retry only with an idempotency key (a unique ID that makes a repeated request a no-op, Day 54) Blind retries that post twice
Many clients in an incident A retry budget (Exercise 2) Every client retrying 4 times
Retry-After longer than a user will wait Fall back now (max_wait_s) Sleep 60 s inside a request
Batch or background jobs Longer waits, queue it (Day 78) User-facing limits on overnight work
Several hops (API → agent → model) Pass the remaining deadline down A fixed 30 s timeout at every layer

In production

  • Alert on breaker_opened events and on the fallback and degraded shares, not on single errors (Day 86).
  • Keep a "provider degraded" runbook entry: status page, breaker states, fallback cost, kill switch (Day 88).
  • Run the chaos test in CI, and a live chaos drill on staging before launch (Day 90).
  • Put every fallback model in the Day 47 suite, and re-run it when the chain changes.

Commit it to R5

Block R5 path
atlas_resilience atlas/resilience.py (wrap Day 77's services with ResilientLLM; build the client with max_retries=0)
test_resilience tests/test_resilience.py (import from atlas.resilience)
chaos_test scripts/chaos_test.py (Day 90's chaos drill builds on it)
gateway_litellm optional: scripts/gateway_probe.py, only if you adopt a gateway

Go Deeper (optional)

Go deeper: why full jitter beats "exponential plus a little jitter"

In words. A recovering service cares about how many retries land in the same instant. Spreading retries evenly across the whole waiting window gives the lowest peak.

Toy numbers. Four clients fail together; the ceiling for attempt 1 is 1 s. With no jitter all four return at exactly 1.0 s: a peak of 4. With the SDK's style (shave 0–25% off), they land somewhere in 0.75–1.0 s, a window a quarter of the size, so about four times as dense as full jitter. With full jitter they land anywhere in 0–1 s.

Formula. Full jitter waits U(0, min(cap, base · 2^(n−1))) before attempt n+1. Its average wait is half the ceiling, versus 0.875 of the ceiling for the 0–25% style, so it also returns sooner on average. The AWS Architecture Blog article simulates full, equal and decorrelated jitter against a contended resource; read it for the curves. The one exception: when the server sends Retry-After, it knows its own recovery time better than any formula, so honour it as a minimum.

Go deeper: read the rate-limit headers before you hit the wall

Every response can carry your remaining budget in headers. The rate-limits guide documents x-ratelimit-limit-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-requests, x-ratelimit-remaining-tokens, x-ratelimit-reset-requests and x-ratelimit-reset-tokens (plus project-token variants). with_raw_response gives you the headers and the parsed object together. This runs offline against a mock transport:

# script_id: day_084_reliability_fallbacks/ratelimit_headers
# Install: uv add "openai>=3.46,<4"    Env: none    Offline: httpx2.MockTransport plays the API.
import re

import httpx2
import openai
from openai import OpenAI

HEADERS = {"x-ratelimit-limit-tokens": "150000", "x-ratelimit-remaining-tokens": "12000",
           "x-ratelimit-remaining-requests": "59", "x-ratelimit-reset-tokens": "6m0s"}


def fake_api(request: httpx2.Request) -> httpx2.Response:
    return httpx2.Response(200, headers=HEADERS, json={"id": "resp_1", "object": "response",
                                                      "model": "gpt-6-luna", "status": "completed", "output": []})


def seconds(duration: str) -> float:            # "6m0s" -> 360.0, "1s" -> 1.0, "20ms" -> 0.02
    units = {"h": 3600, "m": 60, "s": 1, "ms": 0.001}
    return sum(float(n) * units[u] for n, u in re.findall(r"([\d.]+)(ms|h|m|s)", duration))


client = OpenAI(api_key="sk-test", max_retries=0,
                http_client=openai.DefaultHttpxClient(transport=httpx2.MockTransport(fake_api)))
raw = client.responses.with_raw_response.create(model="gpt-6-luna", input="hi")
resp = raw.parse()                               # the usual Response object
left = int(raw.headers["x-ratelimit-remaining-tokens"]) / int(raw.headers["x-ratelimit-limit-tokens"])
print(f"{resp.id}: {left:.0%} of TPM left, resets in {seconds(raw.headers['x-ratelimit-reset-tokens']):.0f}s")
if left < 0.10:
    print("under 10% left: slow the TPM bucket before the 429 arrives")
resp_1: 8% of TPM left, resets in 360s
under 10% left: slow the TPM bucket before the 429 arrives

A simple adaptive policy: when remaining tokens drop under 10%, halve the TPM bucket's rate until the reset time passes. On errors, the same headers are on exc.response.headers.

Go deeper: tenacity instead of a hand-rolled loop

If you'd rather not own a retry loop, tenacity is the standard Python library. Its wait_random_exponential is full jitter, but it doesn't read Retry-After, so pass a custom wait that prefers the server's hint. Reuse is_retryable so the failure map stays in one place. Breakers and fallbacks are still yours: tenacity retries one call.

# script_id: day_084_reliability_fallbacks/tenacity_retry
# Install: uv add tenacity "openai>=3.46,<4" pydantic    Env: none    Offline: a fake flaky call.
from tenacity import RetryCallState, retry, retry_if_exception, stop_after_attempt, wait_random_exponential

from atlas_resilience import fake_error, is_retryable, retry_after_s

jitter = wait_random_exponential(multiplier=0.5, max=8)       # tenacity's full-jitter wait


def server_first(state: RetryCallState) -> float:
    """Honour Retry-After when the server sends it; otherwise full jitter."""
    hint = retry_after_s(state.outcome.exception()) if state.outcome else None
    return hint if hint is not None else jitter(state)


failures = iter([fake_error(429, "slow_down", retry_after="0.2"), fake_error(503, "server_is_overloaded")])


@retry(retry=retry_if_exception(is_retryable), wait=server_first, stop=stop_after_attempt(4), reraise=True)
def ask(question: str) -> str:                 # real code: call the model and return output_text
    err = next(failures, None)
    if err:
        raise err
    return f"answer to {question!r}"


print(ask("How do leases work?"), "| attempts:", ask.statistics["attempt_number"])
answer to 'How do leases work?' | attempts: 3

reraise=True re-raises the last real exception; without it you get tenacity.RetryError, which hides the status code your logs need.

Go deeper: what experts also do
  • Hedged requests. For latency-critical calls, send a second request to the fallback if the first hasn't answered within your p95, and take whichever finishes first. It cuts tail latency at the cost of paying for some duplicates. Only do it for read-only calls.
  • Adaptive concurrency. Instead of fixed bucket rates, shrink concurrency when latency or 429s rise and grow it slowly when they fall (the TCP congestion-control idea applied to API calls).
  • Ramp gradually. The rate-limits guide warns that a slow_down 429 can arrive even within your RPM and TPM limits if traffic rises too fast, and suggests growing by no more than 50% every 15 minutes once you pass 1M input tokens per minute. Launches and backfills should ramp, not jump.
  • Per-tenant breakers. One tenant's huge prompts can trip a model-wide breaker. Large deployments key breakers by (model, tenant tier) so a noisy neighbour can't take the model away from everyone.
Go deeper: streams, the Flex tier and labelling
  • Streams fall back only before the first token. The SDK never retries a stream once you start reading it, and neither should you: re-running it repeats text the user already saw. Wrap stream creation in the same breaker and fallback logic, and once the first event has arrived, stop. If the stream dies mid-answer, send Day 77's error event and let the user retry, or resume a background run (Day 78).
  • Flex is a tier, not a model. service_tier="flex" gives Batch prices on synchronous calls, but expect 429 Resource Unavailable, which isn't billed. The flex guide says to retry with backoff, or retry on the standard tier with service_tier="auto". In ResilientLLM terms, the standard tier is the first fallback for a Flex call, before any other model. Raise the timeout too (the guide suggests 900 s).
  • Label everything. fallback and degraded belong in the API response and in logs. Silent degradation looks like a quality regression to users and like nothing at all to dashboards.
Go deeper: gateways, build vs buy
Concern In-process (ResilientLLM) Gateway (LiteLLM-class)
Retries, breakers, same-provider fallback yes, you control and test it yes, configured; probe it
Keys and spend caps across many services no yes, its main value
Cross-provider fallback possible, but prompts and schemas differ config, but still needs an eval first
Degraded modes (retrieval-only, queue it) yes, it knows your product no, it only sees requests
New failure point and supply-chain risk none yes: run it redundantly, pin it

LiteLLM's Router takes other resilience knobs worth reading about once you adopt it: allowed_fails and cooldown_time (its breaker), retry_policy, and context_window_fallbacks / content_policy_fallbacks (fall back on a specific error class). Its bundled cost map knows gpt-6-luna but not gpt-6.1-terra, so cost reports show the fallback as unknown unless you register a price. To put the LiteLLM proxy in front of Atlas you'd point the SDK's base_url at it (Day 12 introduces base_url); confirm the proxy serves /v1/responses on your version before you rely on it (⚠ verify).

Common Pitfalls

  • Retries stacking. Upstream traffic is many times your request rate during an incident. Cause: SDK retries under your own loop. Fix: max_retries=0 on any client your wrapper owns.
  • Retrying billing 429s. RateLimitError with code credit_balance_exhausted or organization_spend_limit_exceeded (type insufficient_quota) never recovers. Cause: retrying on status alone, as the SDK does. Fix: check exc.code/exc.type and page a human.
  • Breaker opens on user errors. A burst of BadRequestError: Error code: 400 takes a healthy model out of service. Fix: call record_failure() only when is_retryable(exc) is true.
  • Half-open stampede. The recovering model gets every queued request at once and falls over again. Fix: one probe, and expire a lost probe.
  • Silent mid-stream retry. The user sees half an answer twice. Fix: fall back only before the first token; after that, send an error event (Day 77).
  • Unevaluated fallback. It passes chaos tests and fails users. Fix: run it through the Day 47 suite (Exercise 3).

Key Takeaways

  • Retries multiply across layers (4 SDK attempts × 4 wrapper attempts = 16 calls): keep one retry layer and set max_retries=0 on the client it wraps.
  • Classify before retrying: connection errors, timeouts, 408/409, rate-limit 429s and 5xx are retryable; other 4xx and billing 429s never are. Honour Retry-After as a minimum, else use full jitter.
  • A per-model circuit breaker counts only retryable failures, opens after N, and lets exactly one half-open probe through; it turned 40 calls to a dead model into 3.
  • Token buckets for RPM and TPM keep you under limits you'd otherwise discover as 429s; share or divide them across workers.
  • Fall back to an evaluated same-provider model, then to a labelled degraded mode, and prove it with a seeded chaos test.

Quick Reference

Reliability, on one card

API / patternWhen to useGotcha
AsyncOpenAI(max_retries=0, timeout=30.0)Any client wrapped by your own retry layerDefaults are 2 retries and a 600 s timeout
is_retryable(exc)Before every retry and every breaker updateThe SDK retries billing 429s; check exc.code
retry_after_s(exc)Reading the server's wait hintA minimum, not an exact time; add jitter
CircuitBreaker.allow()One breaker per modelCheck budgets first; it hands out the probe
TokenBucket(rate_per_s, capacity)Pacing RPM and TPMCapacity must be ≥ the biggest single request
ResilientLLM.create(**kwargs)Non-streaming calls with fallbacksReturns (response, CallMeta); log the meta
degraded_answer(question, hits)AllModelsUnavailable on read endpointsKeyword search works offline; vector search may not
LiteLLM Router(model_list, fallbacks=...)Shared keys, spend caps, many servicesPin the version; probe that fallbacks fire on the endpoint you use

Exercises

  1. Warm-up. Log each breaker state change as one JSON event (event, model, from, failures) with event set to breaker_opened, breaker_half_opened or breaker_closed. Day 86's alerts read these.

    Peek at the approach
    # script_id: day_084_reliability_fallbacks/solution_ex1
    # Install: as atlas_resilience. Runs offline with a fake clock.
    import json
    import logging
    import sys
    
    from atlas_resilience import CircuitBreaker, CircuitState
    
    logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
    log = logging.getLogger("atlas.resilience")
    EVENTS = {CircuitState.OPEN: "breaker_opened", CircuitState.HALF_OPEN: "breaker_half_opened",
              CircuitState.CLOSED: "breaker_closed"}
    
    
    class LoggedBreaker(CircuitBreaker):
        def _set_state(self, new: CircuitState) -> None:
            old = self.state
            super()._set_state(new)
            log.info(json.dumps({"event": EVENTS[new], "model": self.name, "from": old.value,
                                 "failures": self.failures}))
    
    
    now = [0.0]
    breaker = LoggedBreaker("gpt-6-luna", failure_threshold=2, reset_after_s=30, clock=lambda: now[0])
    for t, ok in [(1, False), (2, False), (40, True)]:      # two failures open it; a later probe closes it
        now[0] = t
        if breaker.allow():
            breaker.record_success() if ok else breaker.record_failure()
    # Wire it in: llm.breakers = {m: LoggedBreaker(m) for m in llm.models}
    
    {"event": "breaker_opened", "model": "gpt-6-luna", "from": "closed", "failures": 2}
    {"event": "breaker_half_opened", "model": "gpt-6-luna", "from": "open", "failures": 2}
    {"event": "breaker_closed", "model": "gpt-6-luna", "from": "half_open", "failures": 0}
    
  2. Core. Add a retry budget: in any 60 s window, retries may be at most 10% of upstream calls (floor of 3). Compare upstream calls, retries and degraded answers with and without it in the 503 scenario.

    Peek at the approach
    # script_id: day_084_reliability_fallbacks/solution_ex2
    import asyncio
    import json
    import time
    from collections import deque
    
    from atlas_resilience import (DATA_DIR, FALLBACK_MODELS, MODEL, AllModelsUnavailable, FakeAsyncClient,
                                  FaultInjector, ResilientLLM, RetryPolicy)
    
    
    class BudgetedLLM(ResilientLLM):
        def __init__(self, *args, ratio: float = 0.1, window_s: float = 60.0, floor: int = 3, **kwargs) -> None:
            super().__init__(*args, **kwargs)
            self.ratio, self.window_s, self.floor = ratio, window_s, floor
            self.recent: deque[tuple[float, bool]] = deque()        # (when, was_retry)
    
        async def _call_upstream(self, model, kwargs, is_retry):
            self.recent.append((time.monotonic(), is_retry))
            return await super()._call_upstream(model, kwargs, is_retry)
    
        def _allow_retry(self) -> bool:
            while self.recent and self.recent[0][0] < time.monotonic() - self.window_s:
                self.recent.popleft()
            retries = sum(was_retry for _, was_retry in self.recent)
            return retries < max(self.floor, self.ratio * len(self.recent))
    
    
    async def main() -> None:
        lines = (DATA_DIR / "eval" / "qa_gold.jsonl").read_text(encoding="utf-8").splitlines()
        for cls in (ResilientLLM, BudgetedLLM):
            fake = FakeAsyncClient(FaultInjector({"503": 0.2}, seed=7), latency_s=0)
            llm, degraded = cls(fake, [MODEL, *FALLBACK_MODELS], policy=RetryPolicy(base_s=0.001, cap_s=0.01)), 0
            for line in lines:
                try:
                    await llm.create(input=json.loads(line)["question"])
                except AllModelsUnavailable:
                    degraded += 1
            print(f"{cls.__name__:12} upstream={llm.upstream_calls} retries={llm.retries} degraded={degraded}")
    
    asyncio.run(main())
    
    ResilientLLM upstream=43 retries=13 degraded=0
    BudgetedLLM  upstream=41 retries=4 degraded=2
    

    The budget cut retries from 13 to 4. The price: once it's spent, requests fall back or degrade sooner (2 degraded answers here).

  3. Core. Fallbacks need evals. Score the primary and each fallback on qa_gold with the same grounded prompt, and tabulate the quality delta. (R5 uses Day 47's fast suite instead.)

    Peek at the approach
    # script_id: day_084_reliability_fallbacks/solution_ex3
    # Install: uv add "openai>=3.46,<4" pydantic   Env: OPENAI_API_KEY   Cost: ≈ $0.05 (as of 2026-11, verify)
    import asyncio
    import json
    import re
    
    from openai import AsyncOpenAI
    
    from atlas_resilience import DATA_DIR, FALLBACK_MODELS, MODEL, ResilientLLM, load_chunks
    
    CHUNKS = load_chunks()
    
    
    def facts(text: str) -> set[str]:               # ORB codes, versions/numbers, snake_case settings
        return set(re.findall(r"ORB-\d+|\d+(?:\.\d+)*|[a-z]+_[a-z_]+", text))
    
    
    async def score(llm: ResilientLLM, rows: list[dict]) -> float:
        total = 0.0
        for row in rows:
            docs = "\n\n".join(c.text for c in CHUNKS if c.doc_id in row["citations"])[:6000]
            resp, _ = await llm.create(instructions="Answer briefly from these Orbit docs:\n\n" + docs,
                                       input=row["question"], max_output_tokens=300, reasoning={"effort": "none"})
            want = facts(row["answer"])
            total += len(want & facts(resp.output_text)) / len(want) if want else 1.0
        return total / len(rows)
    
    
    async def main() -> None:
        rows = [json.loads(x) for x in (DATA_DIR / "eval" / "qa_gold.jsonl").read_text(encoding="utf-8").splitlines()]
        async with AsyncOpenAI(max_retries=0, timeout=30.0) as client:
            print("| model | fact recall | delta vs primary |\n|---|---|---|")
            base = None
            for model in (MODEL, *FALLBACK_MODELS):
                s = await score(ResilientLLM(client, [model]), rows)   # one model each: no silent fallback
                base = s if base is None else base
                print(f"| {model} | {s:.0%} | {s - base:+.0%} |")
    
    asyncio.run(main())
    

    Your table will show different numbers. If a fallback scores well below the primary, either fix its prompt or accept the gap knowingly and say so in the runbook.

  4. Stretch (optional). Degrade by endpoint: when every model is down, POST /v1/investigate returns 202 Accepted with a job id and status URL (queue it, as in Day 78) instead of a retrieval-only answer.

    Peek at the approach
    # script_id: day_084_reliability_fallbacks/solution_ex4
    # Install: uv add fastapi httpx "openai>=3.46,<4" pydantic   Env: none   Offline: all models fake-down.
    import uuid
    
    from fastapi import FastAPI
    from fastapi.responses import JSONResponse
    from fastapi.testclient import TestClient
    from pydantic import BaseModel
    
    from atlas_resilience import (FALLBACK_MODELS, MODEL, AllModelsUnavailable, FakeAsyncClient,
                                  FaultInjector, ResilientLLM, RetryPolicy)
    
    JOBS: dict[str, str] = {}                     # stand-in for Day 78's RQ queue (enqueue -> job id)
    
    
    class InvestigateRequest(BaseModel):
        question: str
    
    
    def create_app(llm: ResilientLLM) -> FastAPI:
        app = FastAPI()
    
        @app.post("/v1/investigate")
        async def investigate(req: InvestigateRequest):
            try:
                resp, meta = await llm.create(input=req.question, max_output_tokens=500)
            except AllModelsUnavailable:           # investigations can wait; `ask` degrades to retrieval instead
                job_id = uuid.uuid4().hex[:12]
                JOBS[job_id] = req.question
                return JSONResponse({"job_id": job_id, "status_url": f"/v1/reports/{job_id}", "degraded": True},
                                    status_code=202, headers={"Retry-After": "60"})
            return {"answer": resp.output_text, "model": meta.model, "fallback": meta.fallback}
    
        return app
    
    
    down = FakeAsyncClient(FaultInjector({}, down={MODEL, *FALLBACK_MODELS}), latency_s=0)
    app = create_app(ResilientLLM(down, [MODEL, *FALLBACK_MODELS], policy=RetryPolicy(base_s=0.001, cap_s=0.01)))
    r = TestClient(app).post("/v1/investigate", json={"question": "Why do ORB-1101 jobs run twice?"})
    print(r.status_code, r.json())
    
    202 {'job_id': '48409317ebca', 'status_url': '/v1/reports/48409317ebca', 'degraded': True}  (your job id will differ)
    

References & Further Reading

Next · Day 85 · 44 minCaching & Cost Optimization at ScaleAtlas now survives a bad hour at the provider, but every answer still costs a full model call, even for a question asked five minutes ago.← Previous: Day 83 · Auth, Multi-Tenancy & Usage Quotas
Not tapped yet