skip to content

How do you make one decorator work both as @retry and as @retry(times=3)?

level: middleimportance: should knowfreq 40%

answer

  1. One name, two calling conventions
  2. Detect which form the caller used
  3. The function slot may arrive empty
  4. func=None plus keyword-only options
  5. functools.partial re-enters the same function

basics

~10 s

Give it a first parameter defaulting to None and keyword-only options: def retry(func=None, *, times=3). When func is None the decorator was called with options, so return functools.partial(retry, times=times); otherwise wrap func directly.

solid answer

~40 s

Write the signature as `def retry(func=None, *, times=3)` and branch on whether `func` arrived. `@retry` calls `retry(plan_route)`, so `func` is set and you build and return the wrapper. `@retry(times=5)` calls `retry(times=5)` with `func` still `None`, so you return `functools.partial(retry, times=5)`, which the `@` machinery then calls with the function - re-entering the same function on the first path. `@retry()` falls out for free. The bare `*` is the load-bearing part: it makes the only positional slot the function, so `func is None` is an exact test rather than a guess. The alternative to the sniff people reach for - `callable(first_arg)` - is unsafe, because classes and exception types are callable too.

code

python · 29 lines
python
import functools

def retry(func=None, *, times=3):
    if func is None:
        return functools.partial(retry, times=times)

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

@retry
def plain():
    return "plain"

@retry(times=5)
def parameterised():
    return "parameterised"

@retry()
def empty_parens():
    return "empty"

print(plain(), parameterised(), empty_parens())

go deeper

for a junior

Recall that a decorator taking options is called before it receives the function, so @retry and @retry(times=3) reach the same name by two different routes and the code has to tell them apart.

for a middle

Be able to write the func=None plus keyword-only signature from scratch and trace all three call paths out loud, including @retry() with empty parentheses, without looking at notes.

for a senior

Justify the design rather than reciting it: why a callable() sniff on a positional option is unsafe, why keyword-only options remove the ambiguity, and when the extra branch is not worth its cost to readers and type checkers.

for a principal

Decide the house style. A decorator exposed to many teams pays a real support cost for two spellings, and a single documented form with a clear error is often the better call than a clever signature nobody can type-annotate.

### The problem A decorator that takes options is called before it ever sees the function; a bare decorator receives the function directly. Those are two different calling conventions arriving at the same name, so a single object that serves both has to work out which one happened. It is a common request for decorators with all-default options, where forcing every caller to type `@retry()` for no benefit feels like a papercut. ### The canonical shape ```python import functools def retry(func=None, *, times=3): if func is None: # called with options return functools.partial(retry, times=times) @functools.wraps(func) # called with the function def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except OSError: if attempt == times: raise return wrapper ``` Trace both paths: * `@retry` evaluates `retry(plan_route)`. `func` is the function, not `None`, so the wrapper is built and returned — the two-level path. * `@retry(times=5)` evaluates `retry(times=5)` with `func` left at its default `None`, which returns `functools.partial(retry, times=5)`. That partial is itself callable, so the `@` machinery calls it with the function, re-entering `retry` as `retry(plan_route, times=5)` and taking the first path. * `@retry()` behaves exactly like the options form with no options — which is why the dual form is strictly more permissive, never less. `functools.partial` is doing one narrow job here: it produces a callable that remembers the options and forwards them on the second call. Anything else with that property works; the point of the idiom is the re-entry, not the type. ### The same thing without partial ```python def retry(func=None, *, times=3): def decorator(f): @functools.wraps(f) def wrapper(*args, **kwargs): ... return wrapper return decorator if func is None else decorator(func) ``` This version defines the wrapper once and decides at the end whether to hand back the decorator or apply it immediately. Many reviewers prefer it because the three levels stay visible and there is no re-entrant call to reason about; the partial version is shorter and keeps a single definition of the signature. ### Why the bare `*` matters more than the trick does Making the options keyword-only is the load-bearing part. With `def retry(func=None, *, times=3)` the only thing that can ever arrive positionally is the function being decorated, so "is `func` `None`?" is an exact test rather than a guess. Drop the `*` and allow `@retry(3)`, and the positional slot now means two different things depending on the caller — at which point people reach for a `callable()` sniff on the first argument, which is where this pattern earns its bad reputation. That sniff is genuinely unsafe. Classes are callable, so an option whose value is an exception type, a class, a factory function, or any instance defining `__call__` passes `callable()` and is misidentified as the decoration target. `callable(ValueError)` is `True`. The failure is silent: the decorator quietly wraps the option instead of the function, and the wrapped name misbehaves at a call site far from the decoration. Making options keyword-only removes the ambiguity structurally instead of trying to detect it. ### The costs you are accepting The dual form obscures the signature for readers and for static analysis: the first parameter is either a function or absent, and the return type is either a decorator or a wrapped function. Type checkers need overloads to describe it precisely, and the branch is one more thing to test — both spellings need coverage, or one path rots. It also produces a subtly worse error when someone passes an option positionally by mistake, because the "function" branch will be taken with a non-callable. ### Testing the branch you did not write first Both spellings are separate code paths through one function, and a codebase that uses only the bare form will happily carry a broken options branch for a year. Two decorated functions in a test module - one written `@retry`, one written `@retry(times=5)` - cover the split for the cost of four lines, and they also pin the keyword-only signature: if someone later drops the `*`, the test that passes an option positionally starts wrapping the wrong object and fails loudly instead of silently. ### When to bother Supporting both spellings is worth it for a decorator used in hundreds of places, where the bare form is the overwhelmingly common one and the options are a rare escape hatch. For an internal decorator used in a dozen places, pick one spelling, document it, and let the missing-parentheses error teach the rule. The failure mode of choosing wrong is small in either direction; the failure mode of a `callable()` sniff is a bug that ships.

  • Why make the options keyword-only with a bare `*` in the signature?
    So the only thing that can arrive positionally is the function being decorated. That turns "which form was used?" into an exact test on `func is None` instead of a heuristic. Allow `@retry(3)` and the first positional slot means two different things depending on the call site, which forces a type sniff and the bugs that come with it.
  • Why is checking `callable(...)` on the first argument a fragile way to detect the bare form?
    Because far too many option values are callable. Classes are callable - `callable(ValueError)` is `True` - as are functions passed as hooks and instances defining `__call__`. The decorator then wraps the option instead of the function, silently, and the damage appears at a call site far from the decoration. Keyword-only options remove the ambiguity structurally.
  • What does the dual form cost you?
    Clarity and testability. The first parameter is either a function or absent and the return value is either a decorator or a wrapped function, so type checkers need overloads to describe it and readers need a moment to trace both paths. Both spellings also need test coverage or one branch rots unnoticed.

saying these in an interview costs you the question

  • Claims one object cannot serve both spellings
  • Takes the option positionally, making @retry(3) ambiguous
  • Sniffs callable() and misreads a callable option as the target
  • Returns the wrapper instead of a decorator in the options branch
  • Thinks @retry() with empty parentheses breaks the dual form
  • Forgets that the partial is called again with the function

context