ategy using modern Python practices.
Step 1: Foundation with Signature Preservation
Every decorator must preserve the original function's metadata. Without this, debuggers, profilers, and type checkers lose context.
import functools
import time
from typing import Callable, Any
def track_execution(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed = time.perf_counter() - start
print(f"[{func.__name__}] completed in {elapsed:.4f}s")
return wrapper
Architecture Rationale:
*args, **kwargs ensures the decorator accepts any callable signature without hardcoding parameters.
functools.wraps copies __name__, __doc__, __module__, and __annotations__ to the wrapper, preserving introspection capabilities.
try/finally guarantees timing metrics are recorded even when the wrapped function raises an exception.
Step 2: Parameterized Decorators for Configuration
Hardcoded behavior limits reusability. A factory pattern allows decorators to accept configuration at import time.
def retry_on_failure(max_attempts: int = 3, delay: float = 1.0) -> Callable:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as exc:
if attempt == max_attempts - 1:
raise
time.sleep(delay)
raise RuntimeError("Retry logic exhausted")
return wrapper
return decorator
Architecture Rationale:
- Three-tier nesting (
retry_on_failure -> decorator -> wrapper) separates configuration from execution.
- The outer function captures
max_attempts and delay in a closure, making them available to the inner wrapper without polluting the global namespace.
- Explicit re-raising on the final attempt preserves the original exception stack trace for debugging.
Step 3: Class-Based Decorators for Stateful Operations
Closures struggle with mutable state across multiple invocations. Class-based decorators provide explicit state management and thread-safe initialization.
import threading
from typing import Any, Dict
class RateLimiter:
def __init__(self, max_calls: int = 10, window_seconds: float = 60.0) -> None:
self.max_calls = max_calls
self.window_seconds = window_seconds
self._call_history: Dict[str, list] = {}
self._lock = threading.Lock()
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
caller_id = f"{func.__module__}.{func.__name__}"
current_time = time.perf_counter()
with self._lock:
if caller_id not in self._call_history:
self._call_history[caller_id] = []
# Prune old timestamps
self._call_history[caller_id] = [
t for t in self._call_history[caller_id]
if current_time - t < self.window_seconds
]
if len(self._call_history[caller_id]) >= self.max_calls:
raise RuntimeError(f"Rate limit exceeded for {caller_id}")
self._call_history[caller_id].append(current_time)
return func(*args, **kwargs)
return wrapper
Architecture Rationale:
__call__ makes the class instance behave like a function, satisfying Python's decorator protocol.
threading.Lock prevents race conditions when multiple threads update call history simultaneously.
- State is isolated per decorator instance, allowing different rate limits for different endpoints without cross-contamination.
Pitfall Guide
Explanation: Omitting functools.wraps causes the wrapper to replace the original function's __name__, __doc__, and type hints. Debuggers show incorrect stack traces, and static analyzers fail to resolve signatures.
Fix: Always apply @functools.wraps(func) to the inner wrapper function. Verify with print(func.__name__) after decoration.
2. Signature Rigidity
Explanation: Hardcoding parameters like def wrapper(a, b): breaks when the decorator is applied to functions with different signatures. This forces developers to write multiple decorator variants.
Fix: Use *args, **kwargs universally. If type safety is required, leverage typing.ParamSpec and typing.Concatenate for precise signature propagation.
3. Import-Time Execution Misconception
Explanation: Developers assume decorators run when the function is called. In reality, @decorator executes immediately when the module loads. Database connections, file handles, or heavy computations inside decorators will block application startup.
Fix: Defer expensive operations to the wrapper body. Use lazy initialization or configuration objects that resolve at runtime.
4. State Leakage in Closures
Explanation: Mutable default arguments or shared variables in closures persist across function calls. A counter or cache defined in the outer decorator scope will accumulate state globally, causing unpredictable behavior.
Fix: Use class-based decorators for stateful logic, or initialize fresh state inside the wrapper. Avoid mutable defaults like def wrapper(cache=[]).
5. Decorator Stacking Order Confusion
Explanation: Stacking decorators applies them bottom-to-top. @retry over @log means retries happen before logging, while @log over @retry logs each attempt. Reversing order changes observability and error handling semantics.
Fix: Document stack order explicitly. Write integration tests that verify the exact execution sequence. Prefer explicit composition over implicit stacking when behavior is critical.
6. Thread-Safety in Singletons and Caches
Explanation: Implementing singletons or memoization with plain dictionaries causes race conditions during concurrent initialization. Two threads may simultaneously check if key not in cache, both compute the value, and overwrite each other.
Fix: Use threading.Lock around cache mutations, or leverage functools.lru_cache which handles thread safety internally. For custom implementations, double-checked locking or concurrent.futures patterns are required.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple logging or timing | Standard function decorator | Minimal boilerplate, preserves signatures, easy to test | Low (development time) |
| Configuration-driven behavior (e.g., retry counts, timeouts) | Parameterized decorator | Factory pattern separates config from logic, enables reuse | Medium (slightly more complex nesting) |
| Stateful operations (rate limiting, connection pooling) | Class-based decorator | Explicit state management, thread-safe, supports __init__ validation | Medium-High (requires lock management) |
| Framework integration (FastAPI, Celery, Flask) | Standard or parameterized | Aligns with framework expectations, supports dependency injection | Low (ecosystem standard) |
| High-frequency microservices | Built-in utilities (lru_cache, singledispatch) | Optimized C-level implementation, lower overhead than custom wrappers | Low (performance gain) |
Configuration Template
# decorators/production.py
import functools
import logging
import time
from typing import Callable, Any, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
logger = logging.getLogger(__name__)
def production_guard(func: Callable[P, R]) -> Callable[P, R]:
"""Production-ready decorator with logging, timing, and error isolation."""
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
logger.debug(f"Entering {func.__name__} with args={args}, kwargs={kwargs}")
try:
result = func(*args, **kwargs)
logger.info(f"{func.__name__} succeeded in {time.perf_counter() - start:.4f}s")
return result
except Exception as exc:
logger.error(f"{func.__name__} failed after {time.perf_counter() - start:.4f}s: {exc}")
raise
finally:
logger.debug(f"Exiting {func.__name__}")
return wrapper
Quick Start Guide
- Create a dedicated module: Place all decorators in
decorators/ or utils/decorators.py to avoid circular dependencies and centralize maintenance.
- Apply the template: Copy the
production_guard template into your project. Adjust logging levels and error handling to match your observability stack.
- Decorate target functions: Apply
@production_guard to entry points like API handlers, background task functions, or database query methods.
- Validate metadata: Run
print(your_function.__name__) and inspect.signature(your_function) to confirm functools.wraps preserved the original signature.
- Test stacking order: Apply multiple decorators and write a test that asserts the exact execution sequence. Verify that configuration parameters propagate correctly through parameterized decorators.