mrkeyoor.com_
Fri 07 Aug 20:52 UTC
PyPIUtilsupdated 07 Aug 2026

retrying

retrying is a decorator that calls your function again when it fails. Put @retry on a flaky function and any exception it raises causes another call. Keyword arguments control the rest: stop_max_attempt_number caps how many tries, stop_max_delay caps total elapsed time, wait_fixed sleeps a constant amount between tries, wait_exponential_multiplier does exponential backoff, and retry_on_exception or retry_on_result let you decide which failures deserve another go. Every time value is an integer number of milliseconds. The whole library is one 350-line module with no dependencies. It dates from 2013, the original author stopped maintaining it, and Greg Roodt now publishes it from a new repository so the enormous number of projects that pinned it keep working.

Verdict

A working retry decorator kept on life support so old pins keep resolving, and fine if you already depend on it. For anything new, tenacity is the same idea with async, jitter, and a maintainer.

API stability5/5The keyword surface has not changed since the original 1.3.3 releases, and 1.4.x added a logger option and tuple support for retry_on_exception without touching existing behavior; nothing you wrote in 2015 breaks.
Docs2/5The README is the only documentation and covers each keyword with a one-line example, but it never mentions that durations are milliseconds, that bare @retry never stops, or that multiple wait options combine by maximum.
Maintenance2/5Pushed July 2026 with 2 open issues and releases 1.4.1 and 1.4.2 in 2025, but the 2026 commits are dependabot bumps and Python 3.14 classifiers; the original author handed it off and no feature work is happening.
Ecosystem3/5Around 9M weekly downloads from being pinned deep inside older tooling, against only 96 stars on the current repository, which is the gap between installed and chosen; nothing is built on top of it.

Use it if

  • Something in your dependency tree already requires retrying and you need to read or patch the call sites; being able to interpret @retry(stop_max_attempt_number=7, wait_exponential_multiplier=1000) is the main practical use today
  • You want a zero-dependency retry decorator in a constrained environment where adding tenacity's dependency chain is more paperwork than the problem is worth
  • Your retry logic is genuinely simple and synchronous: try an HTTP call or a database connect a few times with backoff, then give up
  • You need to retry on a returned value rather than an exception, for example polling until a function stops returning None; retry_on_result covers that without you writing a loop
Skip it if

Setup reality

pip install retrying installs a single pure Python module with no dependencies on Python 3.6 and up, so the install itself is a non-event. The configuration is where you lose time. Every duration is milliseconds as an integer, so wait_fixed=2 sleeps two milliseconds rather than two seconds. Defaults only apply to a category once you touch it: passing stop_max_attempt_number activates the attempt limit, but with no stop keyword at all there is no limit and the decorator loops until the function succeeds. If you set both wait_fixed and wait_exponential_multiplier, the library takes the maximum of the two per attempt instead of adding them. There is no jitter unless you pass wait_jitter_max, and by default every failure is retried, including TypeError and other bugs in your own code that no amount of retrying will fix.

Patterns

Retry a flaky function on any exceptionbasic-retry

from retrying import retry

@retry(stop_max_attempt_number=3)
def fetch():
    return requests.get(url).json()

Always pass a stop condition. A bare @retry with no arguments registers no stop function and no wait function, so it loops forever with no sleep between attempts and pins a core.

Cap by attempts or by total elapsed timelimit-attempts-and-time

from retrying import retry

@retry(stop_max_attempt_number=7)
def seven_tries():
    ...

@retry(stop_max_delay=10000)   # 10 seconds total
def ten_seconds():
    ...

Milliseconds, not seconds: stop_max_delay=10 gives up after 10ms. The elapsed check runs after an attempt finishes, so a single slow call can overshoot the budget by its own duration.

Sleep a constant amount between attemptsfixed-wait

from retrying import retry

@retry(wait_fixed=2000, stop_max_attempt_number=5)
def poll():
    ...

wait_fixed=2000 is two seconds. Constant waits from many clients hitting one recovering service produce a thundering herd; add wait_jitter_max or use exponential backoff instead.

Back off exponentially with a ceilingexponential-backoff

from retrying import retry

@retry(wait_exponential_multiplier=1000,
       wait_exponential_max=10000,
       stop_max_attempt_number=6)
def call_remote():
    ...

The wait is multiplier * 2 ** attempt_number capped at the max, so the first sleep is already 2000ms here, not 1000ms. Combining this with wait_fixed does not add them; retrying takes the larger of the two.

Spread retries out with randomnessrandom-and-jitter

from retrying import retry

@retry(wait_random_min=1000, wait_random_max=2000, stop_max_attempt_number=5)
def jittered():
    ...

@retry(wait_exponential_multiplier=500, wait_jitter_max=1000,
       stop_max_attempt_number=5)
def backoff_with_jitter():
    ...

wait_jitter_max adds a uniform 0 to N milliseconds on top of whatever the wait function returned, which is the piece exponential backoff needs to stop clients from synchronizing. It is off by default.

Only retry the failures worth retryingretry-on-specific-exceptions

from retrying import retry

@retry(retry_on_exception=(IOError, TimeoutError),
       stop_max_attempt_number=4, wait_fixed=500)
def only_transient():
    ...

def _is_retryable(exc):
    return isinstance(exc, HTTPError) and exc.response.status_code >= 500

@retry(retry_on_exception=_is_retryable, stop_max_attempt_number=4)
def only_5xx():
    ...

The default retries every exception, so a TypeError from your own code gets attempted five times before surfacing. A non-retryable exception propagates immediately without waiting, which is the behavior you want.

Retry based on the return valueretry-on-result

from retrying import retry

@retry(retry_on_result=lambda r: r is None,
       stop_max_attempt_number=5, wait_fixed=1000)
def poll_until_ready():
    return queue.pop()   # None until a job appears

Return True from the predicate to try again. If the result is still rejected when the stop condition fires, you get RetryError rather than the last value, so read e.last_attempt.value to see what you actually got.

Choose between the original error and RetryErrorwrap-exception

from retrying import retry, RetryError

@retry(stop_max_attempt_number=3, wrap_exception=True)
def wrapped():
    raise ValueError("nope")

try:
    wrapped()
except RetryError as e:
    print(e.last_attempt.attempt_number)      # 3
    print(e.last_attempt.value[1])            # the ValueError

By default the original exception is re-raised with its traceback, which is usually what your error handling expects. With wrap_exception=True you get RetryError instead, and last_attempt.value holds the sys.exc_info tuple.

Retry a call without decorating anythingretrying-object

from retrying import Retrying

retrier = Retrying(stop_max_attempt_number=3, wait_fixed=200)
result = retrier.call(session.get, url, timeout=5)

Useful when the function is not yours to decorate or the policy is chosen at runtime. One Retrying instance is reusable and holds no per-call state, so you can build it once from config.

Observe each attemptattempt-hooks

import logging
from retrying import retry

@retry(stop_max_attempt_number=4, wait_fixed=250,
       before_attempts=lambda n: logging.info("attempt %s", n),
       after_attempts=lambda n: metrics.incr("retry"),
       logger=logging.getLogger("http"))
def instrumented():
    ...

after_attempts only fires when the attempt is rejected, not on the successful one. Passing logger routes retrying's own warnings into your logging setup; without it the library attaches a NullHandler and stays silent.

Understand why this cannot wrap async functionsasync-does-not-work

# Broken: the coroutine is returned, never awaited, so nothing can fail
@retry(stop_max_attempt_number=3)
async def fetch():
    return await client.get(url)

# Blocking: time.sleep inside an event loop stalls every other task
# Use tenacity.AsyncRetrying or stamina instead.

retrying was written before async existed in Python. Calling the decorated coroutine function succeeds immediately because building a coroutine object never raises, so your retry policy silently does nothing.

Translate a retrying policy to tenacitymigrate-to-tenacity

# before
# @retry(stop_max_attempt_number=5,
#        wait_exponential_multiplier=1000, wait_exponential_max=10000,
#        retry_on_exception=(IOError,))

from tenacity import (retry, stop_after_attempt, wait_exponential,
                      retry_if_exception_type)

@retry(stop=stop_after_attempt(5),
       wait=wait_exponential(multiplier=1, max=10),
       retry=retry_if_exception_type(IOError),
       reraise=True)
def call_remote():
    ...

Two conversions to watch: tenacity takes seconds where retrying took milliseconds, and tenacity raises its own RetryError by default, so pass reraise=True to keep retrying's habit of surfacing the original exception.

Alternatives

PackageRegistryPick it when
tenacityPyPIYou are writing new retry code: composable stop and wait objects, async support, jitter, and an active release cadence.
staminaPyPIYou want production defaults chosen for you on top of tenacity: capped exponential backoff with jitter, sync and async, and testing hooks.
backoffPyPIYou prefer a decorator-per-policy style, like @backoff.on_exception(backoff.expo, RequestException, max_tries=5).