Free — no signup required

Decorators: Wrapping Behavior

2 min read

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:

  1. retry(...) — The decorator factory. It accepts configuration parameters and returns a decorator. This is what you call with @retry(max_attempts=3).
  2. decorator(func) — The actual decorator. It receives the original function and returns the wrapper.
  3. wrapper(*args, **kwargs) — The replacement function. This is what gets called every time you call deploy_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.

Interview Tip

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.

This is one of 18 chapters

Every chapter is free — Kubernetes, Terraform, SRE, distributed systems, and more — with fast daily review built in.

Sign up free