TL;DR

Python tenacity is the default retry library for a reason: one @retry decorator covers sync and async code, and the stop, wait and retry conditions are separate objects you can combine. The defaults are dangerous, though. A bare @retry retries immediately and forever, so every real decorator needs an explicit stop= and wait=. For HTTP clients, retry only transient failures (timeouts, connection errors, 429, 5xx), use wait_random_exponential for jitter, and honor the server’s Retry-After header. One finding from running the code below: wait_exponential_jitter stops jittering once it reaches its max, so 1,000 simulated clients all retried at exactly 8.0 seconds.

FactValueSourceVerified
Latest tenacity release9.2.1 (Oct 7, 2026)PyPINov 1, 2026
Minimum Python3.10PyPI metadataNov 1, 2026
Version used for the runs in this post9.1.4 on Python 3.12.3local importlib.metadataNov 1, 2026
backoff (main alternative)2.2.1, last released Oct 2022; repo archivedGitHubNov 1, 2026
stamina (opinionated wrapper)26.1.0, depends on tenacityPyPINov 1, 2026

Why retries need more than a loop

Most outages your code sees are short: a pod restarting behind a load balancer, a 502 from a proxy that lost its upstream for half a second, a 429 because you and four other workers hit a quota at the same moment. Retrying fixes all of those. A for attempt in range(3) loop with time.sleep(1) fixes them badly, because every worker that failed together retries together, and the upstream that was struggling now gets the whole burst again one second later.

Marc Brooker’s Exponential Backoff And Jitter post on the AWS Architecture blog is the usual reference here. In his simulations, exponential backoff alone still produced clustered retries, and adding randomness (“jitter”) spread the load and cut the total work clients did. Google’s Cloud Storage retry strategy recommends the same shape: exponential delays, a cap, randomness, and only for errors that are actually transient.

tenacity packages all of that as composable pieces. It started as a fork of the unmaintained retrying library and is Apache 2.0 licensed. Install it with pip install tenacity, or uv add tenacity if you manage projects with uv (the uv tutorial covers that setup).

Everything below was run for this post on Python 3.12.3 with tenacity 9.1.4 and httpx 0.28.1. The HTTP examples use httpx.MockTransport, so they run offline and the failures are deterministic. Outputs are pasted exactly as printed.

Step 1: a basic retry with stop and wait

The tenacity docs say it plainly: the default behavior is to retry forever without waiting when an exception is raised. That’s almost never what you want, so always pass stop= and wait=.

from tenacity import retry, stop_after_attempt, wait_exponential

calls = 0

@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=0.1, max=2))
def flaky_fetch() -> str:
    global calls
    calls += 1
    if calls < 4:
        print(f"attempt {calls}: ConnectionError")
        raise ConnectionError("upstream reset the connection")
    print(f"attempt {calls}: ok")
    return "payload"

print(flaky_fetch())
stats = flaky_fetch.statistics
print("attempts:", stats["attempt_number"], "| slept:", round(stats["idle_for"], 2), "s")

Output:

attempt 1: ConnectionError
attempt 2: ConnectionError
attempt 3: ConnectionError
attempt 4: ok
payload
attempts: 4 | slept: 0.7 s

wait_exponential(multiplier=0.1) produced waits of 0.1, 0.2 and 0.4 seconds, which add up to the 0.7 seconds in idle_for. The max=2 caps a single wait, and stop_after_attempt(5) caps the attempt count. If all five attempts had failed, tenacity would have raised RetryError wrapping the last exception (more on that in Step 6).

stop_after_attempt is the right stop for most code, but for anything user-facing, add a time budget too. Stop conditions combine with |, so stop=stop_after_attempt(5) | stop_after_delay(10) gives up on whichever comes first. One caveat: stop_after_delay is checked between attempts, so a single call that hangs for 60 seconds still hangs for 60 seconds. Per-attempt timeouts belong to the HTTP client (httpx.Client(timeout=5.0)), and the retry policy decides what happens after one fires.

Step 2: retry only transient failures

Retrying a 404 Not Found or a 422 validation error wastes time and hides bugs. The useful filter for HTTP is: network errors and timeouts, 429 Too Many Requests, and the 5xx statuses that mean “try again later”. tenacity’s retry_if_exception takes a predicate, which is cleaner than listing exception classes when the decision depends on the status code.

import httpx
from tenacity import (
    retry,
    retry_if_exception,
    stop_after_attempt,
    wait_none,
)

RETRYABLE_STATUS = {429, 500, 502, 503, 504}


def is_transient(exc: BaseException) -> bool:
    if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
        return True
    if isinstance(exc, httpx.HTTPStatusError):
        return exc.response.status_code in RETRYABLE_STATUS
    return False


def make_client(statuses: list[int]) -> httpx.Client:
    """A client whose fake upstream answers with the given statuses in order."""
    queue = list(statuses)

    def handler(request: httpx.Request) -> httpx.Response:
        status = queue.pop(0)
        print(f"  upstream -> {status}")
        return httpx.Response(status, json={"ok": status == 200})

    return httpx.Client(transport=httpx.MockTransport(handler), base_url="https://api.example")


@retry(retry=retry_if_exception(is_transient), stop=stop_after_attempt(4), wait=wait_none(), reraise=True)
def get_order(client: httpx.Client, order_id: int) -> dict:
    resp = client.get(f"/orders/{order_id}")
    resp.raise_for_status()
    return resp.json()


print("503, 502, then 200:")
print("  result:", get_order(make_client([503, 502, 200]), 1))

print("404 (not transient):")
try:
    get_order(make_client([404, 200]), 2)
except httpx.HTTPStatusError as e:
    print("  gave up immediately:", e.response.status_code)

Output:

503, 502, then 200:
  upstream -> 503
  upstream -> 502
  upstream -> 200
  result: {'ok': True}
404 (not transient):
  upstream -> 404
  gave up immediately: 404

The 404 made exactly one request. Without the predicate, the default retry_if_exception_type() would have retried every exception, including the 404 and any KeyError from a typo in your own parsing code. wait_none() is there only to keep the demo fast; Step 3 picks a real wait.

If your exceptions map cleanly to classes, retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)) is the shorter form, and retry_if_not_exception_type inverts it. Conditions also combine: retry_if_exception_type(IOError) | retry_if_result(lambda r: r is None) retries on an exception or on a None return value.

Watch out for idempotency. Retrying a GET is safe. Retrying a POST /payments after a read timeout can charge a card twice, because the timeout tells you nothing about whether the server processed the first request. Retry non-idempotent calls only when the API supports an idempotency key, and send the same key on every attempt.

Step 3: picking a wait strategy (and the jitter cap gotcha)

tenacity ships several exponential strategies whose names look interchangeable. To see the difference, this script calls each strategy directly for attempts 1 through 6, then simulates 1,000 clients that all failed at the same instant and counts how many distinct 100 ms slots their sixth retry lands in:

import random
from tenacity import (
    RetryCallState,
    wait_exponential,
    wait_exponential_jitter,
    wait_random,
    wait_random_exponential,
)

random.seed(42)
CAP = 8.0
strategies = {
    "wait_exponential": wait_exponential(multiplier=0.5, max=CAP),
    "wait_random_exponential": wait_random_exponential(multiplier=0.5, max=CAP),
    "wait_exponential_jitter": wait_exponential_jitter(max=CAP, jitter=1),
    "exponential + random(0,1)": wait_exponential(multiplier=0.5, max=CAP) + wait_random(0, 1),
}


def waits(strategy, attempts: int = 6) -> list[float]:
    state = RetryCallState(retry_object=None, fn=None, args=(), kwargs={})
    out = []
    for n in range(1, attempts + 1):
        state.attempt_number = n
        out.append(round(strategy(state), 2))
    return out


for name, strategy in strategies.items():
    print(f"{name:<27}", waits(strategy))

# 1,000 clients that all failed at the same instant: how many distinct
# retry times does attempt 6 produce (rounded to 100 ms)?
print()
for name, strategy in strategies.items():
    slots = {round(waits(strategy)[-1], 1) for _ in range(1000)}
    print(f"{name:<27} attempt-6 distinct 100ms slots: {len(slots)}")

Output:

wait_exponential            [0.5, 1.0, 2.0, 4.0, 8.0, 8.0]
wait_random_exponential     [0.32, 0.03, 0.55, 0.89, 5.89, 5.41]
wait_exponential_jitter     [1.89, 2.09, 4.42, 8.0, 8.0, 8.0]
exponential + random(0,1)   [0.53, 1.2, 2.65, 4.54, 8.22, 8.59]

wait_exponential            attempt-6 distinct 100ms slots: 1
wait_random_exponential     attempt-6 distinct 100ms slots: 81
wait_exponential_jitter     attempt-6 distinct 100ms slots: 1
exponential + random(0,1)   attempt-6 distinct 100ms slots: 11

wait_exponential has no randomness at all, so 1,000 clients collapse into one slot. That was expected. wait_exponential_jitter, the strategy whose name promises jitter, ends up in the same place. Its formula, per the docstring, is min(multiplier * 2**n + random.uniform(0, jitter), max): the jitter is added first and the cap applied second. Once the exponential term reaches max (attempt 4 here), every client waits exactly max, and the herd is synchronized again for every remaining attempt. stamina’s defaults use the same min(wait_max, ...) formula, per its source, so the same thing happens there once the 5-second wait_max is hit.

StrategyFormula (simplified)Jitter at the cap?Use it for
wait_exponentialmultiplier * 2**n, cappedNone anywhereSingle client, no contention
wait_random_exponentialuniform(0, min(multiplier * 2**n, max))Full spread (81 slots)Many clients sharing an API
wait_exponential_jittermin(multiplier * 2**n + uniform(0, jitter), max)Lost (1 slot)Fine if max is never reached
wait_exponential(...) + wait_random(0, 1)capped exponential plus 0-1 sKept, but narrow (11 slots)When you want a guaranteed minimum delay

My pick for anything with more than one worker is wait_random_exponential. It’s the “full jitter” variant from the AWS post. Its downside is that an individual retry can be nearly instant (0.03 s for attempt 2 above), which is fine against a load-balanced API and less fine against a single overloaded database. If you need a floor, both wait_random_exponential and wait_exponential take a min= argument.

If you keep wait_exponential_jitter, note the 9.2 API change: the 9.2.0 release deprecates its initial parameter in favor of multiplier and adds min. Code passing initial= gets a DeprecationWarning on 9.2.1. The demo above passes only max and jitter, so it runs unchanged on both versions.

Step 4: honoring Retry-After on 429 and 503

When a server sends Retry-After, it’s telling you exactly when to come back. RFC 9110 allows two forms: a number of seconds (Retry-After: 120) or an HTTP date. Exponential backoff that ignores the header either retries too early and eats another 429, or waits much longer than needed.

tenacity 9.1.3 added a wait_exception strategy that computes the wait from the exception, but it needs a number for every exception, which makes falling back to backoff awkward. Any callable that takes a RetryCallState works as a wait strategy, so a small function handles both cases:

import logging
import time
from email.utils import parsedate_to_datetime

import httpx
from tenacity import (
    RetryCallState,
    before_sleep_log,
    retry,
    retry_if_exception,
    stop_after_attempt,
    wait_random_exponential,
)

logging.basicConfig(format="%(relativeCreated)6.0fms %(levelname)s %(message)s", level=logging.INFO)
log = logging.getLogger("orders")
logging.getLogger("httpx").setLevel(logging.WARNING)

MAX_SERVER_WAIT = 30.0
fallback = wait_random_exponential(multiplier=0.5, max=10)


def retry_after_seconds(resp: httpx.Response) -> float | None:
    value = resp.headers.get("Retry-After")
    if value is None:
        return None
    if value.isdigit():
        return float(value)
    try:  # HTTP-date form: "Wed, 21 Oct 2026 07:28:00 GMT"
        return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
    except (TypeError, ValueError):
        return None


def wait_server_hint(retry_state: RetryCallState) -> float:
    exc = retry_state.outcome.exception()
    if isinstance(exc, httpx.HTTPStatusError):
        hinted = retry_after_seconds(exc.response)
        if hinted is not None:
            return min(hinted, MAX_SERVER_WAIT)
    return fallback(retry_state)


def is_transient(exc: BaseException) -> bool:
    if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
        return True
    return isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in {429, 500, 502, 503, 504}


replies = [
    httpx.Response(429, headers={"Retry-After": "1"}),
    httpx.Response(503),
    httpx.Response(200, json={"id": 42, "status": "shipped"}),
]
client = httpx.Client(transport=httpx.MockTransport(lambda req: replies.pop(0)), base_url="https://api.example")


@retry(
    retry=retry_if_exception(is_transient),
    wait=wait_server_hint,
    stop=stop_after_attempt(5),
    before_sleep=before_sleep_log(log, logging.WARNING),
    reraise=True,
)
def get_order(order_id: int) -> dict:
    resp = client.get(f"/orders/{order_id}")
    resp.raise_for_status()
    return resp.json()


start = time.monotonic()
print(get_order(42), f"after {time.monotonic() - start:.2f}s")

Output:

   307ms WARNING Retrying __main__.get_order in 1 seconds as it raised HTTPStatusError: Client error '429 Too Many Requests' for url 'https://api.example/orders/42'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429.
  1309ms WARNING Retrying __main__.get_order in 0.212 seconds as it raised HTTPStatusError: Server error '503 Service Unavailable' for url 'https://api.example/orders/42'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503.
{'id': 42, 'status': 'shipped'} after 1.22s

The 429 carried Retry-After: 1, and the log shows a wait of exactly 1 second (the timestamps go from 307 ms to 1309 ms). The 503 had no header, so the function fell through to the jittered fallback and waited 0.212 s. Because the header is read only from the exception, a successful response that happens to carry Retry-After never triggers a pause. That’s relevant for servers like slowapi, which attaches Retry-After to every response once headers are enabled, as the FastAPI rate limiting tutorial found when testing the server side of this exchange.

The MAX_SERVER_WAIT cap is a judgment call. With the cap at 30 seconds, a server that says Retry-After: 60 gets retried after 30 and will probably answer 429 again. For a batch job, raising the cap is better. For a request a user is waiting on, the honest move is to give up: compare the hint against your remaining budget and raise instead of sleeping.

Step 5: async retries with the same decorator

@retry detects coroutine functions and switches to asyncio.sleep between attempts, so the event loop keeps running other tasks while one waits. When you need to retry only part of a function, AsyncRetrying works as an async for loop:

import asyncio
import time

import httpx
from tenacity import AsyncRetrying, retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential

failures_left = {"a": 2, "b": 0, "c": 1}
calls: dict[str, int] = {}


def handler(request: httpx.Request) -> httpx.Response:
    key = request.url.path.rsplit("/", 1)[-1]
    if failures_left[key] > 0:
        failures_left[key] -= 1
        raise httpx.ConnectTimeout("timed out", request=request)
    return httpx.Response(200, json={"key": key})


@retry(
    retry=retry_if_exception_type(httpx.TransportError),
    wait=wait_random_exponential(multiplier=0.2, max=2),
    stop=stop_after_attempt(4),
    reraise=True,
)
async def fetch(client: httpx.AsyncClient, key: str) -> dict:
    calls[key] = calls.get(key, 0) + 1
    resp = await client.get(f"/items/{key}")
    resp.raise_for_status()
    return resp.json()


async def fetch_inline(client: httpx.AsyncClient) -> int:
    # Same policy without a decorator: retry just one block inside a function.
    async for attempt in AsyncRetrying(stop=stop_after_attempt(3), reraise=True):
        with attempt:
            resp = await client.get("/items/b")
            resp.raise_for_status()
    return attempt.retry_state.attempt_number


async def main() -> None:
    transport = httpx.MockTransport(handler)
    async with httpx.AsyncClient(transport=transport, base_url="https://api.example") as client:
        start = time.monotonic()
        async with asyncio.TaskGroup() as tg:
            tasks = {k: tg.create_task(fetch(client, k)) for k in failures_left}
        for key, task in tasks.items():
            print(key, task.result(), "real attempts:", calls[key])
        print("fetch.statistics says:", fetch.statistics["attempt_number"])
        print(f"all three done in {time.monotonic() - start:.2f}s")
        print("inline block attempts:", await fetch_inline(client))


asyncio.run(main())

Output:

a {'key': 'a'} real attempts: 3
b {'key': 'b'} real attempts: 1
c {'key': 'c'} real attempts: 2
fetch.statistics says: 2
all three done in 0.49s
inline block attempts: 1

The three fetches ran concurrently inside a TaskGroup (the TaskGroup vs gather comparison explains why that’s the better default since Python 3.11), and their backoff sleeps overlapped instead of adding up.

The fetch.statistics line is wrong, though: three calls made 3, 1 and 2 attempts, and the statistics report 2. The wraps() source explains it: every call works on a fresh copy of the Retrying object, and the decorated function’s .statistics attribute is repointed to whichever copy was created last. With concurrent calls, the attribute belongs to the most recently started call, which may still be running. Treat .statistics as a debugging aid for sequential code. For metrics in concurrent code, record attempts from a before_sleep or after callback, where the RetryCallState belongs to one specific call.

Step 6: logging, reraise and RetryError

Two settings decide what your logs and callers see.

before_sleep_log(logger, level) logs each retry before the sleep, as in Step 4’s output. Log at WARNING: a retry that succeeded is still a signal that something upstream is degrading, and it’s the line you want when you’re trying to work out why p99 latency doubled. On Python 3.15, the built-in sampling profiler in its default wall-clock mode will also show threads parked in tenacity’s sleep, since wall mode counts waiting time. 9.2.0 adds a name= parameter to Retrying that labels these log lines when you use it as a loop or context manager (for attempt in Retrying(...)); decorated functions still log their qualified name.

reraise=True changes what happens when retries run out. By default tenacity raises tenacity.RetryError, which wraps the last attempt; callers then need err.last_attempt.exception() to get at the real error. With reraise=True, the original exception (httpx.HTTPStatusError, ConnectTimeout) propagates as if there had been no retry layer, and existing except clauses keep working. I’d set reraise=True on every HTTP helper. The default is useful when callers need to tell “failed after retries” apart from “failed on the first try”, which is mostly a library-author concern.

For a fallback value instead of an exception, retry_error_callback=lambda state: None returns whatever the callback returns once the stop condition fires.

Step 7: testing retry code without sleeping

Retry code with real backoff makes test suites slow: five attempts with wait_exponential(multiplier=1) sleep 1 + 2 + 4 + 8 = 15 seconds before giving up. tenacity exposes the Retrying object as fn.retry, and its sleep attribute is read on every call, so a pytest fixture can swap it out:

import httpx
import pytest
from tenacity import RetryError, retry, retry_if_exception_type, stop_after_attempt, wait_exponential


@retry(
    retry=retry_if_exception_type(httpx.TransportError),
    wait=wait_exponential(multiplier=1, max=30),
    stop=stop_after_attempt(5),
)
def ping(client: httpx.Client) -> int:
    return client.get("/health").status_code


def client_failing(times: int) -> httpx.Client:
    state = {"left": times}

    def handler(request: httpx.Request) -> httpx.Response:
        if state["left"]:
            state["left"] -= 1
            raise httpx.ConnectError("refused", request=request)
        return httpx.Response(200)

    return httpx.Client(transport=httpx.MockTransport(handler), base_url="https://svc.example")


@pytest.fixture(autouse=True)
def no_sleep(monkeypatch):
    # Without this, test_gives_up would really sleep 1+2+4+8 = 15 seconds.
    monkeypatch.setattr(ping.retry, "sleep", lambda seconds: None)


def test_recovers_after_two_failures():
    assert ping(client_failing(2)) == 200
    assert ping.statistics["attempt_number"] == 3


def test_gives_up_with_retry_error():
    with pytest.raises(RetryError) as info:
        ping(client_failing(10))
    assert info.value.last_attempt.attempt_number == 5
    assert isinstance(info.value.last_attempt.exception(), httpx.ConnectError)

Running python3 -m pytest -q test_orders.py:

..                                                                       [100%]
2 passed in 0.22s

Both tests exercise the full retry path, including the give-up case with five attempts, in 0.22 seconds. Two alternatives: ping.retry_with(wait=wait_none()) returns a copy of the function with a different policy, and tenacity 9.2.0 adds an enabled parameter that turns retrying off entirely, for tests that should see the first failure directly.

tenacity vs backoff vs stamina vs httpx’s built-in retries

tenacitybackoffstaminahttpx.HTTPTransport(retries=N)
Latest release9.2.1 (Oct 2026)2.2.1 (Oct 2022)26.1.0 (Apr 2026)ships with httpx
MaintainedYesRepo archivedYesYes
AsyncYesYesYesYes
Default jitterNone (you choose)Full jitter by defaultAdditive, cappedNone (exponential delays)
Retries onAny exception or return valueExceptions or predicates on resultsExceptions you listConnection failures only
Test helpersSwap .retry.sleep, retry_with, enabled= (9.2)None built inBuilt-in testing moden/a

backoff still works, and its on_exception decorator defaults to full jitter, which is a better default than tenacity’s. But its GitHub repository was archived in August 2025 and its last PyPI release was October 2022, so new projects shouldn’t start on it.

stamina by Hynek Schlawack describes itself as an “opinionated wrapper” around tenacity. You must name the exceptions to retry (@stamina.retry(on=httpx.HTTPError, attempts=3)), it caps total time at 45 seconds by default, and it has Prometheus, structlog and logging instrumentation built in. If you want good defaults and don’t need custom wait functions like the Retry-After one above, stamina is a good choice.

httpx’s own retries= option covers only connection setup. The httpx transports docs describe it as retrying connection establishment, and the httpcore source (HTTPConnection._connect) confirms the retry loop wraps only the TCP connect. A 503 or a read timeout won’t be retried. It’s a reasonable first layer under tenacity, but it can’t replace it.

Retry layers multiply

A frequent retry bug is two retry layers stacked without anyone noticing. The OpenAI Python SDK README, for example, documents that connection errors, 408, 409, 429 and 5xx responses are retried 2 times by default. Wrap that client in @retry(stop=stop_after_attempt(5)) and one failing call becomes 5 × 3 = 15 requests. Put a third layer in a job queue that re-runs failed jobs three times and you’re at 45, all aimed at a service that was already failing.

Pick one layer to own retries and set the others to zero (OpenAI(max_retries=0) in that example). The outermost layer is usually the right owner, because it knows the caller’s time budget.

FAQ

Is tenacity better than backoff?

For new code, yes, mostly because backoff’s repository is archived and hasn’t had a release since October 2022. tenacity also handles result-based retries, has more stop and wait combinators, and exposes test hooks. backoff’s one advantage is that jitter is on by default; in tenacity you have to pick wait_random_exponential yourself.

Does tenacity work with async functions?

Yes. Decorating an async def with @retry makes tenacity await the coroutine and sleep with asyncio.sleep between attempts. For retrying only part of a coroutine, use async for attempt in AsyncRetrying(...) with with attempt: around the block, as shown in Step 5.

What does reraise=True do in tenacity?

When the stop condition is reached, tenacity normally raises RetryError wrapping the last attempt. With reraise=True, it raises the last attempt’s original exception instead, so callers catch httpx.HTTPStatusError or ConnectionError directly.

How do I use retry_if_exception_type?

Pass an exception class or a tuple of them: @retry(retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)), stop=stop_after_attempt(3)). Only those exceptions (and their subclasses) trigger a retry; anything else propagates on the first failure. When the decision depends on attributes like an HTTP status code, use retry_if_exception with a predicate function, as in Step 2.

How do I retry an HTTP 429 with tenacity?

Treat 429 as transient in your retry predicate, then use a custom wait function that reads the Retry-After header from the exception’s response and falls back to wait_random_exponential when the header is missing. The full working version is in Step 4.

Sources

Bottom line

tenacity gives you the parts and leaves the policy to you, and the policy is where retry code goes wrong. My default for an HTTP call is retry_if_exception with a transient-only predicate, wait_random_exponential with a cap, stop_after_attempt combined with stop_after_delay, reraise=True, and a before_sleep_log at WARNING. Add the Retry-After wait from Step 4 for any API that rate-limits you. And before shipping, check whether the client underneath already retries, because two well-configured layers stacked together turn one failing call into 15.