Cuando escribes @mi_decorador sobre una función, Python hace exactamente una cosa: llama a mi_decorador pasándole la función como argumento. Simple. Pero cuando escribes @mi_decorador(3), las reglas cambian: Python primero evalúa mi_decorador(3) y luego usa el resultado de esa llamada como decorador. Eso implica que mi_decorador(3) tiene que devolver algo que se comporte como un decorador, es decir, un callable que acepte una función y devuelva otra. Ahí nace la necesidad del tercer nivel.
La mecánica es sencilla si separas los tres roles. El nivel exterior es una fábrica de decoradores: recibe los parámetros de configuración y no sabe nada de la función que se va a decorar. El nivel medio es el decorador real: recibe la función y la captura en un closure. El nivel interior es el wrapper: se ejecuta cada vez que el usuario llama a la función decorada.
La razón de este diseño no es arbitraria. Python evalúa @expresión como cualquier expresión: puede ser un nombre, un atributo, o una llamada. Si la llamada devuelve un decorador válido, el sistema funciona. Esto es consistente con el modelo general del lenguaje: los decoradores no son una sintaxis especial con reglas propias, son azúcar sobre func = expresion(func).
¿Cuándo necesitas esto? Siempre que la lógica del decorador dependa de un parámetro externo: número de reintentos, nivel de logging, un nombre de caché, permisos requeridos. Si el comportamiento es fijo para todas las funciones que decoras, un decorador sin argumentos basta. Si varía, necesitas la fábrica.
Lo que se rompe cuando no entiendes esto: escribes @con_reintento en lugar de @con_reintento(3) y Python pasa la función al nivel exterior, que espera un entero. El error de tipo puede ser confuso porque la excepción ocurre en el momento de la definición de la función, no en la llamada.
import time
import functools
from typing import Callable, TypeVar, Any
F = TypeVar("F", bound=Callable[..., Any])
def retry(max_attempts: int, delay: float = 0.0) -> Callable[[F], F]:
"""
Nivel exterior: fábrica de decoradores.
Recibe configuración; no sabe nada de la función destino todavía.
"""
if max_attempts < 1:
raise ValueError("max_attempts debe ser al menos 1")
def decorator(func: F) -> F:
"""
Nivel medio: decorador real.
Captura `func` en el closure; también captura `max_attempts` y `delay`
del scope exterior.
"""
@functools.wraps(func) # preserva __name__, __doc__, __annotations__
def wrapper(*args: Any, **kwargs: Any) -> Any:
"""
Nivel interior: lógica de ejecución.
Se invoca en cada llamada real a la función decorada.
"""
last_error: BaseException | None = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_error = exc
if attempt < max_attempts and delay > 0:
time.sleep(delay)
# Si llegamos aquí, todos los intentos fallaron
raise RuntimeError(
f"{func.__name__} falló tras {max_attempts} intentos"
) from last_error
return wrapper # type: ignore[return-value]
return decorator
# ── Uso ──────────────────────────────────────────────────────────────────
_call_count = 0
@retry(max_attempts=3, delay=0.05)
def fetch_data(url: str) -> str:
global _call_count
_call_count += 1
if _call_count < 3:
raise ConnectionError("timeout simulado")
return f"datos de {url}"
if __name__ == "__main__":
result = fetch_data("https://api.example.com/items")
print(result) # datos de https://api.example.com/items
print(_call_count) # 3 — falló dos veces, tuvo éxito en el tercero
Desglose del código
retry(max_attempts=3, delay=0.05) se evalúa antes de que Python vea fetch_data. Devuelve decorator, un objeto función. Luego Python ejecuta fetch_data = decorator(fetch_data), que a su vez devuelve wrapper. Al final, el nombre fetch_data apunta a wrapper.
Fíjate en el closure en cadena: wrapper tiene acceso a func (del scope de decorator) y a max_attempts/delay (del scope de retry). Esto es lo que hace útil la triple anidación: cada nivel captura exactamente lo que necesita, sin contaminar el espacio de nombres del otro.
functools.wraps(func) en el nivel interior es obligatorio en código de producción. Sin él, fetch_data.__name__ sería "wrapper" y fetch_data.__doc__ quedaría vacío. Herramientas de introspección, frameworks web y pytest se rompen silenciosamente cuando esto falta.
El raise ... from last_error al final del bucle preserva la cadena de excepciones. Si no usas from, pierdes el traceback original y el debugging se complica.
Alternativas cuando la anidación se vuelve incómoda
Con tres niveles anidados el código se puede leer de afuera hacia adentro, pero admitámoslo: no es evidente a primera vista. Hay dos alternativas que ayudan.
Clase como decorador: una clase con __init__ para los parámetros y __call__ para recibir la función. La estructura es más explícita, aunque mezcla estado y comportamiento de un modo que puede sentirse excesivo para casos simples.
functools.partial sobre un decorador de dos argumentos: si reescribes el decorador para que acepte la función como primer argumento y los parámetros como segundo, puedes aplicar partial para fijar los parámetros y obtener un decorador de un solo argumento.
import functools
from typing import Callable, Any
def _retry_impl(func: Callable[..., Any], max_attempts: int) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception:
if attempt == max_attempts:
raise
return wrapper
# partial fija `max_attempts`; el resultado acepta solo `func` → decorador válido
retry3 = functools.partial(_retry_impl, max_attempts=3)
@retry3
def unstable() -> str:
return "ok"
Esta versión con partial es más plana, pero sacrifica la capacidad de parametrizar en el sitio de uso (@retry3 siempre usa 3). La triple anidación clásica sigue siendo la solución más general cuando necesitas que el parámetro varíe función a función.
Errores que debes conocer
Error: usar el decorador sin llamarlo cuando espera argumentos, lo que pasa la función al nivel de fábrica en lugar del nivel de decorador.
# ❌ Wrong @retry # Python llama retry(fetch_data) → max_attempts recibe una función def fetch_data(url: str) -> str: ... # ✅ Right @retry(max_attempts=3) def fetch_data(url: str) -> str: ...
Aunque el error a veces puede ser un TypeError claro, en otros casos el decorador acepta callables y falla tarde, en el momento de la llamada real.
Error: omitir functools.wraps en el wrapper, perdiendo los metadatos de la función original.
# ❌ Wrong
def decorator(func):
def wrapper(*args, **kwargs): # wrapper no sabe que envuelve a func
return func(*args, **kwargs)
return wrapper
# ✅ Right
def decorator(func):
@functools.wraps(func) # copia __name__, __doc__, __wrapped__, etc.
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
functools.wraps copia también el atributo __wrapped__, que permite a las herramientas de introspección (y a ti en un debugger) llegar a la función original.
N° 118