A decorator factory: outer function takes parameters and returns the real decorator
To accept arguments you add one more level of nesting. The outermost function takes the decorator parameters and returns the actual decorator; that decorator takes the function and returns the wrapper; the wrapper takes the call arguments and contains the behavior. This is why the call site becomes @retry(times=3) with parentheses: retry(times=3) is evaluated first and produces the decorator that Python then applies to the function. Getting the level count right, and validating the parameters in the outermost scope so a bad configuration fails at import time rather than on the first call, is the practical skill here.
Three levels: parameters -> decorator(fn) -> wrapper(*args, **kwargs). Forget one level and you get either @retry applied to the function or a TypeError at decoration time.
Validate parameters in the outermost function so misconfiguration fails fast at import, not on the first invocation.
Retry specifically needs a bounded attempt count, a backoff with jitter, and a narrow exception tuple. Catching bare Exception hides real bugs and makes incident debugging miserable.
Trade-off: for anything non-trivial, a battle-tested library such as tenacity or backoff gives you jitter, circuit breaking, and async support for free. Hand-rolled retry decorators are a classic source of retry storms.
Common mistake: writing @retry instead of @retry() and then wondering why the function is never called with the parameters.
Version note: functools.wraps works the same at any nesting depth. For async targets you need a separate async def wrapper that awaits the coroutine function; a sync wrapper around a coroutine returns an un-awaited coroutine.
0-2 years experience
2-5 years experience
5-8 years experience
8+ years experience