Respect a 429 Retry-After header
Problem statement
A metrics exporter polls an API that rate-limits clients. When you go over the limit the API answers 429 Too Many Requests, and it may include a Retry-After header telling you how long to back off. The header comes in two forms (both are standard HTTP):
- a number of seconds, like
Retry-After: 120 - an HTTP date, like
Retry-After: Thu, 24 Sep 2026 07:28:30 GMT
Write get_respecting_429(client, path, clock, max_attempts=5, max_wait=60, base=1.0) that:
- returns
(response, seconds_waited)for the first non-429 response; - on a 429, waits exactly what
Retry-Aftersays (a date in the past means wait 0); - falls back to exponential backoff (
base * 2 ** (attempt - 1)) when the header is missing or cannot be parsed, and says which it used; - refuses to wait longer than
max_wait: raiseRuntimeErrorinstead of blocking the job for an hour; - raises
RuntimeErroraftermax_attemptsresponses that were all 429.
Use a FakeClock with now() and sleep(seconds), where sleep just moves the clock forward, and a FakeClient that replays a scripted list of responses. The clock starts at 2026-09-24 07:28:00 UTC. Run four scenarios:
seconds: 429 withRetry-After: 2, 429 withRetry-After: 5, then 200.http-date: 429 withRetry-After: Thu, 24 Sep 2026 07:28:30 GMT, then 200.garbage: 429 withRetry-After: soon, 429 with no header, then 200.too long: 429 withRetry-After: 3600.
Examples
Example 1
Input: The four scenarios above.
Output: seconds:
429, waiting 2s via Retry-After
429, waiting 5s via Retry-After
200 after waiting 7s
http-date:
429, waiting 30s via Retry-After
200 after waiting 30s
garbage:
429, waiting 1s via backoff (bad header 'soon')
429, waiting 2s via backoff (no header)
200 after waiting 3s
too long:
error: server asked for 3600s, more than max_wait=60s
Explanation: In http-date the target time is 30 s after the fake clock's start. In garbage the backoff delays are 1 s and 2 s for attempts 1 and 2.
Hints
Approach
Optimal
Split the work into a small parser and a loop.
retry_after_seconds(value, now) returns a number of seconds, or None if the header is missing or unusable:
Nonein,Noneout.- If the stripped value is all digits, it is delta-seconds: return
int(value). - Otherwise try
parsedate_to_datetime. It raisesValueErrororTypeErroron text likesoon; returnNonethen. A date without a timezone is treated as UTC, since HTTP dates are always GMT. - Return
max(0, when - now). A date that has already passed means you may retry right away.
The loop makes up to max_attempts requests. Any non-429 response is returned together with the total time waited, so the caller can log or alert on it. On a 429 it asks the parser for a delay and falls back to exponential backoff when the parser says None. The log line records which source was used; when you debug a rate-limit problem later, "we ignored the server's header" and "the server sent no header" are very different findings.
The max_wait check comes before the sleep. A server that says "come back in an hour" should fail the job with a clear message, not freeze a CI runner for an hour. Nothing sleeps after the last attempt.
Because the clock is injected, the http-date case is exact: the fake clock is at 07:28:00, the header says 07:28:30, so the wait is 30 s.
O(max_attempts)Space O(1)from datetime import datetime, timezonefrom email.utils import parsedate_to_datetime class Response: def __init__(self, status, headers=None): self.status = status self.headers = headers or {} class FakeClock: # Fake time source: sleep() advances the clock instead of waiting. def __init__(self, start): self.t = start.timestamp() def now(self): return self.t def sleep(self, seconds): self.t += seconds class FakeClient: def __init__(self, script): self.script = list(script) def get(self, path): return self.script.pop(0) def retry_after_seconds(value, now): # Returns seconds to wait, or None if the header is missing or unusable. if value is None: return None value = value.strip() if value.isdigit(): # delta-seconds form: "120" return int(value) try: # HTTP-date form when = parsedate_to_datetime(value) except (TypeError, ValueError): return None if when.tzinfo is None: when = when.replace(tzinfo=timezone.utc) return max(0, when.timestamp() - now) # a date in the past means "now" def get_respecting_429(client, path, clock, max_attempts=5, max_wait=60, base=1.0): waited = 0.0 for attempt in range(1, max_attempts + 1): resp = client.get(path) if resp.status != 429: return resp, waited if attempt == max_attempts: break header = resp.headers.get("Retry-After") delay = retry_after_seconds(header, clock.now()) source = "Retry-After" if delay is None: delay = base * 2 ** (attempt - 1) source = f"backoff (bad header {header!r})" if header else "backoff (no header)" if delay > max_wait: raise RuntimeError(f"server asked for {delay:.0f}s, more than max_wait={max_wait}s") print(f" 429, waiting {delay:g}s via {source}") clock.sleep(delay) waited += delay raise RuntimeError(f"still rate limited after {max_attempts} attempts") start = datetime(2026, 9, 24, 7, 28, 0, tzinfo=timezone.utc)scenarios = { "seconds": [Response(429, {"Retry-After": "2"}), Response(429, {"Retry-After": "5"}), Response(200)], "http-date": [Response(429, {"Retry-After": "Thu, 24 Sep 2026 07:28:30 GMT"}), Response(200)], "garbage": [Response(429, {"Retry-After": "soon"}), Response(429), Response(200)], "too long": [Response(429, {"Retry-After": "3600"}), Response(200)],}for name, script in scenarios.items(): print(f"{name}:") clock = FakeClock(start) try: resp, waited = get_respecting_429(FakeClient(script), "/v1/metrics", clock) print(f" {resp.status} after waiting {waited:g}s") except RuntimeError as e: print(f" error: {e}")Follow-up questions
- Many APIs also send headers such as
X-RateLimit-Remainingand a reset time. How would you slow down before you hit the limit? - Several worker threads share one API token. How do you make all of them pause when one of them gets a 429?
- Add a total deadline so the function never spends more than N seconds across all attempts.
Frequently asked questions
Cloud provider APIs, code hosting APIs and monitoring backends all rate-limit. Automation that ignores Retry-After keeps hammering during the penalty window and can get throttled for longer, or break other jobs sharing the same credentials. Handling 429 correctly is part of writing a well-behaved client.
Real sleeping makes tests slow and timing-dependent. With a fake clock the test controls time exactly: it can check that the code waited 30 s without waiting 30 s. The production call site passes a small object that wraps time.time and time.sleep.
Yes. The header is also defined for 503 Service Unavailable, for example during planned maintenance. A general retry helper usually checks for Retry-After on any retryable status and falls back to its own backoff otherwise.