Retry Flaky HTTP Requests with Exponential Backoff and Full Jitter in Python

Shared by codesnips Jul 2026
3 tabs
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)
3 files · python Explain with highlit

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.

Retry Flaky HTTP Requests with Exponential Backoff and Full Jitter in Python — share card
Link copied