mrkeyoor.com_
Sun 20 Sept 02:40 UTC
PyPIUtilsupdated 18 Sept 2026

tenacity review

Tenacity 9.1.4 runs synchronous functions, coroutines, or explicit code blocks again under separate retry, stop, wait, sleep, logging, and exhaustion policies. Predicates can inspect exceptions or returned values; stop conditions combine with `|`, and waits combine with `+`. `Retrying` and `AsyncRetrying` cover flows that do not fit a decorator. The package has no knowledge of HTTP idempotency, queue durability, request deadlines, or `Retry-After`, so the caller owns those decisions. Version 9.1.4 fixes type annotations when `retry()` receives an asynchronous `sleep=` function. GitHub has a newer 9.2.0 tag, but PyPI and our measured installation still resolve to 9.1.4; 9.2-only options do not appear in the patterns below.

Verdict

Tenacity 9.1.4 installed in 0.2 seconds as one 1 MB package, imported in 0.18 seconds, and had 0 audit findings in our sandbox. Use it after defining idempotency, per-attempt timeouts, retryable failures, and a hard stop; it cannot supply those safety decisions for you.

We installed it

Lab card: what happened when we installed tenacityScreenshot of tenacity documentation
Install✓ · 0.2s1 package on disk · 1 MB
Importimport tenacity in 0.18s · pure Python · py.typed · requires Python >=3.10
Known vulns0(pip-audit)

Answers from our run

Does tenacity install cleanly?

Yes. In a fresh container with an empty cache, pip install tenacity finished in 0.2s, leaving 1 package and 1 MB on disk. pip-audit reported no known vulnerabilities.

What does tenacity need to run?

Python >=3.10, and nothing compiled: it is pure Python. In our run import tenacity succeeded in 0.18s, and the package ships py.typed for type checkers.

tenacity or backoff: which should you use?

backoff: Use it for a smaller decorator-centered API built around backoff generators. Tenacity 9.1.4 installed in 0.2 seconds as one 1 MB package, imported in 0.18 seconds, and had 0 audit findings in our sandbox.

When should you not use tenacity?

Repeating the operation can duplicate a payment, message, email, or state change and no idempotency key protects it.

API stability4/5The core model of `retry`, `Retrying`, `AsyncRetrying`, retry predicates, stop strategies, wait strategies, callbacks, and `RetryError` remains consistent through 9.1.4. That release changes annotations for an async `sleep=` callback without altering runtime policy. The repository's 9.2.0 tag adds options and context behavior not present on PyPI, so consumers need to separate the installed stable surface from newer tag documentation.
Docs4/5The 9.1.4 source documentation covers bounded attempts, elapsed-time stops, fixed and exponential waits, jitter, exception and result predicates, callbacks, statistics, runtime policy changes, retry blocks, and async functions with executable examples. It also states that the default repeats forever without waiting. Operational topics such as idempotency, client timeouts, `Retry-After`, and durable retry ownership receive much less attention than their failure impact deserves.
Maintenance4/5PyPI published 9.1.4 on 2026-02-07, GitHub records a push on 2026-08-06, and the unarchived repository shows 46 open issues and pull requests. The current PyPI release fixes async-sleep annotations. A 9.2.0 GitHub release appeared on 2026-08-05 with substantial typing, callback, iterator, and policy work, yet it is not the version returned by PyPI, which creates avoidable release-channel ambiguity.
Ecosystem5/5PyPI Stats counted 100,759,815 downloads in the latest week, and GitHub shows 8,764 stars. Tenacity appears in SDKs, data pipelines, service integrations, and asynchronous applications because policies compose without tying them to one protocol. Its generality is also the limit: HTTP clients, queues, and workflow systems can apply method safety, server headers, durable state, and cancellation rules that a generic function wrapper cannot know.

Discussed on

  1. hnTenacity – a multi-track audio editor/recorder160 points
  2. hnThe Tenacity of Tech Recruiters132 points
  3. hnAudacium has officially merged with Tenacity126 points
  4. hnThe Tenacity of Tech Recruiters94 points
  5. hnTenacity83 points

Use it if

  • A transient remote read or idempotent write needs an explicit attempt cap, backoff, and jitter.
  • Retry eligibility and timing should be policy objects that can be tested apart from application code.
  • Both synchronous and asynchronous call sites need the same vocabulary for exceptions, results, waits, and callbacks.
  • Metrics or logs must inspect attempt number, elapsed time, the latest outcome, and accumulated sleep.
Skip it if

Setup reality

We installed Tenacity 9.1.4 in a fresh Python 3.12 Bookworm sandbox in 0.2 seconds. It left 1 package and 1 MB on disk. pip-audit found 0 known vulnerabilities. The pure-Python distribution reports 5 direct dependencies, requires Python 3.10 or newer, uses Apache 2.0, and ships py.typed. import tenacity succeeded in 0.18 seconds. PyPI still serves 9.1.4 even though the repository has a 9.2.0 release tag, so verify examples against the installed package.

A bare @retry has no stop limit and no delay. Set both for network work. stop_after_attempt(5) counts the initial call as attempt 1, while stop_after_delay(30) checks elapsed retry time and cannot interrupt a function already blocked inside an attempt. Give the underlying client its own connect and read timeouts. End-to-end latency equals call durations plus waits, and may exceed a simple delay threshold during the last call.

For an HTTP 429 response, a custom wait callable can read Retry-After; Tenacity will not infer it. Narrow the retry predicate to known transient failures or explicit results. Jitter prevents many workers from waking together. Idempotency remains outside the library. A bounded decorator can still repeat unsafe work several times, and an async wrapper only keeps the event loop free when the wrapped code and injected sleep are asynchronous too.

With reraise=True, exhaustion raises the last original exception; otherwise callers receive RetryError containing the final attempt. Pick one public contract. Callbacks execute application code before attempts, after failed attempts, or before sleep, and callback exceptions can replace the failure being retried. For tests, use .retry_with(wait=wait_none()) or inject a no-op sleep instead of enduring production delays. Statistics live on the decorated function and describe the most recent invocation, not a concurrency-safe metrics store.

Patterns

Cap attempts with exponential waits bound-exponential-retry

from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

@retry(
    retry=retry_if_exception_type((ConnectionError, TimeoutError)),
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=20),
    reraise=True,
)
def fetch_record():
    return client.get_record(timeout=5)

Attempt 1 is the initial call, so this permits at most 5 calls. The client timeout bounds each call; Tenacity's stop rule does not interrupt one.

Exclude permanent exceptions retry-transient-exceptions

from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed

TRANSIENT = (ConnectionError, TimeoutError)

@retry(
    retry=retry_if_exception_type(TRANSIENT),
    stop=stop_after_attempt(4),
    wait=wait_fixed(1),
    reraise=True,
)
def refresh_cache():
    return upstream.read()

Only the listed exception types repeat. Authentication, validation, and programming errors escape on the first attempt.

Stop by attempt count or elapsed time combine-stop-limits

from tenacity import retry, stop_after_attempt, stop_after_delay, wait_fixed

@retry(
    stop=stop_after_attempt(8) | stop_after_delay(30),
    wait=wait_fixed(2),
    reraise=True,
)
def poll_status():
    return service.status(timeout=3)

The retry loop stops when either condition is reached. A call already in progress can carry wall time beyond 30 seconds.

Spread workers across a retry window add-exponential-jitter

from tenacity import retry, stop_after_delay, wait_random_exponential

@retry(
    stop=stop_after_delay(90),
    wait=wait_random_exponential(multiplier=1, max=30),
    reraise=True,
)
def read_shared_service():
    return client.read(timeout=5)

Random exponential waits reduce synchronized retries across workers. They do not make a write safe to repeat or honor server delay headers automatically.

Repeat a valid but incomplete result retry-on-result

from tenacity import retry, retry_if_result, stop_after_attempt, wait_fixed

@retry(
    retry=retry_if_result(lambda status: status == 'pending'),
    stop=stop_after_attempt(20),
    wait=wait_fixed(2),
    reraise=True,
)
def job_status():
    return client.get_job(job_id, timeout=3)

The result predicate retries only `pending`. Decide what exhaustion should return, because `reraise=True` cannot raise an original exception when every attempt returned a value.

Expose the original exception on exhaustion reraise-final-error

from tenacity import retry, stop_after_attempt

@retry(stop=stop_after_attempt(3), reraise=True)
def load_manifest():
    raise OSError('storage unavailable')

try:
    load_manifest()
except OSError as error:
    report_storage_failure(error)

`reraise=True` raises the last `OSError`. Without it, exhaustion raises `RetryError`, whose final attempt contains the original outcome.

Log only failures that will retry log-before-wait

import logging
from tenacity import before_sleep_log, retry, stop_after_attempt, wait_fixed

logger = logging.getLogger(__name__)

@retry(
    stop=stop_after_attempt(5),
    wait=wait_fixed(2),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
def publish_event():
    broker.publish(event)

`before_sleep` runs after a retryable failure and before its delay. Scrub secrets from exception messages before sending them to shared logs.

Wait without blocking the event loop retry-async-call

from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

@retry(
    retry=retry_if_exception_type((TimeoutError, ConnectionError)),
    stop=stop_after_attempt(4),
    wait=wait_exponential(min=1, max=8),
    reraise=True,
)
async def fetch_json(session, url):
    async with session.get(url, timeout=5) as response:
        response.raise_for_status()
        return await response.json()

Tenacity uses asynchronous sleeping for a coroutine. Blocking I/O inside the function would still block the event loop during every attempt.

Retry a block that shares surrounding state retry-code-block

from tenacity import Retrying, retry_if_exception_type, stop_after_attempt, wait_fixed

for attempt in Retrying(
    retry=retry_if_exception_type(ConnectionError),
    stop=stop_after_attempt(3),
    wait=wait_fixed(1),
    reraise=True,
):
    with attempt:
        connection = pool.acquire()
        result = connection.read(key)

The context manager retries exceptions raised inside its block. Resource cleanup still belongs to the block or the objects it acquires.

Remove waits for one invocation change-policy-in-test

from tenacity import stop_after_attempt, wait_none

result = fetch_record.retry_with(
    stop=stop_after_attempt(2),
    wait=wait_none(),
)()

`.retry_with()` creates a call using replacement policies without editing the decorator globally. This keeps production backoff out of a focused test.

Return a final retryable value after exhaustion return-last-result

from tenacity import retry, retry_if_result, stop_after_attempt

def return_last_value(state):
    return state.outcome.result()

@retry(
    retry=retry_if_result(lambda value: value is None),
    stop=stop_after_attempt(3),
    retry_error_callback=return_last_value,
)
def claim_work():
    return queue.claim()

item = claim_work()  # None after 3 empty results

`retry_error_callback` replaces the usual exhaustion exception. Callers must be told that `None` can now mean every attempt was empty.

Read a server delay from the last response respect-retry-after

from tenacity import retry, retry_if_result, stop_after_attempt

def wait_from_response(state):
    response = state.outcome.result()
    value = response.headers.get('Retry-After', '1')
    return min(max(float(value), 0), 60)

@retry(
    retry=retry_if_result(lambda response: response.status_code == 429),
    wait=wait_from_response,
    stop=stop_after_attempt(5),
)
def get_report():
    return client.get('/report', timeout=5)

This handles numeric `Retry-After` values only and caps them at 60 seconds. HTTP-date syntax needs separate parsing, and the request method must be safe to repeat.

Alternatives

PackageRegistryPick it when
backoffPyPIUse it for a smaller decorator-centered API built around backoff generators.
staminaPyPIUse it for a more opinionated retry interface with structured logging choices.
urllib3PyPIUse its `Retry` policy when retries belong inside urllib3 or a Requests HTTP adapter.

More utils guides

lru-cache · type-fest · ajv · p-limit · find-up · js-yaml · the whole shelf →

How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.