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.
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
| Install | ✓ · 0.2s | 1 package on disk · 1 MB |
| Import | ✓ | import tenacity in 0.18s · pure Python · py.typed · requires Python >=3.10 |
| Known vulns | 0 | (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.
Discussed on
- hnTenacity – a multi-track audio editor/recorder160 points
- hnThe Tenacity of Tech Recruiters132 points
- hnAudacium has officially merged with Tenacity126 points
- hnThe Tenacity of Tech Recruiters94 points
- 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.
- Repeating the operation can duplicate a payment, message, email, or state change and no idempotency key protects it.
- An HTTP or database client already implements protocol-aware retries, method rules, connection recovery, and server delay headers.
- The work must survive process termination. An in-process retry loop loses state on a crash; a durable queue or workflow engine owns that requirement.
- Failures are validation, authentication, permission, or other deterministic errors. Retrying them delays the useful error and adds load.
- No attempt, elapsed-time, or external cancellation boundary can be chosen. Bare `@retry` repeats forever with no wait in 9.1.4.
- The team plans to copy `enabled=`, async attempt-context, or `Retrying(name=...)` examples from GitHub 9.2.0 while installing PyPI 9.1.4.
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
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.

