Skip to content

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

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.

Decorator behaviour, tested on Python 3.14 A grid of 5 rows by 2 columns. Decorator behaviour, tested on Python 3.14 check result retrying one coroutine object RuntimeError: cannot reuse already awaited coroutine calling fn(*args) per attempt succeeded on attempt 3 functools.wraps name kept, iscoroutinefunction() True applied to sync function / async generator TypeError at decoration time overhead, no failures 148 ns vs 48 ns per call The correct shape costs about 100 ns per call.

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.

One call through the retry decorator A flow of 5 stages. One call through the retry decorator fn(*args) fresh coroutine await success -> return Exception? predicate + attempts left jittered delay fits the deadline? note + sleep next attempt BaseExceptions such as CancelledError never enter the retry path.

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.

Homemade decorator or library? A decision on What does the policy need with 4 outcomes. Homemade decorator or library? What does the policy need? attempts, predicate, jitter, budget homemade decorator ~100 ns overhead hooks, stats, composable waits tenacity richer API retry on returned values tenacity retry_if_result not exceptions async generator do not retry by re-calling items already yielded Small and explicit is fine; just get the four details right.

Verification

A coroutine retry decorator is correct when:

  • Each attempt calls the function again, creating a fresh coroutine.
  • Only Exception subclasses 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.