Every script that talks to an API eventually gets a 429 Too Many Requests. The naive fix is time.sleep(1) in a loop. It works until you run ten workers, they all sleep exactly one second, wake up together, and hit the limit again in lockstep.
What actually works is three rules, in order:
- If the server tells you when to come back (
Retry-After), obey it. - Otherwise back off exponentially, with jitter.
- Only retry what is safe to retry.
This guide starts from a plain retry loop, shows where it breaks, and builds up to backoff that is safe to run in parallel.
Starting point: a basic retry loop
Without error handling, one bad operation crashes the program. Division by zero is the simplest example:
# This will raise a ZeroDivisionError and crash the program
num1 = 10
num2 = 0
result = num1 / num2 # Raises ZeroDivisionError: division by zero
A try/except inside a bounded loop turns that into a retry: attempt the operation, wait, try again, and give up after a fixed number of attempts. (Division by zero will never succeed on a retry; it only stands in for an operation that can fail.)
import time
def division_with_retry(num1, num2, retries=3, sleep_time=1):
"""
Tries to divide num1 by num2 with retries in case of ZeroDivisionError.
Args:
num1 (int/float): The numerator.
num2 (int/float): The denominator.
retries (int): The number of retry attempts.
sleep_time (int/float): Time to wait between retries in seconds.
Returns:
str: The result of the division or an error message.
"""
for attempt in range(retries + 1): # +1 to include the first attempt
try:
result = num1 / num2
return f"Success: {result}"
except ZeroDivisionError as ex:
if attempt < retries:
time.sleep(sleep_time) # Delay between retries
print(f"Retrying... (Attempt {attempt + 1}/{retries})")
else:
return f"Error: {str(ex)}"
print(division_with_retry(10, 0)) # Output: Error: division by zero
print(division_with_retry(10, 2)) # Output: Success: 5.0
- Retry limit: the loop runs at most
retries + 1times (the first attempt plus the retries), so it can’t loop forever. - Delay:
time.sleep()gives a transient problem time to clear before the next attempt. - Success: the function returns as soon as an attempt works.
- Final failure: once the retries are exhausted, it returns the error instead of raising.
The same logic is sometimes written as a first attempt followed by a while loop that counts down the remaining retries:
import time
def division_with_retry(num1, num2, retries=3, sleep_time=1):
"""
Tries to divide num1 by num2 with a specified number of retries in case of ZeroDivisionError.
"""
calculated = False
result = ""
try:
result = num1 / num2
except ZeroDivisionError as ex:
remaining_retries = retries
while remaining_retries > 0 and not calculated:
time.sleep(sleep_time) # Delay between retries
try:
result = num1 / num2
calculated = True
except ZeroDivisionError:
remaining_retries -= 1
result = f"Error: {str(ex)}"
return result
print(division_with_retry(10, 0)) # Output: Error: division by zero
The for version is easier to read: one loop, one exit per outcome.
To retry on more than one exception type, pass a tuple to except:
def safe_division_with_retry(num1, num2, retries=3, sleep_time=1):
for attempt in range(retries + 1):
try:
result = num1 / num2
return f"Success: {result}"
except (ZeroDivisionError, TypeError) as ex:
if attempt < retries:
time.sleep(sleep_time)
print(f"Retrying... (Attempt {attempt + 1}/{retries})")
else:
return f"Error: {str(ex)}"
print(safe_division_with_retry(10, "two")) # Output: Error: unsupported operand type(s) for /: 'int' and 'str'
print(safe_division_with_retry(10, 0)) # Output: Error: division by zero
The pattern applies to anything that fails intermittently: API requests to a busy server, database connections to an overloaded host, files that are temporarily locked. For HTTP APIs, the fixed one-second sleep is where it falls short, which is what the rest of this guide fixes.
What to retry
| Signal | Retry? | Why |
|---|---|---|
429 Too Many Requests | Yes | Rate limit. Defined in RFC 6585. |
503 Service Unavailable | Yes | Often comes with Retry-After. |
500, 502, 504 | Yes, bounded | Transient upstream/gateway failures. |
| Connection error / timeout | Yes, if idempotent | The request may or may not have been processed. |
400, 401, 403, 404, 422 | No | Retrying won’t change the answer. Fix the request. |
Retry-After can be either a number of seconds or an HTTP date. Handle both. (RFC 9110 §10.2.3, RFC 6585 §4)
Why jitter matters
Pure exponential backoff (base * 2**attempt) spreads retries out over time, but every client that failed at the same moment retries at the same moment. Adding randomness breaks the synchronization. AWS’s analysis compared strategies and “full jitter”, sleep = random(0, min(cap, base * 2**attempt)), gave the best balance of total work and completion time. (AWS Architecture Blog: Exponential Backoff and Jitter)
Hand-rolled version (no dependencies beyond requests)
# >GLB_ // retry with Retry-After + full jitter
import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}
def parse_retry_after(value: str | None) -> float | None:
"""Retry-After is either delta-seconds or an HTTP-date (RFC 9110 §10.2.3)."""
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
pass
try:
when = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
def full_jitter(attempt: int, base: float, cap: float) -> float:
return random.uniform(0, min(cap, base * 2**attempt))
def request_with_backoff(
session: requests.Session,
method: str,
url: str,
*,
max_retries: int = 6,
base: float = 0.5,
cap: float = 30.0,
retry_non_idempotent: bool = False,
**kwargs,
) -> requests.Response:
kwargs.setdefault("timeout", 10)
can_retry = retry_non_idempotent or method.upper() in IDEMPOTENT_METHODS
for attempt in range(max_retries + 1):
try:
resp = session.request(method, url, **kwargs)
except (requests.ConnectionError, requests.Timeout):
if not can_retry or attempt == max_retries:
raise
delay = full_jitter(attempt, base, cap)
else:
if resp.status_code not in RETRYABLE_STATUS or attempt == max_retries:
return resp
# 429 means "not processed", so it's safe even for POST
if not can_retry and resp.status_code != 429:
return resp
retry_after = parse_retry_after(resp.headers.get("Retry-After"))
delay = retry_after if retry_after is not None else full_jitter(attempt, base, cap)
time.sleep(delay)
raise RuntimeError("unreachable")
Usage:
with requests.Session() as s:
r = request_with_backoff(s, "GET", "https://api.example.com/v1/items", params={"page": 1})
r.raise_for_status()
Design decisions worth calling out:
- Retry-After beats your formula. The server knows its window; your exponent doesn’t.
- POST is not retried on timeouts by default. A timeout doesn’t tell you whether the server created the resource. If the API supports an idempotency key header, send one and pass
retry_non_idempotent=True. - Bounded attempts. With
base=0.5,cap=30, six retries cap the worst case at roughly a minute and a half of sleeping. Unbounded retries turn an outage into a hung job.
Option 2: urllib3 Retry mounted on a Session
If you just need it to work, requests sits on urllib3, which already implements backoff and honors Retry-After:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=6,
backoff_factor=0.5, # sleep = backoff_factor * 2**(n-1)
backoff_max=30,
backoff_jitter=0.5, # urllib3 >= 2.0
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}),
respect_retry_after_header=True,
raise_on_status=False, # return the last response instead of raising
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
Caveats:
Retry-Afteris only honored for the status codes urllib3 considers rate-limit related (413, 429, 503). (urllib3 Retry)allowed_methodsexcludesPOSTby default for the same idempotency reason as above.backoff_jitteradds a random amount on top of the exponential value; it’s not full jitter, but it’s enough to desynchronize workers.
Option 3: tenacity
When the retried thing isn’t a single HTTP call (a paginated fetch, an SDK method), tenacity decorates any function:
import requests
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_random_exponential,
)
class RetryableHTTPError(Exception):
pass
@retry(
retry=retry_if_exception_type((RetryableHTTPError, requests.ConnectionError, requests.Timeout)),
wait=wait_random_exponential(multiplier=0.5, max=30), # full jitter
stop=stop_after_attempt(7),
reraise=True,
)
def fetch_page(session: requests.Session, page: int) -> dict:
r = session.get("https://api.example.com/v1/items", params={"page": page}, timeout=10)
if r.status_code in {429, 500, 502, 503, 504}:
raise RetryableHTTPError(r.status_code)
r.raise_for_status()
return r.json()
wait_random_exponential implements full jitter. It doesn’t read Retry-After on its own; for that, write a custom wait callable that reads the header from the exception. (tenacity docs)
Backoff is reactive. Don’t build on it alone.
Backoff handles the 429 after it happens. If your job expects to run near the limit, read the rate-limit headers the API sends (X-RateLimit-Remaining, RateLimit-*, names vary by provider) and pace requests before you get rejected. That’s the topic of batch API requests in Python: throughput vs rate limit trade-off.
Gotchas
- Retrying errors that can’t succeed.
ZeroDivisionError,TypeErrorand HTTP 400/401/403/404/422 fail the same way every time. Retrying them only adds delay before the same error. Catch narrowly: only exceptions that can be transient. - Off-by-one in the attempt count.
range(retries + 1)meansretriesre-tries after the first attempt. Make the name match what you count. - Fixed sleeps in parallel workers.
time.sleep(1)in every worker makes them retry in lockstep. Use jitter. - Returning errors as strings. The basic examples return
"Error: ..."to keep them short. In real code, re-raise after the last attempt (tenacity’sreraise=True, orraisein the loop) so callers can’t mistake a failure for a result.
Summary
- Retry 429, 503 and bounded 5xx. Never 4xx client errors.
Retry-Afterfirst, full jitter second.- Cap attempts and total sleep.
- Don’t retry non-idempotent requests on ambiguous failures unless you have idempotency keys.
- urllib3
Retryfor HTTP, tenacity for arbitrary functions, hand-rolled when you need exact control.
Related
- Batch API requests in Python: throughput vs rate limit trade-off paces requests before the 429s start.
- Understanding ip-api batch limits and effective throughput is a worked example of a request-based rate limit and its headers.
- How to safely retrieve and return SQL query results in Python covers the cursor handling to wrap in a retry when the database connection is flaky.
- PostHog across subdomains and projects covers the browser-side version of rate limiting:
posthog-jsdrops or delays events above 10 per second. - Selenium vs Beautiful Soup covers scrapers, which hit the same 429s as API clients.
References
- RFC 6585 §4 — 429 Too Many Requests: https://www.rfc-editor.org/rfc/rfc6585#section-4
- RFC 9110 §10.2.3 — Retry-After: https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3
- RFC 9110 §9.2.2 — Idempotent methods: https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
- AWS Architecture Blog — Exponential Backoff and Jitter: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- urllib3 —
Retry: https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry - Requests — Transport adapters: https://requests.readthedocs.io/en/latest/user/advanced/#transport-adapters
- tenacity: https://tenacity.readthedocs.io/en/latest/
- Python
email.utils.parsedate_to_datetime: https://docs.python.org/3/library/email.utils.html#email.utils.parsedate_to_datetime