A 429 is the one error that gets worse the harder you fight it. Your client hits a limit, retries immediately, and the retry counts against the same limit that just rejected it. Multiply that by every worker in your pool and a brief throttle becomes a self-inflicted outage.
The fix is not complicated, but most naive implementations get one of three things wrong: they ignore the headers the server already sent, they retry on a fixed schedule, or they put no ceiling on how much work retries may consume. This article covers all three.
What HTTP 429 Actually Means
429 Too Many Requests means the server understood the request and is refusing because you sent too many in some window. It is explicitly temporary, which separates it from its neighbours:
- 401 and 403 are authentication and authorisation. Retrying changes nothing until the credentials or permissions change, and retrying a 403 forever is a common and expensive bug.
- 503 Service Unavailable means the server is overloaded or down, not that you exceeded a quota. Retryable, but back off more conservatively.
- 408 Request Timeout means the server gave up waiting on your request. Retryable, and usually safe to retry quickly.
Your retry policy should branch on this. Treating every failure the same way is how a permanent error gets retried eight times before anyone notices.
Read the Response Before You Retry
Most rate limiters tell you exactly what to do, and reading the response is free. What the headers contain varies by provider, so check the documentation for whatever you are calling; this Twitter API rate limits explained reference shows how per-endpoint windows and reset headers are usually laid out.
1. Retry-After
The only header standardised for this, defined in RFC 9110. It comes in two forms, and a parser that handles only one will break on the other:
- Retry-After: 120, a delay in seconds
- Retry-After: Wed, 09 Sep 2026 13:00:00 GMT, an absolute HTTP date
Honour it when present. No backoff formula you invent will beat a number the server calculated itself.
2. The X-RateLimit family
Not standardised, but near universal in practice. Three headers, usually:
- X-RateLimit-Limit, the ceiling for the window
- X-RateLimit-Remaining, how many requests you have left
- X-RateLimit-Reset, when the window resets, as a Unix timestamp on most APIs and as seconds-from-now on others
Read these on successful responses, not just failed ones. If remaining is low, slow down before you are rejected. Reacting only to 429s means you always find the wall by hitting it.
3. When there are no headers at all
Plenty of APIs return a bare 429 with nothing attached. Then you compute the delay yourself, which is the next section.
Exponential Backoff, and Why Jitter Is Not Optional
Exponential backoff means each retry waits roughly twice as long as the last, capped at some maximum. That part is well known. The part that gets skipped is jitter, and skipping it turns a recoverable blip into an outage.
Picture fifty workers all receiving a 429 at the same moment because a shared quota ran out. Without jitter they all sleep one second and all retry in the same millisecond, get rejected together, sleep two seconds, and retry together again. The herd stays synchronised for the whole sequence, so the server sees fifty simultaneous spikes instead of a trickle.
Jitter breaks the synchronisation by randomising each client's wait. The variant to use is full jitter, which picks a random delay between zero and the computed ceiling:
import email.utils, random, time
from datetime import datetime, timezone
import requests
RETRYABLE = {408, 429, 500, 502, 503, 504}
def retry_after_seconds(value):
"""Retry-After is delta-seconds or an HTTP-date. Handle both."""
if not value:
return None
value = value.strip()
if value.isdigit():
return float(value)
try:
when = email.utils.parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
def request_with_retry(url, max_attempts=6, base=1.0, cap=60.0, deadline=120.0):
started = time.monotonic()
for attempt in range(max_attempts):
try:
response = requests.get(url, timeout=10)
except requests.RequestException:
if attempt == max_attempts - 1:
raise
response = None
else:
if response.status_code not in RETRYABLE:
return response
if attempt == max_attempts - 1:
response.raise_for_status()
header = response.headers.get("Retry-After") if response is not None else None
delay = retry_after_seconds(header)
if delay is None:
delay = random.uniform(0, min(cap, base * (2 ** attempt)))
if time.monotonic() - started + delay > deadline:
raise TimeoutError("retry deadline exceeded; defer this work instead")
time.sleep(delay)
Four details in that snippet are the ones worth copying. The server's Retry-After wins over the computed delay whenever it is present, and it is parsed in both of its legal forms rather than only as an integer, because an HTTP-date silently falling through to your own backoff defeats the point of reading the header. The random range starts at zero rather than at the previous ceiling, which is what actually spreads the herd. The overall deadline bounds the total time the operation may spend retrying, so a server that asks for a day off does not block a worker for a day; past the deadline the right move is to defer the work to a queue, not to retry early. And network-level failures are caught alongside status codes, since a connection reset never produces a response object to inspect.
Which Status Codes Are Safe to Retry
Retry the codes that represent a transient condition, and fail fast on everything else. A reasonable default policy:
- Retry: 408, 429, 500, 502, 503, 504, plus connection errors, DNS failures and read timeouts at the network layer.
- Do not retry automatically: 400, 401, 403, 404, 405, 409, 422. These describe something wrong with the request itself, so do not retry them without resolving the cause first or checking the provider's guidance. Some are resolvable and then legitimately resubmitted; a 409 conflict, for example, often clears once you re-read the current state and rebuild the request.
401 and 403 are the pair most often retried by mistake, because in a log full of network errors they look transient, and they never are. This breakdown of 401 versus 403 responses on an API covers the distinction and where each appears.
One caveat on writes. A request that timed out may have been processed before the response was lost, so retrying can duplicate the effect. For anything non-idempotent, send an idempotency key, or accept that retries are unsafe and skip them.
Retry Budgets and Circuit Breakers
Backoff controls how long one request waits. It says nothing about how much of your total capacity retries may consume, and that is the failure mode that takes systems down. If a dependency starts failing everything, a client that retries six times turns one unit of load into six, exactly when the dependency can least afford it.
Two controls fix that:
- A retry budget. Cap retries as a fraction of successful requests, commonly around ten percent. Past the budget, new failures fail immediately rather than retrying, so load stays bounded however badly the dependency behaves.
- A circuit breaker. After a threshold of consecutive failures, stop sending requests for a cooldown, then let one probe through. If it succeeds, close the circuit and resume. This turns a slow cascade of timeouts into fast, cheap failures your callers can handle.
Both belong at client level, shared across workers. A per-request retry limit does nothing to stop a thousand requests each retrying five times.
Putting It Together on a Rate-Limited API
Social and search APIs are where most developers meet 429 first, because the quotas are tight and the data is worth paging through. The official X API shows the pattern well: per-endpoint windows, headers carrying the remaining allowance, and a hard stop when the window is exhausted. Read the specifics once before tuning any backoff constants against them.
Whatever API you are calling, the checklist is the same:
- Read Retry-After first and obey it when present.
- Watch the remaining-quota header on successful responses so you can throttle before being throttled.
- Use exponential backoff with full jitter when there is no header to read.
- Branch your policy on the status code rather than retrying everything.
- Cap total retry load with a budget, and add a breaker for sustained failure.
- Log every retry with its status code and delay; a rising retry rate is an early warning that something upstream is degrading.
Final Thoughts
Rate limiting is not adversarial. It is the server telling you the pace it can sustain, and a well-behaved client listens the first time. The implementations that survive production are boring: honour the header, randomise the wait, know which failures are worth repeating, and cap the total work retries can create. Get those four right and 429 stops being an incident and becomes a line in a log.
