tenacity
Tenacity is the standard Python retry library: you put @retry on a function and compose stop conditions (attempts, elapsed time), wait strategies (fixed, exponential, jitter), and retry predicates (which exceptions or return values count as failure) as small objects combined with | and +. It began as a fork of the abandoned retrying package and now covers sync functions, asyncio, Trio, and Tornado coroutines, plus a for-loop form for retrying a code block without extracting a function. OpenAI's SDK, LangChain, and half the API-client ecosystem use it underneath.
The default retry library for Python and a safe dependency at roughly 100M weekly downloads; just never ship a bare @retry and decide up front between reraise=True and catching RetryError. If you want guard rails instead of full control, use stamina on top of it.
Use it if
- You call flaky external services and want exponential backoff with jitter in one decorator line instead of a hand-rolled while loop
- You need retry logic on async code: @retry works on asyncio and Trio coroutines and sleeps asynchronously, which most homegrown loops get wrong
- Your retry policy is genuinely conditional: retry on IOError but not on a 4xx-style client error, or retry while the function keeps returning None
- You want observable retries: before_sleep_log hooks and the .statistics attribute give you logging and attempt counts without extra plumbing
- You have one call site with a simple policy: a three-line for loop with time.sleep is easier to read and debug than a decorator stack
- Your retries live in the HTTP layer anyway: urllib3's Retry via requests adapters, or httpx transport retries, handle status-code-aware retry closer to the wire
- The bare @retry default is a footgun you must remember: it retries forever with no wait, so an unconfigured decorator can hang a worker silently
- You want a stricter, typed, opinionated API: stamina wraps tenacity with safer defaults and less surface to misuse
- You retry generators: @retry on a generator function does nothing useful, since the wrapped call just returns the generator object without executing the body
Setup reality
pip install tenacity is a pure-Python, dependency-free install; current releases need Python 3.10+. The friction is semantic, not mechanical: bare @retry retries forever with zero wait, failures surface as RetryError wrapping your exception unless you set reraise=True (so except MyError blocks silently stop matching after you add the decorator), and the kwargs-of-objects API (stop=, wait=, retry=) takes a docs visit every time. In tests you must patch the .retry attribute or pass enabled=False, or your suite sleeps through real backoff.
Patterns
Retry with attempts cap and exponential backoffbasic-retry
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=1, max=30))
def fetch():
return call_flaky_api()Always pass stop=; a bare @retry retries forever with no wait, which is almost never what you want in production.
Retry only on specific exception typesretry-specific-exceptions
from tenacity import retry, retry_if_exception_type, stop_after_attempt
@retry(
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
stop=stop_after_attempt(4),
)
def sync_data():
push_to_server()Anything not listed raises immediately; there is also retry_if_not_exception_type to retry everything except, say, a permanent auth error.
Surface the original exception instead of RetryErrorreraise-original
from tenacity import retry, stop_after_attempt
@retry(reraise=True, stop=stop_after_attempt(3))
def load():
raise IOError("disk on fire")
try:
load()
except IOError:
handle_it()Without reraise=True the caller gets RetryError and existing except blocks for your exception silently stop matching; this is the most common integration bug.
Exponential backoff with random jitterjittered-backoff
from tenacity import retry, stop_after_delay, wait_random_exponential
@retry(wait=wait_random_exponential(multiplier=1, max=60), stop=stop_after_delay(120))
def call_rate_limited_api():
return client.get("/v1/thing")wait_random_exponential is the recommended shape for shared APIs because jitter spreads out competing clients; wait strategies also compose with +, e.g. wait_fixed(3) + wait_random(0, 2).
Stop on attempts OR total elapsed timecombine-stop-conditions
from tenacity import retry, stop_after_attempt, stop_after_delay, wait_fixed
@retry(stop=(stop_after_delay(30) | stop_after_attempt(10)), wait=wait_fixed(2))
def poll_job():
return check_status()Stop conditions combine with |; whichever triggers first ends the retrying. stop_before_delay exists when overshooting a deadline is not acceptable.
Retry while the return value is wrongretry-on-result
from tenacity import retry, retry_if_result, stop_after_attempt
@retry(retry=retry_if_result(lambda r: r is None), stop=stop_after_attempt(10))
def get_assignment():
return queue.claim() # returns None when nothing availablePredicates can be OR-ed together: retry=(retry_if_result(is_none) | retry_if_exception_type(IOError)) retries on either signal.
Log each failed attempt before waitinglog-before-sleep
import logging
from tenacity import retry, stop_after_attempt, wait_fixed, before_sleep_log
logger = logging.getLogger(__name__)
@retry(
stop=stop_after_attempt(5),
wait=wait_fixed(2),
before_sleep=before_sleep_log(logger, logging.WARNING),
)
def publish():
send_event()before_sleep only fires when a retry is actually coming, so you log real retries without noise from the first attempt or the final failure.
Retry an asyncio coroutineasync-retry
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(4), wait=wait_exponential(min=1, max=10))
async def fetch_page(session, url):
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.text()The same decorator detects coroutines and sleeps with asyncio.sleep; for Trio pass sleep=trio.sleep so waits do not block the event loop.
Retry a code block without extracting a functionretry-code-block
from tenacity import Retrying, RetryError, stop_after_attempt, wait_fixed
try:
for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(1)):
with attempt:
conn = connect()
conn.upload(payload)
except RetryError:
alert_ops()The for-loop plus context manager form shares local state across attempts; use AsyncRetrying with async for inside coroutines.
Build retry policy from runtime valuesruntime-configuration
from tenacity import Retrying, stop_after_attempt, wait_fixed
def reliable_call(fn, *args, max_attempts=3):
retryer = Retrying(stop=stop_after_attempt(max_attempts), wait=wait_fixed(1), reraise=True)
return retryer(fn, *args)Retrying used directly avoids decorator-time constants; decorated functions also expose fn.retry_with(...) to override the policy per call.
Turn off waits or retries in testsdisable-in-tests
from unittest import mock
from tenacity import stop_after_attempt, wait_fixed
with mock.patch.object(flaky_fn.retry, "wait", wait_fixed(0)):
flaky_fn()
# or skip retrying entirely per call:
flaky_fn.retry_with(stop=stop_after_attempt(1))()Recent releases also accept enabled=False on @retry (handy behind an env var); without one of these tricks your test suite sleeps through real backoff.
Read attempt statistics after a callinspect-statistics
@retry(stop=stop_after_attempt(3), reraise=True)
def task():
do_work()
try:
task()
finally:
print(task.statistics) # {'start_time': ..., 'attempt_number': ..., 'idle_for': ...}statistics reflects the most recent invocation only and lives on the wrapped function, so it is per-function, not per-call, under concurrency.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| stamina | PyPI | You want production-safe defaults (bounded attempts, jitter on) and a typed API; it is built on tenacity. |
| backoff | PyPI | You prefer a smaller decorator-only library (@backoff.on_exception) and do not need tenacity's composable conditions. |
| httpx | PyPI | Your only retry target is HTTP: transport-level retries plus status handling may remove the need for a retry library at all. |