Skip to content

2. Decorators

Intermediate · 9 min read

A decorator wraps a function to add behaviour — timing, logging, retries, caching — without changing the function's own code. You've already seen them: @app.route, @pytest.fixture, @tool.

2.1 Functions are objects

def shout(text):
    return text.upper()

speak = shout                  # functions can be stored in variables…
print(speak("hi"))             # → HI

def apply(fn, value):          # …and passed to other functions
    return fn(value)

print(apply(len, "genai"))     # → 5

2.2 Closures

def make_multiplier(factor):
    def multiply(x):           # inner function "remembers" factor
        return x * factor
    return multiply

double = make_multiplier(2)
print(double(21))              # → 42

2.3 Your first decorator

import functools
import time

def timed(fn):
    @functools.wraps(fn)                       # keep the original name and docstring
    def wrapper(*args, **kwargs):              # accept any arguments…
        start = time.perf_counter()
        result = fn(*args, **kwargs)           # …call the real function…
        ms = (time.perf_counter() - start) * 1000
        print(f"{fn.__name__} took {ms:.0f} ms")
        return result                          # …and pass its result through
    return wrapper

@timed                                         # same as: slow_add = timed(slow_add)
def slow_add(a, b):
    time.sleep(0.05)
    return a + b

print(slow_add(2, 3))                          # prints the timing line, then → 5
print(slow_add.__name__)                       # → slow_add   (thanks to functools.wraps)

2.4 Decorators with arguments: a retry decorator

import functools
import time

def retry(times=3, delay=0.01, exceptions=(Exception,)):
    """Retry the function up to `times` attempts when it raises one of `exceptions`."""
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return fn(*args, **kwargs)
                except exceptions as err:
                    if attempt == times:
                        raise                          # out of attempts: let the error through
                    print(f"attempt {attempt} failed: {err} — retrying")
                    time.sleep(delay * 2 ** (attempt - 1))   # exponential back-off
        return wrapper
    return decorator

calls = {"n": 0}

@retry(times=3, exceptions=(ConnectionError,))
def flaky_llm_call():
    calls["n"] += 1
    if calls["n"] < 3:                                 # fail twice, then succeed
        raise ConnectionError("rate limited")
    return "answer"

print(flaky_llm_call())                                # two retry messages, then → answer

2.5 Built-in decorators you'll meet

import functools

@functools.lru_cache(maxsize=128)       # remember results for repeated inputs
def embed(text):
    print("computing…")
    return len(text)                    # stand-in for an expensive embedding call

embed("rag"); embed("rag")              # "computing…" prints only once
print(embed.cache_info().hits)          # → 1

@property, @staticmethod and @classmethod (for classes) and @dataclass are decorators too.

Why it matters for GenAI

Retrying on rate limits, timing every LLM call, and caching embeddings are all one-line decorators once you've written them — and frameworks register tools and routes with decorators.

Practice

  • Write a @log_calls decorator that prints the function name and its arguments before calling it.
Answer
import functools

def log_calls(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"calling {fn.__name__} args={args} kwargs={kwargs}")
        return fn(*args, **kwargs)
    return wrapper

@log_calls
def greet(name, punct="!"):
    return f"Hi {name}{punct}"

print(greet("Priya", punct="."))   # → Hi Priya.