Practical infra coding

Respect a 429 Retry-After header

mediumAPIs and HTTP

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-After says (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: raise RuntimeError instead of blocking the job for an hour;
  • raises RuntimeError after max_attempts responses 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:

  1. seconds: 429 with Retry-After: 2, 429 with Retry-After: 5, then 200.
  2. http-date: 429 with Retry-After: Thu, 24 Sep 2026 07:28:30 GMT, then 200.
  3. garbage: 429 with Retry-After: soon, 429 with no header, then 200.
  4. too long: 429 with Retry-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:

  1. None in, None out.
  2. If the stripped value is all digits, it is delta-seconds: return int(value).
  3. Otherwise try parsedate_to_datetime. It raises ValueError or TypeError on text like soon; return None then. A date without a timezone is treated as UTC, since HTTP dates are always GMT.
  4. 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.

ComplexityTime O(max_attempts)Space O(1)
Python
from datetime import datetime, timezone
from 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-Remaining and 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.