Retries, Backoff and Rate Limits
Retry only what can succeed next time, back off exponentially with jitter, and obey Retry-After on a 429.
Retry(total=5, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504])A retry is a bet that the same request will work later. That bet pays off for a dropped connection, a timeout, a 502 from a restarting pod or a 429 from a rate limiter. It never pays off for 400, 401, 403, 404 or 422 — those describe your request, and your request has not changed, so a retry is noise in someone else's logs.
Backoff, and why jitter is not optional
Exponential backoff doubles the wait each attempt: base * 2 ** attempt, capped so it cannot grow for ever. Full jitter then picks a random point inside that window — random.uniform(0, window) — and the randomness is the important half. Without it, every client that failed during the same incident has the same schedule, so they all come back at the same instant and knock the recovering service over again. A tight loop with no backoff at all does this continuously: one blip becomes an outage that you are causing.
Honour what the server tells you
A 429, and often a 503, carries Retry-After. It is either a number of seconds or an HTTP-date; email.utils.parsedate_to_datetime(date) parses the date form and raises ValueError on anything malformed. When the header is there, use it rather than your own arithmetic.
Bounds
Cap the attempts and cap the total time, measured with time.monotonic() so a clock change cannot confuse it. And be careful with POST: a retried create can create twice. urllib3.util.Retry is deliberately conservative here — its allowed_methods default is the idempotent set, and POST is not in it.
Example
When to use it
- Making a nightly sync survive a thirty-second upstream deploy.
- Staying inside a vendor's rate limit without hard-coding a sleep.
- Giving a queue consumer a total time budget so a stuck call cannot hold a message for ever.
- Working out why a provider blocked you after an incident.
More examples
Retries you do not have to write
The version to use in production, because it retries inside the connection pool and already understands Retry-After. Note that total is the retry budget, not the attempt count, and that the timeout you pass applies per attempt, so five retries at ten seconds can still block for a minute.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
retry = Retry(
total=5,
backoff_factor=0.5, # sleeps backoff_factor * 2 ** (retries so far)
backoff_jitter=0.3, # urllib3 2.0+; without it, clients sync up
backoff_max=30, # the cap, default 120
status_forcelist=[429, 500, 502, 503, 504],
respect_retry_after_header=True, # the default, and worth keeping
)
session = requests.Session()
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
r = session.get("https://api.example.com/v1/orders", timeout=10)
print(r.status_code)A loop with a deadline instead of a count
Use this when the caller has a deadline of its own — an HTTP handler, a queue consumer with a visibility timeout. Counting attempts alone cannot promise an upper bound on elapsed time, and a budget can.
import time
import httpx
def get_with_deadline(client, url, budget_seconds=30.0, base=0.5):
deadline = time.monotonic() + budget_seconds # monotonic: immune to clock changes
attempt = 0
while True:
try:
r = client.get(url, timeout=5.0)
if r.status_code < 500 and r.status_code != 429:
return r # success, or a 4xx worth surfacing
except httpx.TransportError: # connect/read/timeout failures
pass
remaining = deadline - time.monotonic()
if remaining <= 0 or attempt >= 5:
raise TimeoutError(f"{url} did not succeed within {budget_seconds}s")
time.sleep(min(remaining, base * 2 ** attempt))
attempt += 1Reading Retry-After in both of its forms
Needed whenever you handle 429s yourself. The date form is rare but legal, and parsedate_to_datetime returns a naive datetime for a -0000 offset, which is the detail that breaks the subtraction if you do not handle it.
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def retry_after_seconds(value, default=1.0):
if value is None:
return default
try:
return max(0.0, float(value)) # the common form: delta-seconds
except ValueError:
pass
try:
when = parsedate_to_datetime(value) # the other legal form: an HTTP-date
except (TypeError, ValueError):
return default # a malformed header is not a reason to crash
if when.tzinfo is None: # "-0000" parses as naive
when = when.replace(tzinfo=timezone.utc)
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
print(retry_after_seconds("120"))
print(retry_after_seconds("Wed, 21 Oct 2026 07:28:00 GMT"))
Discussion