Retry Flaky HTTP Requests with Exponential Backoff and Full Jitter in Python
import random
from dataclasses import dataclass
from typing import Iterator
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 4
base: float = 0.2
factor: float = 2.0
cap: float = 10.0
def exponential_delays(base: float, factor: float, cap: float) -> Iterator[float]:
n = 0
while True:
yield min(cap, base * (factor ** n))
n += 1
def full_jitter(delay: float) -> float:
return random.uniform(0.0, delay)
import time
from typing import Callable, Optional, TypeVar
from backoff import RetryPolicy, exponential_delays, full_jitter
T = TypeVar("T")
def retry_call(
func: Callable[[], T],
policy: RetryPolicy,
retryable: Callable[[Exception], bool],
on_retry: Optional[Callable[[int, float, Exception], None]] = None,
) -> T:
delays = exponential_delays(policy.base, policy.factor, policy.cap)
last_exc: Optional[Exception] = None
for attempt, raw_delay in zip(range(1, policy.max_attempts + 1), delays):
try:
return func()
except Exception as exc:
last_exc = exc
if not retryable(exc) or attempt == policy.max_attempts:
raise
sleep_for = full_jitter(raw_delay)
if on_retry is not None:
on_retry(attempt, sleep_for, exc)
time.sleep(sleep_for)
assert last_exc is not None
raise last_exc
import logging
import requests
from backoff import RetryPolicy
from retry import retry_call
log = logging.getLogger("github_client")
RETRYABLE_STATUS = {500, 502, 503, 504}
def _is_retryable(exc: Exception) -> bool:
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
return True
if isinstance(exc, requests.HTTPError) and exc.response is not None:
return exc.response.status_code in RETRYABLE_STATUS
return False
class GitHubClient:
def __init__(self, token: str, timeout: float = 5.0):
self._session = requests.Session()
self._session.headers["Authorization"] = f"Bearer {token}"
self._timeout = timeout
self._policy = RetryPolicy(max_attempts=5, base=0.25, cap=8.0)
def get_repo(self, owner: str, name: str) -> dict:
url = f"https://api.github.com/repos/{owner}/{name}"
def call() -> dict:
resp = self._session.get(url, timeout=self._timeout)
resp.raise_for_status()
return resp.json()
def log_retry(attempt: int, delay: float, exc: Exception) -> None:
log.warning("retry %d after %.2fs: %s", attempt, delay, exc)
return retry_call(call, self._policy, _is_retryable, on_retry=log_retry)
This snippet shows a small, self-contained retry layer for outbound HTTP calls in Python, built around the classic problem of transient failures: a downstream service occasionally returns 503, times out, or drops the connection, and blindly retrying makes things worse. The three tabs move from the pure backoff math, to a generic retry decorator, to a concrete API client that wires them together.
In backoff.py, the delay policy is isolated from any HTTP concern so it can be tested in isolation. exponential_delays yields an unbounded sequence of base * factor ** n values, each capped at cap seconds. The important detail is full_jitter, which turns a deterministic delay d into random.uniform(0, d). Without jitter, many clients that failed at the same moment retry in lockstep and hammer the recovering service in synchronized waves — the so-called thundering herd. Full jitter spreads those retries uniformly across the window, which empirically minimizes contention and completion time. The RetryPolicy dataclass bundles max_attempts, base, factor, and cap so callers configure intent rather than magic numbers.
In retry.py, retry_call implements the actual loop. It is deliberately generic: it takes a zero-argument func, a policy, and a retryable predicate that decides whether a given exception is worth retrying. Non-retryable errors (a 400, a programming bug) propagate immediately instead of wasting the attempt budget. The loop tracks attempt from 1, and on the final attempt it re-raises the last exception rather than sleeping pointlessly. The on_retry hook receives the attempt number, the sleep duration, and the exception, which is where structured logging or metrics belong. zip(range(...), exponential_delays(...)) pairs each remaining attempt with its computed delay, and full_jitter is applied just before time.sleep.
In github_client.py, the policy is applied to a real requests call. _is_retryable captures the practical rules: retry on connection errors and timeouts, and on 500, 502, 503, and 504, but never on 4xx, since retrying a client error will only fail again. A per-request timeout is mandatory — without it a single hung socket can block indefinitely and defeat the entire retry strategy. raise_for_status converts bad responses into exceptions so they flow through the same predicate.
The trade-offs are worth noting. Retries add latency and can amplify load, so max_attempts and cap should be tuned to the operation's importance and the caller's own timeout budget. Retrying non-idempotent writes risks duplicate side effects, so this pattern fits reads and idempotent operations best, or requires an idempotency key. Because the retry loop is synchronous and blocking, it suits worker or request-handler code where a short blocking wait is acceptable; heavily concurrent services would move the same logic onto an async client. Separating policy, mechanism, and client keeps each piece testable and lets the backoff behavior be reused for database calls or message publishing without change.
Share this code
Here's the card — post it anywhere.