Decorators That Take Arguments
In short: @retry(times=3) isn't a decorator — it's a call to a decorator
factory that Python evaluates first, and its result has to be an actual
decorator. That's three nested layers: the factory (takes settings, runs once
at decoration time), decorate (takes func, also runs once), and wrapper
(takes the call's arguments, runs on every call, reading the settings and
func back out through its cells). Drop the parentheses — @retry instead of
@retry() — and the layers slip by one: func lands in the settings
parameter, and you silently get a wrapper back where you expected a result. A
retry wrapper that swallows every failure and falls off the end returns None
quietly; it must re-raise the last error once every attempt has failed.

Configure a retry for a flaky source
@something above a def means func = something(func). Here something is
the whole expression after the @ — retry(times=3) — evaluated first, and
its result has to itself be a decorator. So retry isn't a decorator; it's a
function that builds one. Written without the @:
decorate = retry(times=3) # 1. the factory runs, returns a decorator
fetch_export = decorate(fetch_export) # 2. the decorator runs, returns a wrapper
fetch_export("feed-7") # 3. later: the wrapper runs, on every callThree calls, three nested closures:
wrapper reads func from decorate's cell and times from retry's cell —
the setting written on the @ line is still sitting in a cell every time the
wrapper runs.
The retry that forgot its parentheses
def retry(times):
def decorate(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorate
@retry
def fetch(source):
return f"rows from {source}"
print(type(fetch("s3")).__name__)That bug doesn't crash — it returns a function where you expected rows, and the
failure turns up stages later. Once a decorator takes settings, the @ line
always has parentheses, even when every setting has a default: @retry().
log = []
def retry(times):
log.append(f"factory({times})")
def decorate(func):
log.append("decorate")
def wrapper(*args, **kwargs):
log.append("call")
return func(*args, **kwargs)
return wrapper
return decorate
@retry(2)
def a(): return 1
@retry(5)
def b(): return 2
a(); a(); b()
print(len(log), log.count("call"))A retry that gives up quietly is worse than none
This code is broken. Run it, read the error, then fix it.
None passes silently into the next stage — Quarry would write an empty
report while telling everyone the run succeeded. A retry wrapper has exactly
two honest outcomes: return a real result, or raise the error it couldn't get
past.
One-liner: retry only what's worth retrying
The except clause above catches SourceDown, not Exception. That's
deliberate — a TypeError from a real bug in the stage fails identically on
every attempt, so retrying it just triples the wait before someone sees the
traceback. A real retry wrapper takes the exception types as a setting too:
@retry(times=3, on=(SourceDown, TimeoutError)) — you'll write that one in the
exercises.
Watching three layers collapse into one call site
Step through it. The factory and decorator frames open and close once, while
the module is being defined. After that, each call goes straight into
wrapper, which still reaches times and func through its cells:
Here is the same thing as a picture. Settings flow in from the top, and the function you actually call is the innermost layer:
And when you stack them, the application order is still bottom-up:
@log_calls # 3rd: wraps everything below
@retry(times=3) # 2nd: retry(times=3) runs, then its decorator wraps timed(...)
@timed # 1st: wraps the original
def fetch_export(source): ...Quick practice
Stacked decorators apply bottom-up, same as a single one: the layer nearest
the def wraps first and ends up innermost.
order = []
def log_calls(func):
def wrapper(*a, **kw):
order.append("log")
return func(*a, **kw)
return wrapper
def retry(times):
def decorate(func):
def wrapper(*a, **kw):
order.append("retry")
return func(*a, **kw)
return wrapper
return decorate
def timed(func):
def wrapper(*a, **kw):
order.append("timed")
return func(*a, **kw)
return wrapper
@log_calls
@retry(times=3)
@timed
def fetch_export(source):
order.append("fetch")
return source
fetch_export("x")
print(order)TypeErrorRun it and read which argument is missing. The decorator needs a setting — supply one.
class Flaky(Exception):
pass
def retry(times):
def decorate(func):
def wrapper(*a, **kw):
last = None
for attempt in range(times):
try:
return func(*a, **kw)
except Flaky as err:
last = err
raise last
return wrapper
return decorate
tries = [0]
@retry(times=3)
def fetch():
tries[0] += 1
if tries[0] < 2:
raise Flaky("down")
return "ok"
print(fetch(), tries[0])🎯 Your turn
Write retry(times), a decorator factory. Its wrapper calls the function up to
times times, catching SourceDown. It returns the first success, and if
every attempt fails it re-raises the last error. It also records how many
attempts the most recent call made, as wrapper.attempts.
Then write retry_outcome(times, failures). It wraps make_flaky(failures)
(provided — a fetcher that fails its first failures calls) with
retry(times), calls it once with "feed", and returns
{"result": ..., "attempts": ...} — the fetched text, or "gave up" if
SourceDown escaped:
retry_outcome(3, 2)→{"result": "rows from feed", "attempts": 3}retry_outcome(2, 5)→{"result": "gave up", "attempts": 2}
Hint: three layers — retry(times) returns decorate(func) returns
wrapper(*args, **kwargs). Reset wrapper.attempts = 0 at the start of each
call.
Press ▶ Run, then ✓ Check. ✅
