How to Design AI API Retries: What to Retry, How Long to Back Off, and What to Do When a Stream Breaks

2026-09-11 46 0

When a model call in production goes down, the easiest fix is to wrap it in an outer retry(3). That one line can save some requests, but it can also completely kill your key during peak traffic—because it treats all errors the same, including those that will never succeed no matter how many times you retry.

Let's start with the conclusion: the first step in a retry mechanism is not choosing a backoff algorithm, but deciding whether the error is worth retrying. Get that wrong, and no amount of fine-tuning the parameters afterward will help.

Step 1: Split errors into two piles

Classify errors by whether the server can recover on its own after a while, not by the size of the status code.

Retryable (transient failures):

  • 429 with rate-related error codes, such as rate_limit_exceeded, slow_down—you've consumed too much concurrency or token quota within this minute; just wait for the window to roll over
  • 500 internal error, 502 gateway error, 503 service unavailable (server_is_overloaded), 529 overloaded_error—upstream capacity temporarily maxed out
  • 408 request timeout
  • Connection-layer failures: TCP disconnect, DNS flapping, TLS handshake failure, usually surfaced in SDKs as APIConnectionError

Do not retry (deterministic failures):

  • 400: missing parameters, malformed JSON, context_length_exceeded context too long. The request itself is wrong; resending it a hundred times yields the same error
  • 401: missing, misspelled, or revoked key
  • 403: insufficient permissions or unauthorized access to a specific model
  • 404: misspelled model ID or wrong endpoint path

The easiest one to confuse is 429. The same 429 with error code insufficient_quota means the account quota is exhausted, not rate limiting—waiting won't restore it; you can only top up or switch keys. If your retry logic only looks at the status code and not the error.code in the body, then as soon as the balance hits zero it turns into hammering upstream with invalid requests, filling the logs with 429s and drowning out the real cause.

So the decision function must at least look like this:

RETRYABLE_STATUS = {408, 409, 500, 502, 503, 504, 529}

def should_retry(status, body):
    if status == 429:
        code = (body.get("error") or {}).get("code")
        return code != "insufficient_quota"
    return status in RETRYABLE_STATUS

Something like 400 should not enter the retry queue; it belongs in alerts and parameter validation. For a detailed breakdown of Claude-side 400s, see the comparison of four parameter rejection types.

Decision flowchart for whether to retry based on status code and error code

Step 2: How long to wait—check response headers first, then compute backoff

Once you get a retryable error, prioritize reading retry-after in the response headers. The server explicitly tells you when the window rolls over, and this value is more accurate than any local algorithm. Some implementations give seconds, others give an HTTP timestamp; parsing must be compatible with both, and you should add a bit of random offset to avoid a batch of requests all rushing back at the same second.

When that header is absent, use Exponential Backoff with Full Jitter:

import random

def wait_seconds(attempt, retry_after=None, base=1.0, cap=20.0):
    if retry_after is not None:
        return min(retry_after + random.uniform(0, 0.5), cap)
    ceiling = min(cap, base * (2 ** attempt))
    return random.uniform(0, ceiling)

The key is the random value on the last line, not base * 2**n itself. Pure exponential backoff makes all instances retry at the same moment in multi-client scenarios, creating a thundering herd effect, and the second wave often hits harder than the first. Taking a random number between 0 and the cap spreads retries out over time and actually recovers faster.

For parameters, here are a few usable starting points: initial 1 second, cap around 20 seconds, total retries controlled to 2–3. Don't pile on more than 5 attempts—LLM requests are slow as it is, and 5 retries plus backoff waits can mean the user has already been waiting two minutes; better to fail fast and degrade. For more on choosing wait times, see How long should exponential backoff retries wait.

Even more valuable than a retry count is a total time budget. Give the whole call (including all retries) an upper limit, say 90 seconds, and abandon it once exceeded, no matter how many attempts are left. That's what keeps user-facing latency controllable.

Step 3: Don't let retry counts multiply

This is the most common invisible amplifier in production.

The official OpenAI and Anthropic SDKs retry by default, typically doing about 2 exponential-backoff attempts for connection errors, 408, 409, 429, and 5xx. If you also wrap them in Tenacity, axios-retry, or toss tasks into queues like Celery or Sidekiq with automatic redelivery, the actual number of retries is multiplied: SDK 3 × framework 3 × queue 3 = 27 requests. A single transient 503 gets amplified into a targeted attack on your own TPM quota.

Handle it by choosing one of the two, not both:

  • Let the SDK do it: set max_retries=2, and let the outer framework only handle business-level failures without resending requests;
  • Control it yourself: set the SDK's max_retries to 0 and put backoff logic in your own layer—the advantage being you can read retry-after, branch by error code, and emit your own metrics.

Also set a timeout on every request. For long-output tasks, estimate the timeout based on expected generation time, not a default blanket of a few tens of seconds; but don't leave it unset either, or one stuck connection will keep occupying concurrency quota and drag all subsequent requests into 429.

When retries are exhausted, throw an exception with context—which model, which attempt, the final status code, and request id—rather than a bare RetryError. Those fields save most of the debugging time when you're investigating.

Step 4: Streaming output cannot be blindly replayed

Standard retry logic only holds before the stream is established. Once the server starts emitting tokens and the client has consumed several chunks, if the connection drops or an error occurs mid-stream, silently resending the entire request in the background and appending new content after the old output will definitely produce duplicated paragraphs or broken semantics.

Handle it in two ways depending on whether the first token has arrived:

  • Failure before the first token (connection never established, 5xx during handshake): equivalent to a normal request failure; can be silently retried with no user awareness;
  • Failure after the first token: cannot be concatenated. Either clear the content already rendered for this turn and regenerate the whole turn as a replacement, or clearly mark "generation interrupted" in the UI and let the user decide whether to start over.

Implementation-wise, the client needs to maintain a flag for "has this turn produced content," and the retry decorator reads this flag to decide which path to take. Also, streaming request timeouts should not be set by total duration; it's more reasonable to set them by chunk interval—only judge the stream as broken if no data arrives between two chunks for N seconds, otherwise long answers will be killed by their own timeout.

Step 5: Calls with side effects must be idempotent

What you're retrying is an HTTP request, but the server may have already finished the work—the response was lost on the way back, and all you see is a timeout. For pure text generation, resending costs at most one extra token; but in Agent scenarios, if the tool call returned by the previous request has already been executed (an order placed, an email sent, a database written), replaying will produce a second side effect.

So the tool execution layer must enforce its own idempotency: generate a business-side idempotency key for each turn of the call and check whether it has already completed before executing. Don't expect the model API layer to cover this for you.

On relay endpoints, confirm two more things

If your requests don't go directly to the vendor but through an OpenAI-compatible relay endpoint, your retry logic needs one extra verification: whether the error was generated by the gateway or passed through from upstream, whether the retry-after header is preserved, and whether the field structure of error.code matches the checks in your code. The safest approach is to deliberately max out quota and deliberately use a wrong model ID during testing, print the full response headers and body, and then write the decision branches accordingly—error body structures vary significantly across implementations, and copying field names from someone else's docs easily leads to missed detection.

Quota semantics are also worth clarifying first. NexAIX is a relay with a single endpoint and a single provider; model pages indicate whether a model is deployed on proprietary compute with open weights or through an official closed-source licensed channel, with quota and rate limits publicly documented, and integration only requires changing base_url—no changes to retry or backoff code. Per-key isolation is very practical in retry design: split batch offline tasks and online user requests into two keys, so aggressive retries for offline tasks hitting rate limits won't drag online requests into 429 together—much simpler than writing a priority queue in code.

One more overlooked debugging direction: if retries succeed but output quality fluctuates, don't rush to tune backoff parameters—first confirm whether the response is being silently swapped to a smaller model or having context truncated. NexAIX's four commitments and verification methods document no model swapping, no precision reduction, no context truncation, plus the corresponding self-check methods; for a more general verification approach, see supply transparency and model downgrade detection.

A default configuration you can copy directly

  • Decision: retry only 408/409/5xx/529, connection errors, and 429s with code != insufficient_quota
  • Wait: if retry-after exists, obey it; otherwise full jitter exponential backoff with initial 1s and cap 20s
  • Attempts: 2–3, plus a total time budget
  • Layers: choose either SDK built-in retries or an outer framework, never stack both
  • Timeouts: non-streaming by expected generation time; streaming by chunk interval
  • Streaming: never silently replay after the first token
  • Wrap-up: on failure throw an exception with request id and status code, and count/alarm separately for the two meanings of 429

One last reminder: retries are a way to stop the bleeding, not a cure. If 429s remain persistently high in the logs, it means the concurrency model or quota tier itself needs adjustment; backoff merely moves the queue to the client side. For systematic handling on the rate-limiting side, continue with From 429 headers to backoff retries and traffic isolation.

Last updated on 2026-09-11 15:52:37

Related Posts

How to Handle AI API Rate Limiting: From 429 Headers to Backoff Retries and T...
Two Layers of AI API Privacy Risk: Vendor Log Retention and Relay Log Persist...
API Relay Comparison: Direct Official API or Relay?
Locking Models and Disabling Automatic Routing on AI API Aggregators: Request...
How to Choose an AI API Aggregation Platform: Verify Key Differences by Capab...
How to Conduct AI API Performance Testing: Five Fixed Variables and Gray-Scal...

Comments(0)

No comments yet

Leave a Comment