Back to blog
Tutorial·12 min read·2254 words

How to Build a Production Retry & Fallback Layer for AI APIs in Python (With Code)

429s, 5xxs, and dead providers are inevitable. Build exponential backoff with jitter, circuit breakers, multi-model fallback chains, and checkpointed batch jobs — complete Python code for any OpenAI-compatible API.

How to Build a Production Retry & Fallback Layer for AI APIs in Python (With Code) — illustration

The Problem Every AI App Hits

You shipped your AI feature. It works in staging. Then production happens: OpenAI returns a 429, Anthropic has a five-minute incident, your batch job dies at 80% complete, and the intern's retry loop (while True: call_api()) turns a rate limit into a self-inflicted denial-of-service attack.

Calling an AI API is unlike calling a database. Latency is measured in seconds, not milliseconds. Providers rate-limit aggressively. Models hallucinate in structured output. And the failure modes compound: the more you retry, the more you get rate-limited, the more you retry.

This guide builds a production-grade retry and fallback layer for AI APIs in Python — the exact patterns that keep AI features alive when providers aren't. All code runs against any OpenAI-compatible endpoint (which is most of the industry now, including Qubax AI, where one API key gives you 300+ models to fall back across).

What We're Building

A small resilience toolkit with four pieces:

  1. Exponential backoff with jitter — retry failures politely
  2. Circuit breaker — stop hammering a provider that's down
  3. Multi-model fallback chain — fail over to another model automatically
  4. Batch retries with checkpointing — survive long-running jobs

Along the way: idempotency keys, timeout discipline, and cost-aware fallback ordering. Everything is plain Python (requests + tenacity), no framework required.

Prerequisites

  • Python 3.10+
  • pip install requests tenacity
  • An API key from any OpenAI-compatible provider. On Qubax, one key unlocks OpenAI, Anthropic, Google, DeepSeek, GLM, Qwen, and hundreds more through a single /v1/chat/completions endpoint — which makes fallback chains trivial to configure.

Part 1: The Naive Call (and Why It Fails)

python
import requests

def call_llm(prompt: str, model: str) -> str:
    resp = requests.post(
        "https://api.qubax.ai/v1/chat/completions",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={
            "model": model,
            "messages": [{"role": "user", "content": prompt}],
        },
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"]

This works until it doesn't. The failure taxonomy for LLM APIs:

StatusMeaningCorrect response
400Bad request (bad model name, malformed messages)Don't retry — fix the request
401/403Auth failureDon't retry — check your key
408Request timeoutRetry with backoff
429Rate limit / quotaRetry with backoff, honor Retry-After
5xxProvider-side failureRetry, then fail over
Network errorTimeout, connection resetRetry, then fail over

The cardinal rule: only retry idempotent, transient failures. Retrying a 400 burns quota and fixes nothing.

Part 2: Exponential Backoff with Jitter

When a provider says "slow down" (429) or "I'm struggling" (5xx/503), the correct response is to wait progressively longer between attempts. Adding random jitter prevents thousands of clients from retrying in lockstep (the "thundering herd").

We'll use tenacity, the de-facto Python retry library:

python
import random
from tenacity import retry, stop_after_attempt, wait_custom, retry_if_exception

RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}

class TransientAPIError(Exception):
    """HTTP error that is safe to retry."""
    pass

class FatalAPIError(Exception):
    """Not safe to retry - fix the request instead."""
    pass
python
def classify_error(status: int) -> None:
    if status in RETRYABLE_STATUS:
        raise TransientAPIError(f"HTTP {status}")
    raise FatalAPIError(f"HTTP {status}")

def wait_exponential_jitter(retry_state):
    """Full jitter: uniform random between 0 and 2^attempt seconds, capped at 30s."""
    base = min(2 ** retry_state.attempt_number, 30)
    return random.uniform(0, base)

@retry(
    retry=retry_if_exception_type(TransientAPIError),
    wait=wait_custom(wait_exponential_jitter),
    stop=stop_after_attempt(5),
    reraise=True,
)
def call_with_retry(prompt: str, model: str) -> str:
    try:
        resp = requests.post(
            f"{BASE_URL}/v1/chat/completions",
            headers={"Authorization": f"Bearer {API_KEY}"},
            json={"model": model,
                  "messages": [{"role": "user", "content": prompt}]},
            timeout=(5, 120),  # (connect, read) - LLMs are slow; give read a budget
        )
        classify_error(resp.status_code)
        return resp.json()["choices"][0]["message"]["content"]
    except requests.Timeout:
        raise TransientAPIError("timeout")
    except requests.ConnectionError:
        raise TransientAPIError("connection error")

Key details:

  • Full jitter (uniform(0, 2^n)) beats "exponential + fixed sleep" in high-concurrency systems — it's AWS's own recommendation from their retry guidance.
  • `timeout=(5, 120)` — 5s to establish a connection, 120s to read the response. A 5s read timeout on a reasoning model is how you manufacture phantom outages.
  • `reraise=True` — surface the real exception, not tenacity's wrapper.
  • Honor the Retry-After header when present: if the provider tells you to wait 20s, wait 20s, not your computed 4s.

Part 3: The Circuit Breaker

Retries protect you from blips. Circuit breakers protect you from outages. Without one, every request against a down provider pays the full retry cost (5 attempts × exponential waits ≈ 30+ seconds of dead time) before failing over.

A circuit breaker tracks consecutive failures and "opens" — failing fast for a cool-down period — then lets limited traffic through ("half-open") to test recovery:

python
import time

class CircuitBreaker:
    def __init__(self, failure_threshold=5, cooldown_seconds=60):
        self.failure_threshold = failure_threshold
        self.cooldown = cooldown_seconds
        self.failures = 0
        self.opened_at = None

    @property
    def state(self) -> str:
        if self.opened_at is None:
            return "closed"
        if time.monotonic() - self.opened_at >= self.cooldown:
            return "half-open"
        return "open"

    def allow(self) -> bool:
        return self.state in ("closed", "half-open")  # half-open lets one probe through

    def record_success(self):
        self.failures = 0
        self.opened_at = None

    def record_failure(self):
        self.failures += 1
        if self.failures >= self.failure_threshold:
            self.opened_at = time.monotonic()

breaker = CircuitBreaker(failure_threshold=5, cooldown_seconds=60)

Wire it around the retry layer:

python
def call_with_breaker(prompt: str, model: str) -> str:
    if not breaker.allow():
        raise TransientAPIError("circuit open - failing fast")
    try:
        result = call_with_retry(prompt, model)
        breaker.record_success()
        return result
    except TransientAPIError:
        breaker.record_failure()
        raise

Now a provider outage costs you one fast failure instead of 30 seconds of retry death-march — and the breaker auto-probes recovery every 60s instead of requiring manual intervention.

Part 4: Multi-Model Fallback Chains

Here's the part specific to AI APIs: unlike a normal web service, you have dozens of functionally substitutable backends. When Claude is down, GPT or GLM can often finish the job. A fallback chain tries models in order until one succeeds:

python
from dataclasses import dataclass

@dataclass
class FallbackModel:
    model: str
    timeout: int = 120

# Ordered by preference: best quality first, cheap backup last
FALLBACK_CHAIN = [
    FallbackModel("anthropic/claude-sonnet-5"),
    FallbackModel("openai/gpt-5.6-sol"),
    FallbackModel("zhipu/glm-5.2", timeout=60),
    FallbackModel("deepseek/deepseek-v4-flash", timeout=60),
]

def call_with_fallback(prompt: str, chain=None) -> tuple:
    chain = chain or FALLBACK_CHAIN
    errors = []
    for fm in chain:
        try:
            text = call_with_breaker(prompt, fm.model)
            return text, fm.model  # return which model answered
        except (FatalAPIError, TransientAPIError) as e:
            errors.append(f"{fm.model}: {e}")
            continue
    raise RuntimeError(f"All models failed: {errors}")

Design notes:

  • Return which model answered. Downstream, you'll want to know — for cost tracking, quality monitoring, and debugging "why did the answer style change."
  • Order by cost AND quality. A common pattern: frontier model first for quality, budget model last as a safety net. On Qubax the same key works across the whole chain, so fallback is a config change, not an integration project.
  • Per-model circuit breakers. In production, keep a breaker per model (a dict {model: CircuitBreaker()}) so a dead Claude doesn't block healthy GPT.
  • Fallback changes the output distribution. GLM 5.2 won't write exactly like Claude Sonnet 5. For user-visible copy, log fallbacks and watch them; for extraction/classification tasks, it's usually invisible.

Cost-aware fallback ordering

Since fallback re-runs the full prompt, cost matters. A pragmatic ordering for a coding assistant:

python
CODING_CHAIN = [
    FallbackModel("anthropic/claude-sonnet-5"),      # ~$0.15/$0.75 per M tokens (Qubax)
    FallbackModel("zhipu/glm-5.2"),                  # ~$0.0046/$0.0145 per M - 30x cheaper
    FallbackModel("deepseek/deepseek-v4-flash"),     # budget workhorse
]

If your task tolerates the cheaper model's quality, flipping the order saves 30× on the 95% of requests that never need the frontier model. Check live prices on qubax.ai/models before hardcoding assumptions — they move.

Part 5: Structured Output That Doesn't Shatter

A huge fraction of AI API failures aren't HTTP failures — they're json.loads() blowing up on a model that wrapped its JSON in code fences or added a friendly "Here's the JSON you asked for!" preamble.

Defensive parsing:

python
import json, re

def extract_json(text: str):
    """Extract the first valid JSON object/array from an LLM response."""
    text = re.sub(r"^

(?:json)?\s|\s```$", "", text.strip(), flags=re.M) try: return json.loads(text) except json.JSONDecodeError: pass # fallback: find the outermost { ... } or [ ... ] for match in re.finditer(r"[\[{]", text): start = match.start() depth = 0 for i in range(start, len(text)): ch = text[i] if ch in "[{": depth += 1 elif ch in "]}": depth -= 1 if depth == 0: try: return json.loads(text[start:i+1]) except json.JSONDecodeError: break raise TransientAPIError("model returned unparseable JSON") # retryable - once!

code

Two production tricks:

1. **Use native structured outputs when available.** The OpenAI-compatible `response_format: {"type": "json_schema", ...}` forces schema-valid JSON at the API level for supported models — eliminating the entire class of fence/preamble parse failures. Support varies by model; the Qubax [docs](https://qubax.ai/docs) list which models support it.
2. **Make bad JSON retryable — once.** A re-roll often fixes it, but don't retry parse failures 5 times; the model may be systematically incapable on this prompt. One structured retry with the error message appended ("Your previous response was not valid JSON: ...") is the sweet spot.

## Part 6: Batch Jobs with Checkpointing

For jobs processing thousands of items, retries aren't enough — you need to survive your own process dying. Checkpoint progress to disk and make operations idempotent:

python import json, os

CHECKPOINTFILE = "jobcheckpoint.jsonl"

def loadcheckpoint() -> set: if not os.path.exists(CHECKPOINTFILE): return set() done = set() with open(CHECKPOINT_FILE) as f: for line in f: done.add(json.loads(line)["id"]) return done

def processbatch(items): done = loadcheckpoint() with open(CHECKPOINTFILE, "a") as cp: for item in items: if item["id"] in done: continue try: result, modelused = callwithfallback(buildprompt(item)) cp.write(json.dumps({"id": item["id"], "model": modelused}) + "\n") cp.flush() # survive a crash mid-batch yield item["id"], result except FatalAPIError: cp.write(json.dumps({"id": item["id"], "error": "fatal"}) + "\n") cp.flush() continue # log and move on; don't kill the batch ```

And two batch-specific optimizations that dwarf everything above in impact:

  • Use the batch API when you can. Many providers (and Qubax) offer async batch endpoints at 50% off with 24-hour completion windows. If you don't need realtime, half your bill disappears.
  • Prompt caching is free money. Identical system prompts across requests earn automatic cache discounts on supporting models — up to 90% off cached input tokens. Keep stable prompt prefixes stable.

Part 7: The Checklist

Print this and tape it to your monitor:

  • [ ] Timeout on every request: connect=5s, read= model-appropriate
  • [ ] Retry only 408/429/5xx + network errors — never 400/401
  • [ ] Exponential backoff with full jitter, max 4–5 attempts
  • [ ] Honor Retry-After headers
  • [ ] Circuit breaker per model/provider (5 failures → 60s cool-down)
  • [ ] Fallback chain across substitutable models; return which model answered
  • [ ] Defensive JSON parsing + one structured-output retry
  • [ ] Batch checkpointing + idempotency keys for long jobs
  • [ ] Alert on fallback rate, not just failures — rising fallbacks = degrading quality or capacity
  • [ ] Log model used, latency, and tokens for every call (your future self, debugging at 2am, says thanks)

Wrapping Up

Resilience for AI APIs is mostly discipline: classify errors, retry only what's transient, fail fast when a provider is down, and fail over to a substitutable model when one exists. The patterns above — backoff with jitter, circuit breakers, fallback chains, checkpointed batches — are the complete toolkit, and they're all config-level decisions once your provider layer is unified.

The single highest-leverage decision is using a unified OpenAI-compatible gateway: one endpoint, one key, hundreds of models. Set up your fallback chain on Qubax AI — browse the model catalog to pick your chain, and read the docs for integration in under 10 minutes.

FAQ

Which HTTP status codes should I retry for AI APIs?

Retry 408, 429, 500, 502, 503, 504, and network-level errors (timeouts, connection resets). Never retry 400 (fix your request), 401/403 (fix your key), or 404 (fix your model name).

Why is jitter important in retry backoff?

Without jitter, every client that failed at the same moment retries at the same moments, creating synchronized load spikes ("thundering herd") that can keep a struggling provider down. Randomizing wait times smooths recovery load across time — full jitter (uniform 0 to 2^attempt) is the AWS-recommended scheme.

How many retry attempts is right?

4–5 total attempts for interactive requests. More than that and your user is staring at a spinner while you burn quota; fail over to another model instead of retrying the same one into oblivion.

What's the difference between a retry and a fallback?

Retries re-attempt the same model after transient failure; fallback switches to a different model when retries are exhausted (or a circuit breaker opens). Production systems need both: retries absorb blips, fallback absorbs outages.

Can I fall back across different providers' models?

Yes — if they share an API format. OpenAI-compatible gateways like Qubax expose Anthropic, OpenAI, Google, DeepSeek, GLM, Qwen, and more behind one /v1/chat/completions endpoint, so a cross-provider fallback chain is just an ordered list of model strings.

How do I handle rate limits (429) specifically?

Back off exponentially with jitter, honor the Retry-After response header when present, and reduce concurrency client-side (a semaphore on in-flight requests beats retrying harder). For sustained 429s, the fix is quota or batching — not more retries.

Should the fallback chain order models by quality or cost?

By task criticality. User-visible generation: quality first, cost second. Bulk extraction/classification: cheapest capable model first — often 30× cheaper with acceptable quality. Track which model actually answered and alert when fallbacks spike.

Where can I see live per-model pricing to plan my fallback chain?

qubax.ai/models lists live input/output pricing across 300+ models — useful for pricing a whole fallback chain, not just a single model, before committing.

🧠

Try GLM on Qubax

Zhipu AI models on Qubax. Up to 94% off.

View pricing

Article tags

#ai-api#python#retries#circuit-breaker#tutorial
Share:Post on XTelegramLinkedInYHacker NewsReddit
Qubax AI

Qubax AI

AI Models at up to 99% off · Pay with crypto

Reading about GLM and DeepSeek? Access them — plus 340+ other models — through one API. Zhipu AI models on Qubax. Up to 94% off.

Related articles