Backoff needs more than an exponent
Python exponential backoff increases the delay between retry attempts so a failing dependency has time to recover.
A common capped formula is:
delay = min(cap, base * multiplier ** (retry_number - 1))
With a base of 0.25, multiplier 2, and cap 5, the uncapped sequence starts
at:
0.25s, 0.5s, 1s, 2s, 4s, 5s, 5s
That is better than retrying every 250 milliseconds forever, but it is not a complete production policy. If many clients fail at the same time, deterministic exponential delays make them retry together at 0.25, 0.5, 1, and 2 seconds.
A safe retry with backoff also needs jitter, a cap, bounded attempts, a maximum
elapsed time, response-aware hints such as Retry-After, and one budget shared
across every layer that can repeat the operation.
The examples were executed with Python 3.13.5 on August 12, 2026. The RelPrim example was verified against RelPrim 0.9.0 source and documentation on the same date.
Why fixed and deterministic delays fail under load
A fixed delay keeps sending traffic at the same rate. If an outage affects ten thousand callers, ten thousand retries arrive after the same interval.
Plain exponential backoff lowers the rate, but still aligns callers that failed together:
failure retry 1 retry 2 retry 3
| | | |
+---- 0.5s ----+---- 1s -----+---- 2s -----+
Jitter spreads each retry across a range. The total number of attempts may stay the same, but the dependency sees a smoother arrival pattern instead of sharp waves.
This matters in HTTP clients, queue consumers, payment integrations, and AI provider workflows. A retry policy that behaves well for one request can still amplify an incident when multiplied by an entire fleet.
For an HTTP-specific example, see Building Safe HTTPX Retry Policies in Python with Backoff, Timeouts and Async Failure Handling.
Choosing a jitter strategy
Start with the capped exponential value d, then randomize it.
| Strategy | Delay | Best fit | Trade-off |
|---|---|---|---|
| No jitter | d |
Controlled single-caller jobs | Clients synchronize under load |
| Full jitter | random between 0 and d |
General distributed systems default | Some retries happen almost immediately |
| Equal jitter | d / 2 plus random between 0 and d / 2 |
When a minimum wait matters | Higher average delay |
| Decorrelated jitter | random between base and previous * 3, capped |
Long-running retry loops | Depends on previous delay and is less predictable |
Full jitter is usually the simplest safe default. It gives the widest spread and keeps the average delay below the deterministic ceiling.
Equal jitter avoids near-zero delays. It is useful when the downstream needs a meaningful recovery window after every failure.
Decorrelated jitter does not follow one fixed exponential sequence. Each delay depends on the previous one, which can reduce repeated alignment in long-lived clients.
The retry budget is the real boundary
max_attempts answers how many times the code may call the dependency.
max_elapsed_time answers how much user or worker time the complete retry flow
may consume.
You usually need both.
Four attempts with a five-second request timeout and backoff can already exceed twenty seconds. If an SDK retries three times inside an application loop that also tries three times, the provider can receive up to nine attempts.
The budget should belong to the logical operation and be passed down through layers. A lower layer may spend part of it, but it should not create a fresh deadline.
The same distinction appears in OpenAI Timeouts in Python for Retries, Streaming and Long-Running Responses, where SDK retries, model latency, validation, and fallback all compete for one operation budget.
Engineering Notes
Finding this useful?
Get the next one, along with occasional project updates and engineering notes.
A reusable Python policy
This implementation keeps backoff, retry classification, attempts, elapsed time, randomness, and sleeping explicit.
from __future__ import annotations
import random
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TypeVar
T = TypeVar("T")
Uniform = Callable[[float, float], float]
Sleeper = Callable[[float], None]
Clock = Callable[[], float]
class RetryBudgetExceeded(TimeoutError):
pass
@dataclass(frozen=True)
class ExponentialBackoff:
base: float = 0.25
multiplier: float = 2.0
cap: float = 5.0
def full_jitter(
self,
retry_number: int,
*,
uniform: Uniform = random.uniform,
) -> float:
ceiling = min(
self.cap,
self.base * self.multiplier ** (retry_number - 1),
)
return uniform(0.0, ceiling)
@dataclass(frozen=True)
class RetryBudget:
max_attempts: int = 4
max_elapsed_seconds: float = 12.0
def retry(
operation: Callable[[], T],
*,
retry_on: tuple[type[BaseException], ...],
backoff: ExponentialBackoff = ExponentialBackoff(),
budget: RetryBudget = RetryBudget(),
uniform: Uniform = random.uniform,
sleeper: Sleeper = time.sleep,
clock: Clock = time.monotonic,
) -> T:
started = clock()
last_error: BaseException | None = None
for attempt in range(1, budget.max_attempts + 1):
try:
return operation()
except retry_on as exc:
last_error = exc
if attempt == budget.max_attempts:
raise
delay = backoff.full_jitter(
attempt,
uniform=uniform,
)
remaining = (
budget.max_elapsed_seconds - (clock() - started)
)
if delay >= remaining:
raise RetryBudgetExceeded(
"Retry budget exhausted before the next attempt"
) from exc
sleeper(delay)
raise AssertionError("unreachable") from last_error
max_attempts=4 means one initial attempt and at most three retries. The caller
must supply a narrow transient exception set instead of retrying every
Exception.
The injected uniform, sleeper, and clock make the policy testable without
waiting in real time.
Async retry must preserve cancellation
The async version should use asyncio.sleep(), which suspends the current task
instead of blocking the event loop.
import asyncio
import random
from collections.abc import Awaitable, Callable
from typing import TypeVar
T = TypeVar("T")
async def retry_async(
operation: Callable[[], Awaitable[T]],
*,
retry_on: tuple[type[BaseException], ...],
backoff: ExponentialBackoff,
budget: RetryBudget,
) -> T:
loop = asyncio.get_running_loop()
started = loop.time()
last_error: BaseException | None = None
for attempt in range(1, budget.max_attempts + 1):
try:
return await operation()
except asyncio.CancelledError:
raise
except retry_on as exc:
last_error = exc
if attempt == budget.max_attempts:
raise
delay = backoff.full_jitter(
attempt,
uniform=random.uniform,
)
remaining = (
budget.max_elapsed_seconds - (loop.time() - started)
)
if delay >= remaining:
raise RetryBudgetExceeded(
"Retry budget exhausted before the next attempt"
) from exc
await asyncio.sleep(delay)
raise AssertionError("unreachable") from last_error
Cancellation is not a transient provider failure. Swallowing it can keep work alive after the request, worker, or structured-concurrency scope has ended.
Respect the provider’s clock
A server returning Retry-After knows more about its recovery window than a
generic client formula.
For 429 and 503 responses, prefer the provider hint over local backoff, but still compare it with the remaining operation budget. If the server asks for a 60-second wait and the caller has 8 seconds left, another inline attempt is not viable.
The safer response may be to fail, enqueue the operation for later, or shed load. Backoff alone does not solve sustained rate limits. Concurrency limits, backpressure, quotas, and admission control may matter more.
The HTTP handling, including Retry-After, is implemented in
Building Safe HTTPX Retry Policies in Python.
Expressing the policy with RelPrim
RelPrim keeps the retry policy independent of the HTTP client, SDK, queue, or provider being called.
from relprim import (
ExponentialBackoff,
RetryPolicy,
async_operation,
)
class TemporaryProviderError(RuntimeError):
pass
retry_policy = RetryPolicy(
max_attempts=4,
retry_on=(TemporaryProviderError,),
backoff=ExponentialBackoff(
base_delay_seconds=0.25,
multiplier=2.0,
max_delay_seconds=5.0,
jitter=True,
),
)
result = await (
async_operation("generate_product_summary", call_provider)
.with_retry(retry_policy)
.run(product)
)
print(result.value)
print(result.report.to_dict())
In RelPrim 0.9.0, jitter=True uses full jitter between zero and the capped
exponential delay. max_attempts counts all executions, including the first.
The current policy bounds attempts, but it does not yet interpret
provider-specific Retry-After values or expose a shared elapsed-time retry
budget. Keep those concerns in the integration or caller when they are required.
See the RelPrim retry implementation for the current policy and its explicit limitations.
Make randomness deterministic in tests
A retry test should assert decisions, not sleep for several seconds.
def test_retry_uses_injected_jitter_and_sleep() -> None:
attempts = 0
delays: list[float] = []
def unstable() -> str:
nonlocal attempts
attempts += 1
if attempts < 3:
raise ConnectionError("temporary")
return "ok"
result = retry(
unstable,
retry_on=(ConnectionError,),
uniform=lambda low, high: (low + high) / 2,
sleeper=delays.append,
)
assert result == "ok"
assert attempts == 3
assert delays == [0.125, 0.25]
Also test budget exhaustion, a permanent exception, cancellation during async sleep, and a provider hint longer than the remaining deadline.
When backoff is the wrong response
Do not retry invalid requests, authentication failures, permanent schema mismatches, or operations that cannot be repeated safely.
Stop when the downstream is already overloaded and another attempt would only consume recovery capacity. A circuit breaker, queue, fallback, or direct failure may be more appropriate.
Before you ship
Before enabling retry with backoff, identify the logical operation and every layer capable of repeating it. Set one total attempt count and one elapsed-time budget across the client, SDK, worker, and application.
Choose the retryable failures narrowly. Confirm that the operation can be replayed, add idempotency where the outcome may be uncertain, cap the exponential delay, and use jitter when multiple callers can fail together. Honor provider guidance only within the remaining deadline.
Record the attempt number, selected delay, exception or status, elapsed time, remaining budget, and final outcome. Watch the ratio of retry traffic to initial traffic because a policy that recovers individual calls can still amplify a fleet-wide incident.
Exponential backoff controls when another attempt happens. The retry budget decides whether it should happen at all.
Which jitter strategy do you use in production, and is its retry budget shared across every layer that can repeat the operation?
