skip to content

Why does @retry(times=3) need one more function layer than a bare @retry?

level: juniorimportance: must knowfreq 60%

answer

  1. Count the returns, not the defs
  2. The @ expression is evaluated first
  3. A call happens before the function arrives
  4. Factory returns decorator returns wrapper
  5. retry(times=3)(plan_route)

basics

~20 s

@retry(times=3) calls retry(times=3) first and decorates with whatever that call returns. So retry must be a factory that returns a decorator, which then takes the function and returns the wrapper - three nested levels instead of two.

solid answer

~40 s

The `@` line is not special-cased for arguments: Python evaluates the expression after `@`, then calls the result with the function being defined. With `@retry(times=3)` that expression is a *call*, so `retry(times=3)` runs first and whatever it returns must itself be a decorator. That forces three levels: the **factory** closes over the options, the `decorator(func)` it returns closes over the function, and the `wrapper(*args, **kwargs)` that returns is what the name ends up bound to. Levels one and two run once, when the `def` executes; only the wrapper runs per call. Forgetting the third level is the classic bug - applying such a factory as bare `@retry` binds the function to `times` and leaves the inner decorator under the function's name.

code

python · 25 lines
python
import functools

def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ValueError:
                    if attempt == times:
                        raise
        return wrapper
    return decorator

calls = []

@retry(times=3)
def plan_route():
    calls.append(1)
    if len(calls) < 3:
        raise ValueError("not yet")
    return "planned"

print(plan_route(), len(calls))

go deeper

for a junior

Be ready to say what @retry(times=3) expands to and to name the three levels in order. Recall that the expression after @ is evaluated before the function is ever passed in.

for a middle

Explain which level closes over the options and which closes over the function, and that the factory and the decorator both run once when the def executes while only the wrapper runs per call.

for a senior

Show the failure modes you have debugged: a factory applied bare, which raises a TypeError about a missing func at the first call rather than at import, and the reverse mistake, which fails immediately.

for a principal

Own the convention. Decide whether your codebase's decorators are always parameterised, whether their options are keyword-only, and how a team avoids a decoration bug that surfaces far from the line that caused it.

### The decoration line is a rewrite rule, not special argument handling Python does not treat `@` as a keyword that understands options. It evaluates the expression that follows `@`, calls the result with the object being defined, and rebinds the name to whatever that call returns. One rule covers both spellings: ```python @retry def plan_route(): ... # plan_route = retry(plan_route) @retry(times=3) def plan_route(): ... # plan_route = retry(times=3)(plan_route) ``` The second decoration contains a *call*. The interpreter evaluates `retry(times=3)` first, and then immediately applies whatever came back to `plan_route`. Nothing hands `times=3` to `retry` alongside the function; there are two separate calls, in that order. That is the entire reason a parameterised decorator needs one more layer than a bare one — the layer is not a convention, it is what the desugaring demands. ### The three levels and what each one closes over ```python import functools def retry(times): # 1: the factory - receives the options def decorator(func): # 2: the real decorator - receives the function @functools.wraps(func) def wrapper(*args, **kwargs): # 3: runs per call - sees both for attempt in range(1, times + 1): try: return func(*args, **kwargs) except ValueError: if attempt == times: raise return wrapper return decorator ``` The names are conventional; the return chain is not. Each level's only structural job is to return the next one, and the last one returns the value the caller wanted. `wrapper` can read `times` because it is nested inside the factory's scope, and it can read `func` because it is nested inside the decorator's scope. Both arrive as closure cells, which is why nothing has to be stored on the function object or threaded through as an extra parameter. A useful way to check your own code is to count `return` statements rather than `def` statements: a bare decorator has one `return` of a callable, a factory has two. ### When each level actually runs Levels one and two run exactly once, at the moment the `def` statement executes — module import time for a top-level function, class-body execution for a method. Only `wrapper` runs per call. Saying that out loud in an interview is what separates "I memorised the shape" from "I know decoration is ordinary function application". It also explains why any cost you pay inside the factory or the decorator is paid once per process, while cost inside the wrapper is paid on every call. ### The two ways of getting the count wrong, and how differently they fail Apply a factory bare and nothing complains at import: `plan_route = retry(plan_route)` succeeds, binding the function object to the `times` parameter and leaving the *inner decorator* under the name `plan_route`. The failure surfaces later, at the first call, and the message points at a function the caller has never heard of: ``` TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'func' ``` The mirror-image mistake — writing `@log()` where `log` is an ordinary two-level decorator — fails immediately, at import, with `TypeError: log() missing 1 required positional argument: 'func'`. That asymmetry is worth remembering: the missing-parentheses bug is the dangerous one because it can ship, sit in a rarely-exercised branch, and blow up in production far from the decoration line. ### Empty parentheses are not optional If a factory's options all have defaults, you still write `@retry()`, not `@retry`. The parentheses are what runs the factory and produces a decorator. Deciding a factory should also accept the bare form is a deliberate design choice that costs an extra branch in the signature; it does not happen for free. ### The factory is just a callable Nothing requires the factory to be a plain function, and nothing requires the decorator it returns to be a function either — only that the object returned by the `@` expression can be called with one argument and return something useful. Since Python 3.9 (PEP 614) the grammar after `@` accepts any expression, so `@registry["retry"](times=3)` is legal; before 3.9 the grammar allowed only a dotted name with an optional call, which already permitted `@retry(times=3)`. The semantics of decorator factories themselves are unchanged through 3.14. ### Why this gets asked live Interviewers ask candidates to write a parameterised decorator on the spot precisely because the three-level shape is easy to memorise and hard to fake under a follow-up. The tell is whether a candidate can say, for each `return` in their own code, what the returned object is and who calls it next.

  • What error do you get if you write bare @retry on a factory, and when does it surface?
    Nothing fails at decoration: `retry(plan_route)` succeeds, binding the function to `times`, and the name is left holding the inner decorator. The failure comes at the first call, as `TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'func'`. That lateness is what makes it dangerous - the mirror mistake, `@log()` on a plain two-level decorator, fails loudly at import instead.
  • If every option on a factory has a default, why do you still have to write @retry()?
    Because the parentheses are what runs the factory. Without them the `@` expression evaluates to the factory itself, so Python calls the factory with the function and binds the returned decorator to the name. Accepting the bare form as well is a deliberate design choice requiring an extra branch in the signature; it is not automatic.
  • Does the factory have to be a function?
    No. The `@` machinery only requires that the expression after `@` evaluates to something callable with one argument, and that the result of that call is usable as the new binding. Any callable returning a callable works. Since Python 3.9 the grammar also allows arbitrary expressions there, so a lookup like `@registry["retry"](times=3)` is legal.

A bare decorator is a vending machine that takes your function and hands back a wrapped one. A factory is a machine that first takes your coins and dispenses the vending machine.

saying these in an interview costs you the question

  • Says the @ line passes its options to the wrapper
  • Claims Python calls retry(func, times=3) automatically
  • Insists two levels are enough for a parameterised decorator
  • Applies the factory as bare @retry and expects the defaults
  • Thinks a new wrapper is built on every call
  • Treats the parentheses as optional on a factory

context