A retry is a resilience policy, not a blanket exception handler. Repeat a call only when the failure is transient and the operation is safe to execute again. Without limits, timeouts, and telemetry, retries prolong incidents and can duplicate side effects.

Define the policy

python -m pip install tenacity
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_random_exponential,
)


class ServiceUnavailable(Exception):
    pass


@retry(
    retry=retry_if_exception_type(ServiceUnavailable),
    stop=stop_after_attempt(4),
    wait=wait_random_exponential(multiplier=0.5, max=8),
    reraise=True,
)
def fetch() -> dict[str, object]:
    return call_service(timeout=3)

This policy caps attempts, selects a transient exception, and adds exponential backoff with jitter. reraise=True exposes the final underlying exception instead of making callers unpack a generic retry error.

Do not automatically retry a POST that creates a payment, order, or account. The API needs an idempotency mechanism before repetition is safe. See the Python API security guide for perimeter controls.

Add timeouts and telemetry

The HTTP client or database driver owns the timeout for each call. Tenacity controls the series of calls. Log the attempt number, operation, and duration, but exclude credentials and personal data. The Python logging tutorial provides a useful foundation.

Test without sleeping

Separate the operation from its policy or replace waits during tests. Simulate two transient failures followed by success and assert three calls. Then raise a permanent error and verify that it is not retried. Avoid tests that depend on an unstable external service.

The official Tenacity documentation, accessed July 22, 2026, covers stop and wait strategies, exception predicates, callbacks, and async support. A sound policy answers four questions: what may be retried, how often, for how long, and how the final failure becomes visible.

Classify failures before adding retries

Transient failures include a short network interruption, connection reset, rate limit, or a service response that explicitly signals temporary unavailability. Permanent failures include invalid credentials, malformed input, forbidden access, and a resource that definitively does not exist. Retrying the second group wastes capacity and delays useful feedback.

Do not select Exception just because it is convenient. Define or reuse narrow exception classes at the adapter boundary. An HTTP adapter can translate selected timeout and 503 responses into ServiceUnavailable, while preserving an authentication error as a separate, nonretryable exception. This keeps transport details out of business code and makes policy review possible.

Results may also describe a transient failure. Tenacity supports result predicates, but an exception is usually clearer when failure prevents producing a valid domain value. Never treat an empty list as retryable unless the domain guarantees that emptiness means the upstream operation has not completed.

Combine attempt and time budgets

An attempt limit prevents an infinite loop, while a time limit protects latency. They can be combined:

from tenacity import stop_after_attempt, stop_after_delay

stop_policy = stop_after_attempt(5) | stop_after_delay(20)

The delay budget does not replace the timeout of an individual network call. If each request can hang for 60 seconds, a nominal 20-second retry policy cannot provide a 20-second user-facing deadline. Configure connect and read timeouts in the client, then leave enough total budget for useful retries.

Backoff gives a recovering dependency room. Fixed waits are predictable for controlled local resources; exponential waits fit shared remote services. Jitter prevents a fleet of clients from synchronizing after an outage. Cap the maximum wait so one attempt does not consume the entire caller deadline.

Respect server guidance such as Retry-After when the protocol and client expose it. A custom wait strategy can use that value with a safe cap and fall back to randomized exponential waiting. Validate untrusted header values rather than sleeping for an arbitrary duration.

Idempotency and side effects

Reads are often safe to repeat, though not universally. Writes require deliberate design. A timeout after sending a request does not prove the server rejected it; the response may have been lost after the order was created.

Use a stable idempotency key when the upstream API supports one. The key must remain the same across attempts for one logical operation and differ for a new operation. For databases, prefer a transaction and an invariant such as a unique constraint. Do not place email sending, charging, or event publication inside a retried block unless duplication is prevented.

Keep the decorated function as small as possible. If parsing and persistence happen after a remote call, retry only the remote portion. Otherwise a local parsing defect may accidentally cause the successful remote request to run again.

Observe attempts without leaking data

Tenacity callbacks can report attempts before sleeping:

import logging
from tenacity import before_sleep_log

logger = logging.getLogger(__name__)


@retry(
    retry=retry_if_exception_type(ServiceUnavailable),
    stop=stop_after_attempt(4),
    wait=wait_random_exponential(multiplier=0.5, max=8),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
def fetch() -> dict[str, object]:
    return call_service(timeout=3)

Logs should identify the operation, attempt, wait, and exception category. Do not include access tokens, full request bodies, or personal data. Metrics are useful for retry counts, exhausted operations, and eventual success. A high eventual-success rate can still reveal an unhealthy dependency and increased latency.

Avoid logging the same final exception at every layer. The retry component records attempt context; the application boundary records the final impact with a request or job identifier.

Test policies deterministically

Inject or override waiting so unit tests do not sleep:

def test_succeeds_on_third_attempt() -> None:
    calls = 0

    @retry(
        stop=stop_after_attempt(3),
        wait=lambda retry_state: 0,
        retry=retry_if_exception_type(ServiceUnavailable),
        reraise=True,
    )
    def operation() -> str:
        nonlocal calls
        calls += 1
        if calls < 3:
            raise ServiceUnavailable
        return "ok"

    assert operation() == "ok"
    assert calls == 3

Add a test that raises a permanent exception and assert one call. Add an exhaustion test and assert the final underlying error when reraise=True. Test idempotency separately: all attempts for one logical write should carry the same key.

Do not mock Tenacity itself unless testing an adapter around it. Control the failing dependency and the wait instead. This verifies the actual stop and retry predicates.

Async code and cancellation

Tenacity supports coroutines when the decorated function is async. Use an async HTTP client and awaitable operations throughout; wrapping blocking I/O in an async function still blocks the event loop. Cancellation must be allowed to propagate so shutdowns and caller deadlines work. Do not configure a broad predicate that accidentally retries cancellation signals.

Operational checklist

Before shipping, document the retryable exceptions, maximum attempts, approximate total delay, per-attempt timeout, and idempotency guarantee. Verify that the caller's deadline is larger than the worst useful policy. Confirm logs and metrics expose exhaustion. Finally, test the dependency's real error mapping in an integration environment.

Retries are most effective as a narrow defense around known transient behavior. They cannot repair invalid requests, replace capacity planning, or make unsafe side effects safe. Explicit classification and budgets keep resilience from becoming hidden load.

Review the policy in production

A retry policy is an operational hypothesis. After release, compare first-attempt success, eventual success, exhaustion, and end-to-end latency. If almost every retry fails, the predicate may include permanent errors. If retries frequently succeed but add unacceptable latency, the dependency or timeout policy needs attention.

Set alerts on exhausted logical operations rather than raw attempts alone. One outage can multiply attempt counts, so dashboards should distinguish original demand from retry traffic. During overload, circuit breaking, concurrency limits, or load shedding may protect the service better than additional attempts.

Revisit the policy when an upstream API changes its status codes, rate-limit guidance, or idempotency behavior. Keep the adapter's error mapping under integration tests. A retry that was safe for a read can become unsafe if the endpoint begins triggering work.

For batch processing, decide whether one failed item should stop the batch, move to a dead-letter path, or be reported for later repair. Do not wrap an entire large batch in one retry, since successful items would run again. Apply the policy at the smallest independently recoverable unit.