Writing a Retry Decorator for Coroutines¶
A retry decorator for coroutines is about thirty lines, and writing your own is reasonable when a library would be overkill or its semantics do not fit. The details are where homemade retry helpers go wrong. Tested on Python 3.14: a decorator that created the coroutine once and awaited it on each attempt failed on the second attempt with RuntimeError: cannot reuse already awaited coroutine — a coroutine object can be awaited only once, so each attempt must call the function again. The correct version, built with functools.wraps, kept the function's name and still reported inspect.iscoroutinefunction() == True, refused sync functions and async generators with a clear TypeError, and added about 100 ns per call (148 against 48 ns) when nothing failed. This guide builds the decorator step by step: fresh coroutines, a retry predicate, jittered backoff, a deadline, and correct cancellation.
Prerequisites¶
- Python 3.11+, stdlib only.
- What to retry, from classifying retryable errors in async clients.
- Testing it, from testing retry logic without real sleeps.
1. Call the function again on every attempt¶
A coroutine object is single-use. The decorator must keep the function and its arguments, and create a new coroutine per attempt:
# Broken: awaits the same coroutine object again
def retry_broken(attempts: int = 3):
def deco(fn):
async def run(coro):
for i in range(attempts):
try:
return await coro # second attempt: RuntimeError
except Transient:
if i == attempts - 1:
raise
return lambda *a, **kw: run(fn(*a, **kw))
return deco
# Correct: a fresh coroutine each time
def retry(attempts: int = 3):
def deco(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
for i in range(attempts):
try:
return await fn(*args, **kwargs) # new coroutine per attempt
except Transient:
if i == attempts - 1:
raise
return wrapper
return deco
Tested: the broken version raised RuntimeError: cannot reuse already awaited coroutine on its first retry; the correct one succeeded on the third attempt. The same mistake appears in retry helpers that take a coroutine instead of a function — await retry(fetch(url)) cannot work. Take a callable (a function plus arguments, or a zero-argument lambda) instead.
Verify: a test where the first two attempts fail and the third succeeds returns the third attempt's result.
2. Validate what is decorated, and preserve its identity¶
Fail at import time if the decorator is applied to something it cannot retry, and keep the wrapped function looking like the original:
import functools
import inspect
def retry(**options):
def deco(fn):
if not inspect.iscoroutinefunction(fn):
raise TypeError(f"retry() needs an async def function, got {fn!r}")
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
return await _run_with_retry(fn, args, kwargs, **options)
return wrapper
return deco
Tested: decorating a plain function or an async generator raised TypeError immediately, and the decorated coroutine function kept its __name__ and was still recognised by inspect.iscoroutinefunction. That matters for frameworks that inspect handlers — FastAPI, for example, decides how to call an endpoint by checking whether it is a coroutine function. Async generators cannot be retried by re-calling them without replaying already-yielded items, so rejecting them is the honest choice.
Verify: applying the decorator to a sync function fails when the module is imported, not when it is first called.
3. Add a predicate, jittered backoff and a deadline¶
The full retry loop decides per exception, backs off with jitter, and never exceeds a total time budget:
import random
import time
async def _run_with_retry(fn, args, kwargs, *, attempts: int = 4, base: float = 0.1,
cap: float = 2.0, budget: float | None = None,
retry_if=lambda exc: isinstance(exc, TRANSIENT_ERRORS)):
deadline = None if budget is None else time.monotonic() + budget
for attempt in range(attempts):
try:
return await fn(*args, **kwargs)
except Exception as exc:
if attempt == attempts - 1 or not retry_if(exc):
raise
delay = random.uniform(0, min(cap, base * 2 ** attempt)) # full jitter
if deadline is not None and time.monotonic() + delay >= deadline:
raise # no time for another try
exc.add_note(f"retrying after attempt {attempt + 1} (sleep {delay:.2f}s)")
await asyncio.sleep(delay)
Catching Exception — not BaseException — means CancelledError, KeyboardInterrupt and SystemExit always propagate immediately, which is exactly right. The last attempt re-raises the original exception, so callers see the same types as without retries; the note records how many attempts it took. Full jitter (uniform(0, backoff)) spreads retries from many clients, as explained in exponential backoff with jitter in asyncio. The deadline check avoids starting a sleep that would end after the budget; wrap the call in asyncio.timeout as well if a single attempt can run long.
Verify: a non-retryable error is raised after one attempt, and a retryable one after attempts with the note attached.
4. Support methods and per-call overrides¶
Decorators work on methods unchanged, because self is just the first argument. Configuration per call — a longer budget for a batch job — is handy without redecorating:
class Client:
@retry(attempts=4, budget=2.0)
async def get(self, path: str) -> dict:
response = await self.http.get(path)
response.raise_for_status()
return response.json()
def retry(**defaults):
def deco(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
return await _run_with_retry(fn, args, kwargs, **defaults)
async def with_options(*args, retry_options: dict, **kwargs):
return await _run_with_retry(fn, args, kwargs, **{**defaults, **retry_options})
wrapper.with_options = with_options # client.get.with_options(...) for bound use
wrapper.__wrapped__ = fn # call the undecorated function in tests
return wrapper
return deco
Keeping the original reachable through __wrapped__ (which functools.wraps sets anyway) lets tests call the function without retries when they test its own behaviour. Avoid configuration through global state; the explicit with_options keeps the effective policy visible at the call site.
Verify: a test calls Client.get.__wrapped__ to check behaviour without retries, and another checks the retry behaviour through the decorated method.
5. Decide when a library is the better choice¶
A homemade decorator is right when the policy is simple and stable. Reach for a library when you need more:
# Homemade covers: attempts, predicate, jittered backoff, budget, notes, cancellation safety.
# Consider tenacity when you need:
# - composable stop/wait strategies (stop_any, wait_chain, wait_random_exponential)
# - before/after/before_sleep hooks and retry statistics out of the box
# - Retry-After-aware waits, or retrying on results (retry_if_result)
# - the same API for sync and async code
The measured overhead of the homemade decorator was about 100 ns per successful call — negligible next to any I/O. tenacity adds more, still negligible, and brings composable strategies, hooks and statistics; its async behaviour and pitfalls are covered in retrying async calls with tenacity. Whichever you choose, pair retries with a budget or breaker so outages are not multiplied.
Verify: the team has one retry implementation in use, not several slightly different ones.
Verification¶
A coroutine retry decorator is correct when:
- Each attempt calls the function again, creating a fresh coroutine.
- Only
Exceptionsubclasses matching a predicate are retried; cancellation propagates. - Backoff is jittered and bounded by a deadline, and the original exception is re-raised.
- The decorator validates its target and preserves the function's identity.
Diagnostic Hook: read the notes on exceptions that reach your error tracker. "Retrying after attempt N" notes on most of them mean retries are not succeeding — the predicate may include permanent errors; their absence on transient errors means the decorator is not applied where you think.
Pitfalls & edge cases¶
- Awaiting the same coroutine twice. Tested:
RuntimeError. - Catching
BaseException. Cancellation and shutdown get retried. - Losing
iscoroutinefunction. Frameworks may call the wrapper incorrectly. - Decorating async generators. Re-calling them repeats yielded items.
Frequently Asked Questions¶
How do I write a retry decorator for async functions in Python?
Wrap the function with an async def wrapper decorated by functools.wraps, and in a loop call await fn(args, *kwargs) for each attempt, catching only retryable Exception subclasses and sleeping with jittered backoff between attempts.
Why do I get 'cannot reuse already awaited coroutine' when retrying?
The retry code awaits the same coroutine object again. A coroutine can be awaited once; call the function again to create a new coroutine for each attempt.
Should a retry decorator catch CancelledError?
No. Catch Exception, not BaseException, so cancellation propagates immediately and is never retried.
How much overhead does a retry decorator add?
In testing, about 100 ns per successful call (148 ns against 48 ns), negligible next to any I/O the function performs.
Related¶
- Retry & Backoff Strategies — up to the topic overview.
- Resuming interrupted streaming downloads — a retry that continues instead of repeating.
- Resilience, Cancellation & Error Handling — the section overview.