mrkeyoor.com_
Thu 06 Aug 00:58 UTC
PyPIUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability4/5The stop/wait/retry composition API has been stable for years across the 8.x and 9.x lines, though the 9.0 major did drop old Python versions and prune deprecated internals that some libraries pinned against.
Docs4/5The README doubles as the readthedocs site and covers nearly every feature with runnable examples, but it is one long page and the RetryCallState/callback reference takes digging.
Maintenance4/5Pushed August 2026 with only 44 open issues and PRs and steady releases (9.1.4 in February 2026), but it is a small volunteer effort led by one primary maintainer rather than a funded team.
Ecosystem5/5Roughly 100M weekly downloads and it is the retry layer inside major SDKs including OpenAI's Python client and LangChain, so examples, answers, and battle-testing are everywhere.

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
Skip it if

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 available

Predicates 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

PackageRegistryPick it when
staminaPyPIYou want production-safe defaults (bounded attempts, jitter on) and a typed API; it is built on tenacity.
backoffPyPIYou prefer a smaller decorator-only library (@backoff.on_exception) and do not need tenacity's composable conditions.
httpxPyPIYour only retry target is HTTP: transport-level retries plus status handling may remove the need for a retry library at all.