Decoradores con argumentos: la triple anidación explicada

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.

118

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio