Retries & Backoff
Transient network and infrastructure failures often succeed on retry. Use bounded attempts, exponential backoff with jitter, and retry only idempotent operations - libraries like tenacity encode the pattern cleanly.
Recipe
import random
import time
def retry(callable_fn, attempts=3, base=0.5):
for attempt in range(1, attempts + 1):
try:
return callable_fn()
except TransientError:
if attempt == attempts:
raise
delay = base * (2 ** (attempt - 1)) + random.uniform(0, 0.1)
time.sleep(delay)When to reach for this:
- HTTP 429/503, connection resets, DNS blips
- Database deadlock or serialization failures (with care)
- Message queue publish timeouts
- Cloud API rate limits with Retry-After semantics
- NOT for validation errors or 4xx client mistakes
Working Example
Manual retry with jitter, then equivalent tenacity-style structure (stdlib-only demo).
import random
import time
from dataclasses import dataclass
class TransientError(Exception):
pass
@dataclass
class FakeClient:
calls: int = 0
fail_until: int = 2
def get(self) -> str:
self.calls += 1
if self.calls <= self.fail_until:
raise TransientError(f"attempt {self.calls}")
return "ok"
def with_retry(client: FakeClient, attempts: int = 5) -> str:
for attempt in range(1, attempts + 1):
try:
return client.get()
except TransientError as exc:
if attempt == attempts:
raise RuntimeError("exhausted retries") from exc
delay = min(2.0, 0.2 * (2 ** (attempt - 1))) + random.uniform(0, 0.05)
time.sleep(delay)
raise RuntimeError("unreachable")
client = FakeClient()
print(with_retry(client), "calls:", client.calls)What this demonstrates:
- Cap attempts and raise chained error when exhausted
- Exponential backoff capped to avoid multi-minute waits in sync code
- Jitter reduces thundering herd on recovery
- Count calls in tests to verify retry behavior
Deep Dive
How It Works
- Classify exceptions into retriable vs permanent
- Backoff:
delay = min(cap, base * 2**attempt) + jitter - Idempotency: retries safe only if side effects are absent or deduplicated
- tenacity provides decorators:
stop_after_attempt,wait_exponential,retry_if_exception_type - Log each retry with attempt number and sleep duration
Retry Policy Table
| Signal | Retry? |
|---|---|
| Timeout, 503 | Yes with backoff |
| 400, 422 validation | No |
| 409 conflict | Only with idempotent merge |
| Success | Stop |
Gotchas
- Retrying non-idempotent POST - duplicates charges/orders. Fix: idempotency keys or skip retry.
- Infinite retry loops - no
stopcondition burns CPU and masks outages. Fix: max attempts + circuit breaker. - No jitter - synchronized clients retry together. Fix: random uniform or full jitter.
- Retrying too fast - worsens rate limits. Fix: honor
Retry-Afterheader when present. - Swallowing final exception - return None after failures. Fix: raise with context on exhaustion.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Circuit breaker | Sustained outage | Single blip |
| Queue + dead letter | Async workers | Sync request path |
| Client library built-in retry | boto/urllib3 configured | Need custom logging/metrics |
FAQs
When should I use tenacity?
When decorators and composable stop/wait policies beat hand-rolled loops - most production services.
How many retries are enough?
3-5 for HTTP with exponential backoff is common; tune with SLOs and p99 latency budgets.
Should retries log warnings?
Yes - include attempt, sleep, exception type, and correlation id for incident correlation.
Are database retries safe?
Only for known transient errors (deadlock, connection lost) on idempotent statements.
How do async retries differ?
Use asyncio.sleep instead of time.sleep; tenacity supports async callables.
What about retry in middleware?
Centralize policy for outbound HTTP clients; do not double-retry at multiple layers.
Can I retry on return values?
tenacity retry_if_result works when "soft failures" return error codes instead of raises.
How do I test backoff?
Inject clock/sleep mock; assert attempt count without real delays (tenacity wait_fixed(0) in tests).
Does FastAPI retry automatically?
No - application or HTTP client (httpx transport) owns retry policy.
What is full jitter?
sleep = random.uniform(0, cap) - AWS recommended variant to spread load on recovery.
Related
- Raising & Chaining - final failure chain
- The logging Module - retry warnings
- Async HTTP with httpx/aiohttp - client timeouts
- Errors & Logging Best Practices - operational policy
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.