Browse documentation
Retries and backoff
What to retry, what never to retry, and how fast.
The decision, in one table
| Status | Retry | How |
|---|---|---|
429 with retry-after | Yes | Sleep the header value, then retry. Usually one second. |
429 without retry-after | No | Hard-capped quota. It will not change this period. |
402 | No | Suspended account. Somebody has to settle billing. |
401, first ever use of a key | Once | Wait a minute — it may not have propagated yet. |
401, a key that worked before | No | Revoked or lapsed. Check the console. |
400, 404 | No | The request is wrong. Retrying will not fix it. |
408 | Once | And narrow the query — see below. |
500, 503 | Yes | Exponential backoff with jitter. |
| Network error, connection reset | Yes | Exponential backoff with jitter. |
200 with partial: true | Yes | Treat like 503: part of the corpus was unreachable. |
The one that catches people is 429 meaning two unrelated things. Branch on
retry-after, not on the status — a loop that retries every 429 will hammer a
hard-capped key thousands of times and never succeed.
Every read is idempotent
Every endpoint is a GET and nothing has side effects, so a retry cannot duplicate
anything. There is no idempotency key to manage, and none is needed.
The only cost of a retry is that it bills another request. That matters for the retries you
should not be making: a retry loop against a hard cap does not spend quota (it is already
exhausted), but a retry loop against 500 does.
A retry that behaves
import random, time, httpx
RETRYABLE = {408, 500, 502, 503, 504}
def search(client: httpx.Client, params: dict, attempts: int = 4) -> dict:
for attempt in range(attempts):
r = client.get("/search", params=params)
if r.status_code == 429:
after = r.headers.get("retry-after")
if after is None:
# Hard-capped monthly quota. Retrying changes nothing.
raise RuntimeError(f"quota exhausted: {r.text}")
time.sleep(int(after))
continue
if r.status_code in RETRYABLE and attempt < attempts - 1:
# Full jitter: without it, every client that failed together retries
# together, and the recovering service gets the same spike again.
time.sleep(random.uniform(0, 2 ** attempt))
continue
r.raise_for_status()
body = r.json()
if body.get("partial") and attempt < attempts - 1:
# 200, but part of the corpus was unreachable. A short answer here is a
# symptom, not a finding — retrying usually gets the whole thing.
time.sleep(random.uniform(0, 2 ** attempt))
continue
return body
raise RuntimeError("exhausted retries")
Three things that example does deliberately:
- Jitter, not a fixed doubling. Clients that failed together will otherwise retry together and re-create the spike that broke the thing they are waiting for.
- Retries
partial. It is a200, soraise_for_statuswill not catch it, and a caller that ignores it silently reports an incomplete answer as a complete one. - Raises on a hard cap rather than looping. Failing loudly at the point of the decision beats a retry budget draining into nothing.
Pacing before you get refused
Backoff is what you do after being refused; pacing is how you avoid it.
Every authenticated response carries x-ratelimit-limit and x-ratelimit-remaining. Read
them and slow down as remaining falls, rather than sprinting into a 429. There is no
x-ratelimit-reset — the window is one minute, so the recovery is always about a second
away.
Spending fewer requests helps more than any retry policy:
limit=0&facets=truesizes a topic in one call, instead of five exploratory searches.collapse=storystops you paying for the same article five times.assemble_contextis one call in place of a whole retrieval loop.
408: retry differently, not just again
A timeout usually means the query was expensive — a very broad term with no filters, a
large limit, or semantic mode across a wide vertical. The identical request will
probably time out identically.
Name the vertical, add a filter, or lower limit, then retry. That is a retry with new
information, which is the only kind worth making twice.
Over MCP
Most clients handle transport-level retries themselves. Two things to know:
429still arrives as an HTTP status, because that is what client backoff understands.- A suspended account or exhausted quota arrives as a tool error, not a status — your
retry logic will not see it, and the agent reading the result has to. Do not let a tool
error labelled
isErrordisappear silently into a loop.
The transport is stateless, so a retried request needs no session recovery: any replica can serve it, and the key is re-presented every time.
Next
- Errors
- Rate limits and quotas
- Reading a result — on
partial