Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 66 of 899 min

Decorators: wrapping a function with retry, timing or a cache

Functions are values

You met this in module 3 when you passed a function to sorted as key=. A function can be stored in a variable, put in a list, passed as an argument, and returned from another function. A decorator uses all of that at once.

python
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

def greet(name):
    return f"hello {name}"

greet = shout(greet)
print(greet("asha"))          # HELLO ASHA

shout receives greet, builds a new function wrapper that calls greet and changes the result, and returns wrapper. The last line replaces the name greet with the wrapper. The @ syntax is that last line moved to the top:

python
@shout
def greet(name):
    return f"hello {name}"

Identical meaning. @shout above a def means "after defining this, pass it through shout and bind the result to the same name". That is all the syntax does.

*args, **kwargs in the wrapper means it accepts whatever the original accepted and passes it straight through. wrapper closes over func — it remembers which function it wraps — which is the closure idea from the scope lesson doing real work.

@wraps

python
print(greet.__name__)     # wrapper

The replacement has its own name and docstring, so tracebacks, logs and help() now say wrapper for every decorated function in the program. Fix it with functools.wraps, which copies the original's metadata onto the wrapper:

python
from functools import wraps

def shout(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

Always. A decorator without @wraps is a small lie in every traceback.

A retry decorator

Module 6 wrote a retry loop inline. As a decorator, it applies to any function by adding one line:

python
import random, time
from functools import wraps

def retry(attempts=3, base=1.0, on=(ConnectionError,)):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(attempts):
                try:
                    return func(*args, **kwargs)
                except on as e:
                    if i == attempts - 1:
                        raise
                    delay = base * 2 ** i + random.uniform(0, 1)
                    log.warning("%s failed (%s); retry in %.1fs", func.__name__, e, delay)
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(attempts=4, on=(requests.ConnectionError, requests.ConnectTimeout))
def fetch(url):
    return session.get(url, timeout=(5, 30))

Three layers, because the decorator takes arguments: retry(...) returns decorator, which receives the function and returns wrapper. Read it from the inside out once and it stops being confusing.

Note on=. The lesson on retries still applies: only retry exceptions that mean nothing happened. A decorator that retries on Exception will retry a read timeout on a paid POST.

Timing

python
def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            log.info("%s took %.2fs", func.__name__, time.perf_counter() - start)
    return wrapper

The finally makes the timing log appear even when the function raises, so a slow failure is visible as slow.

Caching, and the model-call question

python
from functools import lru_cache

@lru_cache(maxsize=1000)
def embed(text: str) -> tuple[float, ...]:
    return tuple(client.embeddings.create(input=text, model=m).data[0].embedding)

lru_cache keeps a dict from arguments to result. The second call with the same text returns instantly and costs nothing. For embeddings — deterministic, expensive, often repeated — this is exactly right.

Two constraints follow from "a dict keyed on the arguments". The arguments must be hashable: strings, numbers, tuples yes; lists and dicts no, with a TypeError that names the type. Convert a list of messages to a tuple of tuples, or key on a string. And the cache lives in memory for the life of the process, so maxsize=None on a function called with a million distinct inputs is a slow memory leak. Set a size, or use a disk cache — module 9 builds one on sqlite3.

Now the question. Should a chat completion be cached? At temperature 0 the same prompt gives nearly the same answer, so caching saves money during development when you re-run the same script twenty times. But a cached call is a call that will never return a different answer, and "the model keeps saying the same thing" is a confusing bug to chase when the cause is a decorator on a function three files away. Cache embeddings freely. Cache completions deliberately, with a way to turn it off, and never in a path where fresh output is the point.

cache_info() on a cached function reports hits and misses; cache_clear() empties it. Both are useful in tests.

Decorators you already use

@dataclass, @property, @classmethod, @contextmanager, @pytest.fixture, and the route decorators in web frameworks are all the same mechanism: a function that receives your function or class and returns something in its place. Knowing that, you can read what each one does by reading its source.

Order matters

python
@timed
@retry(attempts=3)
def fetch(url): ...

Decorators apply bottom-up: retry wraps fetch, then timed wraps the result. So the timing covers all attempts. Swap them and each attempt is timed separately. Neither is wrong; know which you asked for.

Try this now

Write retry and timed, stack them on a function that calls Ollama, and read the log with both orders. Then put lru_cache on an embedding function, call it twice with the same text, and check cache_info().

The one thing to keep

A decorator is a function that takes a function and returns a replacement; @wraps keeps the original's name, and lru_cache on a model call returns the same answer forever for the same arguments, which is a saving or a bug depending on whether you wanted fresh output.

Before you move on

A developer puts `@functools.lru_cache(maxsize=None)` on `ask(prompt: str)` to avoid paying twice for identical prompts. Later they change it to accept `ask(messages: list)` and every call raises `TypeError: unhashable type: 'list'`. What is the cache doing that a list breaks?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly