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.
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 ASHAshout 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:
@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
print(greet.__name__) # wrapperThe 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:
from functools import wraps
def shout(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapperAlways. 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:
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
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 wrapperThe 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
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
@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.