Retry Handler
Implement a configurable retry handler with exponential backoff, jitter, and a max-delay cap to prevent thundering herds and unbounded blocking.
Last updated: September 2026
Implement a configurable retry handler with exponential backoff and jitter. Fixed delays hammer a struggling server; exponential backoff gives it progressively more breathing room. Jitter randomizes the delay so all retrying clients do not wake up simultaneously (thundering herd). max_delay caps the wait to prevent unbounded blocking.
“Exponential backoff without jitter creates synchronized thundering herds. Jitter is not optional - it is the point of not making a struggling server worse. Never retry non-retryable errors. GET is always safe to retry; POST requires an idempotency key.”
Clarifying Questions (Ask These First)
| Question | Why it matters |
|---|---|
| After all retries exhausted: raise, return default, or call fallback? | Determines the contract callers depend on - raising is the safest default |
| Is jitter range flexible, or is 10% of delay a fixed requirement? | 10% is a conservative start; full jitter (0 to delay) is more aggressive and often better |
| Which exceptions should trigger a retry? | Retryable: 503, 429, timeout. Non-retryable: 400, 401, 404 - retrying will never help |
| Is the operation idempotent? | GET and DELETE can be retried freely. POST and PATCH need an idempotency key or risk duplicates |
Implementation
import time
import random
from typing import Callable, Any
class RetryHandler:
def __init__(self, max_retries: int = 5, base_delay: float = 1.0, max_delay: float = 30.0):
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
def execute(self, func: Callable, *args, **kwargs) -> Any:
last_exception = None
for attempt in range(self.max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt == self.max_retries - 1:
break
delay = min(self.base_delay * (2 ** attempt), self.max_delay)
jitter = random.uniform(0, delay * 0.1)
time.sleep(delay + jitter)
raise RuntimeError(f"Failed after {self.max_retries} attempts") from last_exception
# Usage
call_count = {"n": 0}
def flaky_function():
call_count["n"] += 1
print(f" Attempt #{call_count['n']}")
if call_count["n"] < 3:
raise Exception("Simulated failure")
return "Success!"
handler = RetryHandler(max_retries=3, base_delay=0.5, max_delay=5.0)
result = handler.execute(flaky_function)
print(f"Result: {result}")Key Design Decisions
- 1Exponential Backoff - Progressive Breathing Room - delay = min(base_delay * 2^attempt, max_delay). Attempt 0: 0.5s. Attempt 1: 1s. Attempt 2: 2s. Attempt 3: 4s. Fixed delay keeps hammering a struggling server at full rate. Exponential gives it progressively more time to recover.
- 2Jitter - Preventing Thundering Herd - jitter = random.uniform(0, delay * 0.1). Without jitter, 1000 clients that all started retrying at the same moment sleep for exactly the same duration and wake up simultaneously - making the overloaded server 1000x worse at that instant. Jitter spreads the wake-ups.
- 3max_delay Cap - Bounding the Wait - min(..., max_delay) prevents 2^attempt from growing unboundedly. Without it, attempt 30 would sleep for 2^30 * base_delay - over a billion seconds. In languages without Python's arbitrary-precision integers this is also an integer overflow bug. max_delay sets a practical ceiling.
- 4Exception Chain - Preserving Root Cause - raise RuntimeError(...) from last_exception preserves the original exception as __cause__. Without the from clause, the original error is lost and debugging becomes much harder. Always chain exceptions in retry wrappers.
Frequently asked questions
Why exponential backoff instead of a fixed delay?
Fixed delay keeps hammering a struggling server at a constant rate - if it is overloaded at 1 second, it is still overloaded at 2, 3, 4 seconds. Exponential backoff gives the server progressively more time to recover. At attempt 5 with base 1s, the server gets 16 seconds of breathing room.
Why add jitter at all?
Without jitter, all clients that started retrying at the same time sleep for exactly the same duration and wake up simultaneously - thundering herd. If 500 services all failed at once and retry at exactly T+1s, the server gets hammered by 500 simultaneous requests. Jitter spreads the load across a time window.
What is the tradeoff of a very high max_delay?
Better for the server - more recovery time. Worse for the caller - they block longer. For user-facing APIs, a 30-second max delay is unacceptable. For background jobs or async queues, 5 minutes is fine. max_delay should match the caller's latency tolerance, not server preference.
How would you decide which exceptions to retry?
Split into two buckets. Retryable: server overloaded (503), rate limited (429), network timeout - the problem is temporary and will likely self-resolve. Non-retryable: bad request (400), unauthorized (401), not found (404) - retrying will never help. Define RetryableError and NonRetryableError custom exceptions, map HTTP status codes to them at the network layer, and raise immediately for non-retryable errors before entering the retry loop at all.
What if you only have an HTTP status code, not a typed exception?
Build a simple helper: def is_retryable(status: int) -> bool: return status == 429 or (500 <= status < 600). 5xx codes mean the server failed and retrying makes sense. 429 is rate-limited - also retryable, and check for a Retry-After header to use as sleep duration instead of calculating backoff yourself. 4xx codes except 429 are caller errors - raise immediately.
Would you retry a POST the same way as a GET?
No. GET is read-only - retrying 100 times is safe. POST is dangerous: retrying a payment POST could charge the customer twice, retrying an order POST creates duplicate orders. The right pattern: use an idempotency key - a unique UUID generated client-side, sent as a request header. The server deduplicates using this key and returns the original response on replay. Only retry POST if the API supports idempotency keys, and send the same key on every retry attempt.