skip to content

How would you write a Python retry decorator that retries only chosen exception types?

level: middleimportance: must knowfreq 65%

answer

  1. Wrapping a callable in a bounded loop
  2. Not every failure deserves a second call
  3. The wrapper must not steal the identity
  4. functools.wraps plus an exception tuple
  5. Bare raise on the final attempt

basics

~20 s

Loop over a fixed number of attempts, catch only a tuple of retryable exception classes, sleep on a growing delay between attempts, and re-raise on the last one. Decorate the wrapper with functools.wraps so it keeps the original function's name and docstring.

solid answer

~40 s

A parameterised retry decorator is three nested functions: the outer one takes the policy (attempt count, base delay, and the tuple of exception classes to catch), the middle one takes the target function, and the inner wrapper runs the loop. Inside the loop, call the function and `return` on success; catch only an explicit tuple such as `(ConnectionError, TimeoutError)` and let everything else propagate, because a `ValueError` will fail identically on every attempt. On the final attempt use a bare `raise` so the original traceback survives, and put that check before the sleep so nobody pays a backoff for nothing. Apply `@functools.wraps(func)` to the wrapper so `__name__`, `__doc__`, `__module__` and `__qualname__` come across and `inspect.signature` still reports the real parameters. Never catch bare `Exception`, and never retry `BaseException` subclasses such as `KeyboardInterrupt` or `asyncio.CancelledError`.

code

python · 36 lines
python
import functools
import random
import time


def retry(attempts=3, base_delay=0.01, exceptions=(ConnectionError, TimeoutError)):
    def decorate(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == attempts:
                        raise
                    window = base_delay * 2 ** (attempt - 1)
                    time.sleep(random.uniform(0, window))
        return wrapper
    return decorate


calls = 0


@retry(attempts=4)
def flush_batch():
    """Fail twice, then succeed."""
    global calls
    calls += 1
    if calls < 3:
        raise ConnectionError("transport reset")
    return "flushed"


print(flush_batch(), calls)
print(flush_batch.__name__, flush_batch.__doc__)

go deeper

for a junior

Be ready to recall that a decorator is a function returning a replacement function, and that a retry version loops over attempts with a try/except. Knowing that functools.wraps exists and why the name matters is enough at this level.

for a middle

Expect to write the three-layer decorator on a whiteboard: policy, decorator, wrapper. Explain why the exception tuple is a parameter, why the last attempt re-raises rather than returning, and exactly which attributes functools.wraps copies.

for a senior

Show the judgement behind the tuple: which failures are genuinely transient in your transport, why except Exception turns bugs into slow bugs, and how you keep the wrapper thin so deadlines, logging policy and error handling stay with the caller.

for a principal

Own the question of whether a hand-rolled decorator should exist at all in your codebase, where the single one lives, and how you stop teams from each inventing a slightly different exception classification for the same dependency.

## Three layers, not two A retry decorator that accepts options is three nested functions. The outermost takes the *policy*: how many attempts, the base delay, and — most important — the tuple of exception classes that count as retryable. It returns the actual decorator, which takes the target function and returns the wrapper that callers will end up invoking. Writing `@retry(attempts=5)` calls the outer function at import time and applies its result to the function below. Writing `@retry` with no parentheses against that same definition silently binds the *factory* to the name, and the first call fails with a confusing `TypeError` — one of the classic mistakes in this shape. If you never need options, write a two-layer decorator instead; if you might, write three layers from the start. ## The loop ```python for attempt in range(1, attempts + 1): try: return func(*args, **kwargs) except retryable: if attempt == attempts: raise time.sleep(backoff(attempt)) ``` Three details carry the weight. The `return` inside `try` exits on the first success, so the happy path costs one extra frame and nothing else. The bare `raise` inside the handler re-raises the current exception with its traceback intact; the alternative reviewers look for is a wrapper that swallows the last failure and returns `None`, which converts a transport failure into a plausible-looking value that detonates somewhere far away. And the exhausted-attempts check must sit *before* the sleep, or the caller pays one final backoff for an attempt that will never happen. ## Which exceptions are retryable The exception tuple is the real design decision. Transport-level failures are candidates: `ConnectionError` and its subclasses `ConnectionResetError` and `ConnectionRefusedError`, `TimeoutError`, and the `OSError` family a socket or file layer raises. Deterministic failures are not: `ValueError`, `TypeError`, `KeyError`, a decode error, an authentication rejection, a permission denial. Nothing about the second call differs, so retrying merely multiplies latency by the attempt count before delivering the same failure — and it hides the bug, because a genuine programming error now looks like a slow dependency. That is why `except Exception` is the single most common defect in a hand-rolled retry: it makes deterministic bugs slow instead of loud. Two classes deserve a specific mention. `KeyboardInterrupt` and `SystemExit` derive from `BaseException`, not `Exception`, so a correct `except Exception` never sees them — but a hand-rolled `except BaseException` does, and swallowing Ctrl-C into a retry loop makes a process feel unkillable. `asyncio.CancelledError` is likewise a `BaseException` subclass since Python 3.8; retrying a cancelled operation defeats the cancellation the caller explicitly requested. Make the tuple a parameter with a conservative default so a call site can narrow or widen it. A helper that classifies a caught exception — inspecting an error code or a status attribute before deciding — is a reasonable extension, but keep the predicate injectable rather than hard-coding one dependency's error taxonomy into a generic decorator. ## Why functools.wraps The wrapper is a different function object from the one it wraps, and by default it advertises itself. Without `functools.wraps`, `wrapper.__name__` is `"wrapper"`, `__doc__` is `None`, `__qualname__` points inside the decorator, and `__module__` names the module where the decorator was defined rather than where the function lives. The consequences are not cosmetic: log records and tracebacks name `wrapper`, documentation tooling renders an empty docstring, two decorated functions become indistinguishable in a profile, and `inspect.signature` reports `(*args, **kwargs)` instead of the real parameters — which breaks anything that introspects, including argument validation, CLI generation and any framework that binds by parameter name. `functools.wraps(func)` is itself a decorator applied to the wrapper; it delegates to `functools.update_wrapper`, which copies `__module__`, `__name__`, `__qualname__` and `__doc__`, updates the wrapper's `__dict__`, and records a link back to the original function so `inspect.signature` can follow it to the true parameters. ## Keeping the wrapper thin Resist growing the wrapper. It should decide *whether to call again* and nothing else. A total deadline belongs to the caller, not buried in the decorator's defaults. Logging one line per retried attempt is fine; deciding the application's error-handling policy inside a generic wrapper is not. And make the sleep injectable — a parameter defaulting to `time.sleep` — so unit tests can assert the attempt count and the delay sequence without spending real seconds.

  • Where should the retry decorator sit relative to other decorators on the same function?
    Decorators apply bottom-up: the one written closest to `def` wraps the raw function and each one above wraps that result. Everything below the retry decorator re-executes on every attempt; everything above it runs once around the whole sequence. So a timing decorator placed above measures total elapsed time including the sleeps, while the same decorator placed below measures each individual attempt.
  • Why re-raise on the final attempt instead of returning None?
    Because `None` is a value the caller may well accept. Swallowing the last failure turns a transport error into a wrong result that surfaces later, in unrelated code, with no traceback pointing at the real cause. A bare `raise` keeps the original exception and its traceback; if a call site genuinely wants a default on failure, it can wrap the call in its own `try`/`except` and choose one explicitly.
  • What has to change for the decorator to work on an `async def` function?
    The wrapper must itself be `async def`, `await func(*args, **kwargs)` instead of calling it, and use `asyncio.sleep` rather than `time.sleep` — a blocking sleep inside a coroutine stalls the whole event loop thread, not just that call. One decorator can serve both by branching on `inspect.iscoroutinefunction(func)` and returning the appropriate wrapper.

It is the difference between calling someone back when the line drops and calling back when they told you the number is wrong: only one of the two failures changes on the second try.

saying these in an interview costs you the question

  • Catches bare Exception, so a ValueError is retried three times
  • Swallows the final failure and returns None
  • Omits functools.wraps, so tracebacks and docs show wrapper
  • Retries KeyboardInterrupt or asyncio.CancelledError
  • Sleeps once more after the last attempt before raising
  • Hardcodes attempts and delay with no way to configure them

context