backoff review
backoff 2.2.1 puts retry rules on Python functions with decorators. It can react to a named exception or an unacceptable return value, then wait using exponential, Fibonacci, constant, or response-derived timing. The same API covers regular functions and asyncio coroutines. You can cap attempts or elapsed time, stop on a specific error, add jitter, and observe each retry through callbacks. Our sandbox found a typed, pure-Python install with no direct dependencies. The catch is ownership: litl/backoff is archived, and 2.2.1 has been the current release since October 2022.
backoff 2.2.1 installed in 0.2 seconds, occupied 1 MB, and produced zero pip-audit findings in our sandbox, but its GitHub repository is archived. Keep it in tested existing code; pick a maintained retry library for a new service.
We installed it
| Install | ✓ · 0.2s | 1 package on disk · 1 MB |
| Import | ✓ | import backoff in 0.24s · pure Python · py.typed · requires Python >=3.7,<4.0 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does backoff install cleanly?
Yes. In a fresh container with an empty cache, pip install backoff finished in 0.2s, leaving 1 package and 1 MB on disk. pip-audit reported no known vulnerabilities.
What does backoff need to run?
Python >=3.7,<4.0, and nothing compiled: it is pure Python. In our run import backoff succeeded in 0.24s, and the package ships py.typed for type checkers.
backoff or tenacity: which should you use?
tenacity: Choose it for an actively maintained retry API with decorator and block-level forms. backoff 2.2.1 installed in 0.2 seconds, occupied 1 MB, and produced zero pip-audit findings in our sandbox, but its GitHub repository is archived.
When should you not use backoff?
You are selecting a retry dependency for a new service. GitHub has made litl/backoff read-only, so fixes cannot land in this repository.
Discussed on
Use it if
- You maintain a service that already expresses retry policy with on_exception or on_predicate decorators.
- A polling function should repeat until its return value passes a small predicate.
- One retry declaration needs to work on both synchronous functions and asyncio coroutines.
- The next wait must come from a response value such as a Retry-After header.
- You are selecting a retry dependency for a new service. GitHub has made litl/backoff read-only, so fixes cannot land in this repository.
- The code needs an inline retry loop around only part of a function. The documented interface wraps the whole callable with a decorator.
- You need active support for later Python releases. PyPI 2.2.1 dates to October 2022, and the repository was archived in 2024.
- The retry layer must provide per-attempt timeouts or circuit-breaking. Those controls are outside the README's API and must come from the operation or another package.
- None is a valid success value and callers may disable final exceptions. raise_on_giveup=False also returns None after exhaustion, which makes the two outcomes indistinguishable.
Setup reality
We installed backoff 2.2.1 in 0.2 seconds in a fresh Python 3.12 container. The result was one package and 1 MB on disk, with zero direct dependencies. pip-audit found no known vulnerabilities. It is pure Python, declares Python 3.7 through 3.x, and includes py.typed. Our import backoff check completed in 0.24 seconds.
No account, credential, or configuration file is involved. Decorators are created when Python imports the module, though options such as max_time can receive a zero-argument callable. Use that form when the value lives in runtime settings. The package logger is silent by default through a NullHandler, so wire your own logging handler or retry callbacks if attempts must appear in operations data.
Put a ceiling on every retry. The basic on_exception form has no finite stop unless you set max_time or max_tries. Full jitter is enabled by default, and the generator's delay is the maximum sleep rather than an exact schedule. A finite retry window still needs an individual timeout on the network or storage call, because backoff does not impose one.
Coroutine functions are detected and awaited, and event handlers may be coroutines too. A blocking synchronous handler still stalls the event loop. The default final behavior raises the last exception after give-up handlers run. If raise_on_giveup is false, exhaustion becomes a None return, so the calling code must reserve None for that outcome.
Patterns
Retry one transient failure retry-transient-exception
import backoff
@backoff.on_exception(backoff.expo, ConnectionError, max_time=30)
def fetch_record():
return client.fetch()max_time limits total elapsed retry time. Without max_time or max_tries, the decorator can keep retrying a permanent fault.
Retry a fixed exception tuple retry-error-family
@backoff.on_exception(
backoff.expo,
(TimeoutError, ConnectionError),
max_tries=5,
)
def fetch_record():
return client.fetch()Only the listed exception types trigger another attempt; every other exception leaves the function immediately.
Stop retrying client errors give-up-on-http-status
def fatal(error):
response = getattr(error, 'response', None)
return response is not None and 400 <= response.status_code < 500
@backoff.on_exception(backoff.expo, RequestError, giveup=fatal, max_time=60)
def load_items():
return client.get('/items')A truthy giveup result ends the loop. The last exception is still raised under the default raise_on_giveup setting.
Poll until a truthy value arrives poll-until-value
@backoff.on_predicate(backoff.constant, interval=1, jitter=None, max_time=20)
def next_message():
return queue.read()The default predicate retries every falsey result, including None, 0, False, and empty containers.
Repeat while a job is pending poll-specific-state
@backoff.on_predicate(
backoff.fibo,
predicate=lambda job: job.status == 'pending',
max_value=8,
max_time=60,
)
def read_job():
return jobs.get(job_id)max_value belongs to the Fibonacci wait generator; max_time caps the complete decorated call.
Take the delay from a response honor-retry-after
@backoff.on_predicate(
backoff.runtime,
predicate=lambda response: response.status_code == 429,
value=lambda response: int(response.headers['Retry-After']),
jitter=None,
max_time=120,
)
def request():
return client.get('/quota')This parser accepts numeric Retry-After values only. Add HTTP-date parsing when the upstream can send that form.
Turn off random jitter use-fixed-delays
@backoff.on_exception(
backoff.expo,
ConnectionError,
max_tries=4,
jitter=None,
)
def request():
return client.get('/health')jitter=None makes delays deterministic, which can synchronize many clients hitting the same recovering service.
Resolve a limit at call time read-runtime-budget
def retry_budget():
return settings.retry_seconds
@backoff.on_exception(backoff.expo, OSError, max_time=retry_budget)
def sync_file():
return storage.sync()Pass the function itself. Calling retry_budget inside the decorator would freeze the setting during module import.
Count each scheduled retry record-attempt
def record_retry(details):
metrics.increment('worker.retry', tags={'tries': details['tries']})
@backoff.on_exception(backoff.expo, OSError, max_tries=4, on_backoff=record_retry)
def sync_file():
return storage.sync()on_backoff receives a details mapping with tries, elapsed time, target arguments, and the next wait.
Log the terminal failure record-exhaustion
def record_giveup(details):
logger.error('retry exhausted', exc_info=details['exception'])
@backoff.on_exception(backoff.expo, OSError, max_tries=4, on_giveup=record_giveup)
def sync_file():
return storage.sync()The default policy raises the exception again after the give-up callback finishes.
Wrap an asyncio request retry-async-call
import aiohttp
import backoff
@backoff.on_exception(backoff.expo, aiohttp.ClientError, max_time=30)
async def fetch_json(session, url):
async with session.get(url) as response:
response.raise_for_status()
return await response.json()Use coroutine callbacks or quick synchronous callbacks here; blocking callback work holds up the event loop.
Return cached data after exhaustion fallback-after-giveup
@backoff.on_exception(
backoff.expo, OSError, max_tries=3, raise_on_giveup=False
)
def read_remote():
return storage.read()
result = read_remote()
if result is None:
result = read_cache()raise_on_giveup=False returns None after the last attempt. Do not use this shape when None is also a valid remote result.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| tenacity | PyPI | Choose it for an actively maintained retry API with decorator and block-level forms. |
| stamina | PyPI | Choose it when Tenacity-backed retries should come with narrower defaults and instrumentation support. |
| retrying | PyPI | Consider it only for older code already written against its decorator conventions. |
More utils guides
lru-cache · ajv · type-fest · 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.

