How do you write a decorator that accepts its own arguments?
You add one more layer of nesting: a factory function that accepts the decorator's arguments and returns the actual decorator. The @syntax then calls the factory first, and the result decorates the function.
How to think about it
A plain decorator has the shape decorator(func) -> func. To let it take its own arguments, you add one more layer: a factory that receives the arguments and returns a decorator. So @retry(max_attempts=3) isn’t one step — it’s two. Python first calls retry(max_attempts=3), gets a decorator back, and then applies that to your function.
This is really a closures question wearing a decorator costume. What the interviewer is grading is whether you can keep the three levels straight and remember functools.wraps.
The three levels
retry(max_attempts, delay) ← factory (level 1), runs at decoration time
└── decorator(func) ← the actual decorator (level 2)
└── wrapper(*args, **kwargs) ← the replacement function (level 3), runs on every call
The factory runs once, when the @ line is reached. The wrapper runs every time the decorated function is called. Each inner function closes over the scope above it — the wrapper remembers func, the decorator remembers max_attempts and delay.
A worked example
import functools
import time
def retry(max_attempts=3, delay=0.0):
"""Factory: returns a decorator that retries on exception."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
print(f" attempt {attempt}/{max_attempts} failed: {exc}")
if delay > 0:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
@retry(max_attempts=3, delay=0.0) # = flaky_operation = retry(...)(flaky_operation)
def flaky_operation(succeed_on):
flaky_operation._calls = getattr(flaky_operation, "_calls", 0) + 1
if flaky_operation._calls < succeed_on:
raise ValueError(f"Not ready yet (call {flaky_operation._calls})")
return f"Success on call {flaky_operation._calls}!"
print("Result:", flaky_operation(succeed_on=3))
print("Function name:", flaky_operation.__name__) # preserved by functools.wraps
# Make the parentheses optional, so both @log and @log(prefix=...) work
def log(_func=None, *, prefix="LOG"):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{prefix}] calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
if _func is not None: # used as @log (no parens)
return decorator(_func)
return decorator # used as @log() or @log(prefix="DEBUG")
@log
def add(a, b):
return a + b
@log(prefix="DEBUG")
def mul(a, b):
return a * b
print(add(2, 3))
print(mul(4, 5))
attempt 1/3 failed: Not ready yet (call 1)
attempt 2/3 failed: Not ready yet (call 2)
Result: Success on call 3!
Function name: flaky_operation
[LOG] calling add
5
[DEBUG] calling mul
20
Watch the retries: the first two calls raise and are caught, the third succeeds, and flaky_operation.__name__ still reads flaky_operation thanks to wraps. The log factory then shows the polished trick — the _func=None guard lets the same decorator be used with or without parentheses.
The idea underneath
It’s closures all the way down. The factory call returns a decorator that closes over max_attempts and delay; that decorator returns a wrapper that closes over func. Three nested scopes, each capturing the one above it. See it that way and the pattern stops being fiddly and turns mechanical.