HTTP 429 and Retry-After: Retry APIs Without a Traffic Spike
A 429 is a signal to slow down. Learn to parse Retry-After, add jitter, protect non-idempotent operations and test a bounded Python retry client locally.
A 429 Too Many Requests response is a request to slow down, not an invitation to try the same API call again immediately. When an API sends Retry-After, wait at least that long. When it does not, use a bounded retry policy with increasing delays and randomness (jitter). In both cases, stop after a small number of attempts or a time budget.
The important distinction is between making a retry and making a safe retry. Your client should respect the server's limit, avoid creating a second copy of a non-idempotent operation, and give up predictably when the API cannot serve the request soon enough.
This guide explains the HTTP rules, shows a Python client that handles 429 responses, and provides a local test endpoint you can use without sending traffic to a third-party API.
What a 429 response actually tells you
RFC 6585, section 4 defines 429 as a response for clients that have sent too many requests within a period. It allows the server to include Retry-After. The specification does not mandate a universal requests-per-minute limit, identify which client identity is being limited, or require every server to supply that header.
The limit might be attached to an API key, a user, a resource, an IP address, an endpoint or another service-defined quota. Cloudflare's explanation of HTTP 429 documents several distinct types of limits in one service. Those numbers are Cloudflare-specific examples, not a rule for every API.
If a request receives a 429, check the endpoint documentation and the response headers before assuming a different account, worker or IP address has its own allowance. Sending more simultaneous requests can make the situation worse.
A few responses that are often confused:
| Response | What it generally indicates | Reasonable client action |
|---|---|---|
|
| A rate limit has been triggered. | Read |
|
| Temporary overload or maintenance may prevent service. | Check for |
|
| Authentication is missing or invalid. | Fix credentials or authentication; do not blindly repeat the same request. |
|
| The server is refusing access. | Check permissions or policy; delay alone will usually not resolve it. |
A 429 also does not promise the next attempt will work. Other callers can continue consuming the quota, and the service can remain under pressure.
Read Retry-After correctly
The HTTP semantics standard, RFC 9110 section 10.2.3, defines two valid forms:
HTTP/1.1 429 Too Many Requests
Retry-After: 20 The number 20 means delay for 20 seconds after receiving the response. It is not an absolute timestamp or a number of milliseconds.
The other form is an HTTP date:
HTTP/1.1 429 Too Many Requests
Retry-After: Sun, 06 Nov 2095 08:49:37 GMT Here, the server indicates a particular time after which a new request should be attempted. The date above is illustrative, not an actual service recovery time. A real client must parse the HTTP date and calculate the remaining delay. Clock differences between client and server can affect that calculation. Where available, consult the response's Date header and the API's own behaviour.
A service may omit Retry-After. Some APIs expose additional rate-limit headers, but names and meanings can differ between providers. Do not assume an X-RateLimit-Reset value is in seconds, milliseconds or a Unix timestamp without reading that API's documentation.
Keep the wait separate from the retry decision. A valid Retry-After tells you *when* to try again, not whether replaying the original operation is safe.
Why fixed-delay retries can create another spike
Imagine 500 clients all receive a 429 at the same moment. If every client waits exactly one second and retries, the service receives another burst of 500 requests one second later. Doubling that fixed delay on the next attempt can still leave all 500 clients synchronized.
Jitter adds randomness to retry timing so those calls do not land on the same instant. This matters especially with shared scheduled jobs, fleets of workers and clients affected by the same outage. Google Cloud's retry guidance recommends exponential backoff with jitter for retryable operations, while cautioning against immediate retries that worsen cascading failures.
*Illustrative timing diagram, not measured network traffic. Each dot is a retry. The upper pair of clients uses the same delay, while the lower pair spreads attempts with jitter.*
Jitter does not create extra capacity and does not override Retry-After. If the server asks you to wait at least 60 seconds, a random five-second delay is still too short. A practical policy is to respect the server's minimum, optionally add a small positive random delay, then check whether there is enough time left to retry at all.
Decide whether the operation may be repeated
The code below deliberately retries GET requests on a 429 only. The GET method has safe semantics in HTTP, although real applications can have side effects such as logging or metering. Never extend a generic retry loop to every POST request just because the server used a retryable status.
For a request that creates an order, charges a payment method or starts a job, a timeout can hide work that already succeeded. Replaying that operation requires an explicit idempotency contract. Jitsumi's guide to API idempotency keys and duplicate prevention explains that separate problem with a worked example.
Before adding your own retry loop, also check whether your API SDK already retries 429s. Two stacked retry mechanisms multiply attempts and complicate deadlines. The Google Cloud Storage retry documentation shows how behaviour varies by client library and operation.
A bounded Python client for HTTP 429
This example uses Python's standard library, so it needs no third-party package. It makes GET requests, interprets the two Retry-After formats, and uses jittered exponential delays if the header is missing. It stops after four attempts or a 20-second overall budget, whichever is reached first.
Save as client.py:
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.request import Request, urlopen
import random
import time
def retry_after_seconds(value):
if not value:
return None
value = value.strip()
if value.isascii() and value.isdecimal():
return float(value)
try:
when = parsedate_to_datetime(value)
if when.tzinfo is None:
return None
return max(
0.0,
(when - datetime.now(timezone.utc)).total_seconds(),
)
except (TypeError, ValueError, OverflowError):
return None
def get_with_backoff(url, max_attempts=4, total_budget=20.0):
started = time.monotonic()
for attempt in range(1, max_attempts + 1):
remaining = total_budget - (time.monotonic() - started)
if remaining <= 0:
raise TimeoutError("Retry time budget exhausted")
try:
request = Request(
url,
headers={"Accept": "application/json"},
method="GET",
)
with urlopen(
request, timeout=min(5.0, remaining)
) as response:
return response.read()
except HTTPError as err:
if err.code != 429 or attempt == max_attempts:
raise
advised = retry_after_seconds(
err.headers.get("Retry-After")
)
if advised is None:
delay = random.uniform(
0.5, min(2 ** (attempt - 1), 8)
)
else:
delay = advised + random.uniform(0, 0.25)
remaining = total_budget - (time.monotonic() - started)
if delay >= remaining:
raise TimeoutError(
"Server's retry delay exceeds time budget"
) from err
time.sleep(delay)
raise AssertionError("Unreachable")
if __name__ == "__main__":
import sys
print(get_with_backoff(sys.argv[1]).decode()) The header parser uses Python's `email.utils.parsedate_to_datetime` for the HTTP-date form. An invalid or missing value falls back to jittered backoff. A delay longer than the caller's remaining budget raises an error instead of sleeping for hours.
This is an intentionally small example, not a general-purpose production HTTP client. It does not retry network errors, 503s or non-GET methods; it does not log attempt metadata or share a rate budget between processes. The response body is read into memory, so a streaming download needs a different implementation. In a larger application, use your HTTP client's supported retry configuration where possible, and coordinate concurrent requests against a shared quota.
Test it locally without an external API
The following server returns 429 twice with Retry-After: 1, then returns a small JSON response. Save as demo_server.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
attempts = 0
def do_GET(self):
if self.path != "/report":
self.send_error(404)
return
Handler.attempts += 1
if Handler.attempts <= 2:
self.send_response(429)
self.send_header("Retry-After", "1")
self.send_header("Content-Length", "0")
self.end_headers()
return
body = json.dumps(
{"status": "ready", "attempt": Handler.attempts}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever() Open two terminals in the same folder. In the first, start the demo server:
python demo_server.py Then in the second:
python client.py http://127.0.0.1:8765/report On a fresh server instance, the expected final result is:
{"status": "ready", "attempt": 3} The exact duration varies because the client adds random delay after each 429. This localhost example was executed with Python: the server returned 429, 429, then 200, and the client produced the JSON above. The local test does not establish how every third-party API implements rate limits.
Stop the server with Ctrl+C when finished. A second run against the *same* server instance will return success immediately because its in-memory request counter has already advanced; restart the server to repeat the 429 sequence.
What happens if the server asks for too long?
Suppose the client has 12 seconds left in its total budget but receives Retry-After: 120. Sleeping for two minutes would violate the caller's time budget. This implementation raises TimeoutError rather than quietly dropping the server's minimum delay. A real application should then surface a clear retry-later message or move the operation into a scheduled queue, without silently replaying it.
The overall budget is checked between attempts and before sleeping. Like most short examples, it is not a strict cancellation mechanism for every internal step; applications with hard end-to-end deadlines should use libraries and cancellation primitives designed for that purpose.
A practical retry decision table
| Observation | Decision | Pitfall to avoid |
|---|---|---|
|
| Treat the value as a minimum wait; retry only when safe and within budget. | Applying a shorter random delay. |
|
| Use jittered backoff and a finite attempt/time limit. | Sending immediate repeated calls. |
| A delay longer than your deadline | Stop, queue or report the delay. | Blocking a request handler indefinitely. |
|
| Check the API's idempotency contract before any replay. | Creating duplicate orders or jobs. |
|
| Surface the error and correct the cause. | Treating every |
| Persistent | Reduce request concurrency, coordinate quotas and inspect logs. | Adding more parallel workers to push through the limit. |
These are policy choices for a general client, not a promise that every API uses the same retry contract. Start with the provider's own documentation.
Reduce rate-limit errors instead of relying on retries
Retries help with temporary limits; they do not fix a workload that regularly exceeds its allowance. A reliable client also needs to control how many *new* requests it produces.
- Bound concurrency and pace requests. A per-worker delay does little when 30 workers each use the entire quota independently. Use an agreed budget or a shared queue when the API limit is shared.
- Avoid duplicate work. Cache appropriate results, coalesce identical reads where correctness permits, and store a job cursor or checkpoint so a restart does not rerun the full batch.
- Instrument retries. Record the status code, response
Retry-After, attempt number, delay, final outcome and overall duration. Do not log credentials or sensitive response bodies. - Give up visibly. An exhausted budget is an outcome to handle, not a reason for an unbounded background loop.
A useful metric is the share of requests that needed a retry, not just the eventual success rate. A system where every request succeeds after three 429s may still be producing avoidable load and long user-visible waits.
The rule worth keeping
For an API client, a 429 should trigger a decision, not an automatic rapid-fire loop. Check whether the request can be repeated, honour Retry-After when present, stagger retries when it is absent, and set a clear stopping point. If the errors persist, reduce the rate of *new* traffic instead of tuning the retry loop to be more aggressive.
*Technical basis: 429 in RFC 6585, Retry-After in RFC 9110, Google Cloud retry strategy and AWS's discussion of idempotent API retries. Reviewed 31 October 2026.*