Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Closures & Decorators  ›  Lesson

Decorators That Take Arguments

Decorators with Arguments 24 minDecorator factories: settings, then the function, then the call
You're building a piece ofQuarry — the middleware ring
This piece — retry_outcome(): Gives a flaky source a set number of second chances, configured where the stage is declared.
Scenario A partner's export server drops one connection in twenty, and Quarry must ride out a blip without hiding a real outage.
Your task
Write retry(times), a decorator factory whose wrapper calls the function up to `times` times, catching SourceDown, returns the first success, re-raises the last error if every attempt fails, and records the latest call's attempt count as wrapper.attempts. Then write retry_outcome(times, failures), which wraps the provided make_flaky(failures), calls it with "feed" and returns {"result", "attempts"} (result is "gave up" if SourceDown escaped). Example: retry_outcome(3, 2) → {"result": "rows from feed", "attempts": 3}.

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.

Three nested boxes — retry(times=3) the factory, decorate(func) the decorator, wrapper(*args) — the outer two run once at @ time, the innermost runs on every call and reads times and func back through its cells

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 call

Three calls, three nested closures:

python

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

What does this print?
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().

What does this print?
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

broken — fix it

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:

step through it
1class SourceDown(Exception):
2 pass
3 
4def retry(times):
5 def decorate(func):
6 def wrapper(*args, **kwargs):
7 attempts = 0
8 while True:
9 attempts += 1
10 try:
11 return func(*args, **kwargs)
12 except SourceDown:
13 if attempts == times:
14 raise
15 return wrapper
16 return decorate
17 
18state = {"failures_left": 1}
19 
20@retry(3)
21def fetch(source):
22 if state["failures_left"] > 0:
23 state["failures_left"] -= 1
24 raise SourceDown(f"{source} timed out")
25 return f"rows from {source}"
26 
27result = fetch("export-server")
28print(result)

Here is the same thing as a picture. Settings flow in from the top, and the function you actually call is the innermost layer:

Drawing diagram…

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.

What does this print?
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)
broken — fix itTypeError

Run it and read which argument is missing. The decorator needs a setting — supply one.

What does this print?
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. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upRun it n times

Write a decorator factory repeat(n) whose wrapper calls the function n times and returns a list of the results. Then repeat_word(n, word) returns repeat(n)(echo)(word), where echo(word) returns word.

decoratorsdecorator-factory
DrillA configurable label

Write with_prefix(prefix), a decorator factory whose wrapper returns prefix + ": " + result. Use it with @with_prefix("WARN") on a function warn(msg) that returns msg.strip(). warn_line(msg) returns warn(msg).

decoratorsdecorator-factory
BuildClamp a stage's output

Write clamped(low, high), a decorator factory that forces a function's numeric result into [low, high]. Then clamp_reading(low, high, value) returns clamped(low, high)(calibrate)(value), where calibrate(v) returns v * 1.5.

decoratorsdecorator-factory
BossRetry only what is worth retrying

Write retry(times, on): retry up to times attempts, but only for exceptions whose class is in the tuple on; anything else escapes immediately. retry_log(errors, times) wraps a provided stage that raises the named exceptions in order ("SourceDown" or "ValueError") and then returns "ok", retrying only SourceDown. Return {"result": ..., "attempts": ...}, where result is "ok" or the escaping exception's class name.

decoratorsdecorator-factoryexceptions
CapstoneRetry with growing waits

Write retry(times, base, factor, sleep): between failed attempts, the wrapper calls sleep(wait), starting at base and multiplying by factor each time (no sleep after the final attempt). Retry only SourceDown. backoff_waits(times, base, factor, failures) uses a recording sleep (it appends each wait to a list), wraps make_flaky(failures), calls it once, and returns {"result": text or "gave up", "waits": [...]}.

decoratorsdecorator-factoryclosures
ExplainWhy three functions, not two?

@timed needs two nested functions, but @retry(times=3) needs three. Explain why, saying what each layer receives, when each one runs, and what would go wrong if you wrote @retry without the parentheses.

retry_outcome.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
retry_outcome(times, failures) → dictRetry a fetcher that fails its first `failures` calls, at most `times` attempts; report the result and the attempts used.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.