Functions That Modify Functions
Think of a decorator like a phone case. The phone (your function) does its job. The case (the decorator) adds behavior — protection, a kickstand, a battery pack — without modifying the phone itself. You can swap cases without touching the phone.
A decorator is a function that takes another function as input and returns a new function that wraps it. The @ syntax is pure syntactic sugar:
@retry
def upload_file(data):
pass
# This is exactly equivalent to:
def upload_file(data):
pass
upload_file = retry(upload_file)
Building a Retry Decorator
Retry logic is one of the most common Ops patterns. Any function that calls an external API, database, or service should retry on transient failures. Without decorators, you'd copy-paste the retry loop into every function. With a decorator, you write it once:
import time
import functools
import logging
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
"""
Decorator factory: retries the wrapped function on specified exceptions.
Usage:
@retry(max_attempts=5, delay=2.0, exceptions=(ConnectionError,))
def call_api():
...
"""
def decorator(func):
@functools.wraps(func) # Preserves the original function's name and docstring
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
logging.warning(
f"[{func.__name__}] Attempt {attempt}/{max_attempts} failed: {e}"
)
if attempt < max_attempts:
time.sleep(delay)
raise last_exception # All attempts exhausted
return wrapper
return decorator
# Usage
@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError,))
def deploy_to_server(host: str) -> bool:
# Simulates a flaky network call
import random
if random.random() < 0.7:
raise ConnectionError(f"Cannot reach {host}")
return True
The Three Layers Explained
The retry decorator above has three nested functions, which confuses many developers:
retry(...)— The decorator factory. It accepts configuration parameters and returns a decorator. This is what you call with@retry(max_attempts=3).decorator(func)— The actual decorator. It receives the original function and returns the wrapper.wrapper(*args, **kwargs)— The replacement function. This is what gets called every time you calldeploy_to_server(...).
functools.wraps(func) is critical. Without it, deploy_to_server.__name__ would return "wrapper" instead of "deploy_to_server", breaking logging, debugging, and documentation tools.
Interviewers often ask: "What does functools.wraps do and why does it matter?" The answer: without it, the wrapper function replaces the original's metadata (__name__, __doc__, __module__). This breaks help(), logging that uses func.__name__, and introspection tools. It's a one-line fix that signals you write production-quality decorators, not just toy examples.
Key Point: Decorators implement the "Open/Closed Principle" — your functions are open for extension (via decorators) but closed for modification. A well-designed decorator library (retry, rate-limit, cache, authenticate) lets you compose complex behavior from simple building blocks without touching the underlying business logic.