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.

A reusable Python policy

This implementation keeps backoff, retry classification, attempts, elapsed time, randomness, and sleeping explicit.

retry_policy.py
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.

async_retry.py
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.

relprim_backoff.py
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.

test_retry_policy.py
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?