Tengo 99 problemas pero la configuración de endpoints no es uno de ellos

February 16, 2026

Tienes una aplicación FastAPI. Un puñado de endpoints. Algunos necesitan autenticación, otros necesitan logging. Recurres a las herramientas estándar: decoradores y Depends().

@log_requests
@require_login
async def get_user_profile(
    user_id: int,
    current_user: User = Depends(get_current_user),
) -> UserProfileResponse:

    # Verificación de permisos inline, duplicada en cada endpoint
    if not current_user.has_permission("view_profiles"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

    user = await user_service.get_profile(user_id)
    return UserProfileResponse(user=user)

Entonces los requerimientos cambian. Tu API necesita desplegarse en múltiples regiones. Cada región tiene reglas de negocio diferentes:

  • Región A necesita autenticación completa y rate limiting en endpoints de administración.
  • Región B es un despliegue interno — no necesita autenticación, pero el logging es obligatorio.
  • Región C necesita autenticación y rate limiting en absolutamente todos los endpoints.

Esto tiene varios problemas

La solución ingenua

El instinto es recurrir a variables de entorno:

import os

@log_requests
async def get_user_profile(
    user_id: int,
    current_user: User = Depends(get_current_user),
) -> UserProfileResponse:

    if os.environ.get("REQUIRE_AUTH") == "true":
        if not current_user.is_authenticated:
            raise HTTPException(status_code=401, detail="Auth required")

    if os.environ.get("CHECK_PROFILE_PERMISSIONS") == "true":
        if not current_user.has_permission("view_profiles"):
            raise HTTPException(status_code=403, detail="Insufficient permissions")

    user = await user_service.get_profile(user_id)
    return UserProfileResponse(user=user)

Tres regiones, dos verificaciones. Manejable. Pero más regiones significa más verificaciones, más endpoints, y cada combinación podría necesitar un conjunto diferente de pre-checks en un orden diferente. Tu entorno empieza a verse así:

REQUIRE_AUTH=true
CHECK_PROFILE_PERMISSIONS=true
CHECK_UPLOAD_RATE_LIMIT=true
CHECK_ADMIN_PERMISSIONS=true
CHECK_SETTINGS_RATE_LIMIT=true
CHECK_BILLING_VERIFICATION=true
ENABLE_REQUEST_LOGGING=true
ENABLE_AUDIT_TRAIL=true
# ... 30 más de estos

Y cada endpoint acumula bloques condicionales:

async def get_user_profile(
    user_id: int,
    current_user: User = Depends(get_current_user),
) -> UserProfileResponse:

    if os.environ.get("REQUIRE_AUTH") == "true":
        if not current_user.is_authenticated:
            raise HTTPException(status_code=401, detail="Auth required")

    if os.environ.get("CHECK_PROFILE_PERMISSIONS") == "true":
        if not current_user.has_permission("view_profiles"):
            raise HTTPException(status_code=403, detail="Insufficient permissions")

    if os.environ.get("CHECK_PROFILE_RATE_LIMIT") == "true":
        await check_rate_limit(current_user)

    if os.environ.get("ENABLE_AUDIT_TRAIL") == "true":
        await log_audit_event("view_profile", user_id, current_user)

    user = await user_service.get_profile(user_id)
    return UserProfileResponse(user=user)

Los problemas se acumulan:

  1. Repetición de código: Los mismos bloques condicionales se copian en cada endpoint que los necesita.
  2. Cambios de código por nueva región: Agregar una región con una combinación diferente de verificaciones implica revisar cada endpoint.
  3. Overhead en tiempo de ejecución: Cada request evalúa condicionales para verificaciones que nunca se van a ejecutar en ese despliegue. Las ramas if son código muerto que la aplicación paga en cada request.
  4. Sin control de orden: ¿Qué pasa si la Región C necesita rate limiting antes de la autenticación, pero la Región A lo necesita después? El orden está hardcodeado.
  5. Explosión de la matriz de testing: Cada combinación de variables de entorno es un escenario de prueba.

La solución idiomática

FastAPI tiene Depends() exactamente para este tipo de lógica transversal. La idea: extraer cada verificación en una función de dependencia que lee una variable de entorno para decidir si debe ejecutarse.

import os
from fastapi import Depends, HTTPException


async def require_login_if_enabled(
    current_user: User = Depends(get_current_user),
):
    if os.environ.get("REQUIRE_AUTH") != "true":
        return
    if not current_user.is_authenticated:
        raise HTTPException(status_code=401, detail="Auth required")


async def check_permissions_if_enabled(
    current_user: User = Depends(get_current_user),
):
    if os.environ.get("CHECK_PERMISSIONS") != "true":
        return
    if not current_user.has_permission("admin"):
        raise HTTPException(status_code=403, detail="Insufficient permissions")

Luego cada endpoint declara las verificaciones que podría necesitar:

@app.get("/admin/users")
async def get_users(
    _auth=Depends(require_login_if_enabled),
    _perms=Depends(check_permissions_if_enabled),
    _rate=Depends(check_rate_limit_if_enabled),
):
    ...


@app.get("/users/{user_id}")
async def get_user_profile(
    user_id: int,
    _auth=Depends(require_login_if_enabled),
):
    ...

Más limpio que bloques if crudos, pero tiene sus propios problemas:

  1. Las variables de entorno siguen ahí. Solo las moviste dentro de las funciones de dependencia. Sigues necesitando REQUIRE_AUTH, CHECK_PERMISSIONS, CHECK_RATE_LIMIT, etc., y cada región sigue necesitando su propio conjunto.
  2. Las dependencias se resuelven incluso cuando están deshabilitadas. Mira require_login_if_enabled: declara Depends(get_current_user). Incluso cuando REQUIRE_AUTH es "false", FastAPI igual resuelve get_current_user en cada request. Si esa dependencia consulta una base de datos o llama a un servicio externo de autenticación, estás pagando por ello en cada request para nada.
  3. Cada endpoint debe listar todas las verificaciones posibles. Un endpoint que necesita verificación de permisos en la Región C pero no en la Región A igual necesita _perms=Depends(check_permissions_if_enabled) en su firma. De lo contrario, no se puede habilitar después sin un cambio de código.
  4. La configuración por endpoint y por región es imposible. Depends() se declara estáticamente en la firma de la función. No puedes decir “este endpoint tiene rate limiting en la Región A pero no en la Región B” sin volver a variables de entorno dentro de la dependencia, que es donde empezamos.
  5. Agregar una verificación a un endpoint específico requiere un cambio de código. Alguien decide que la Región D necesita audit logging en /users/{user_id}. Tienes que ir a modificar la firma de ese endpoint.

El enfoque con Depends() centraliza la lógica de cada verificación, pero no resuelve el problema de configuración. La decisión de qué se ejecuta dónde sigue dispersa entre firmas de endpoints y variables de entorno.

Necesitas un enfoque completamente diferente.

¿Qué es la metaprogramación?

La metaprogramación es escribir código que manipula otro código en tiempo de ejecución. En lugar de llamar funciones directamente, modificas qué funciones existen, cómo están conectadas, o qué hacen.

En Python, las formas más comunes son:

  • Decoradores: Funciones que envuelven otras funciones, agregando comportamiento antes o después de que la original se ejecute.
  • Metaclases: Clases que controlan cómo se crean otras clases.
  • Mutación de atributos en tiempo de ejecución: Modificar directamente los atributos de objetos para cambiar su comportamiento después de que han sido creados.

Lo que estamos haciendo aquí es el tercer tipo. FastAPI registra las funciones de endpoint como objetos de Python con atributos mutables. Al iniciar, después de que todas las rutas están registradas, accedemos a esos objetos y reemplazamos las referencias a funciones — recableando lo que se ejecuta cuando llega un request. La aplicación “se reprograma a sí misma” basándose en un archivo de configuración antes de empezar a servir tráfico.

Si quieres una introducción más profunda a decoradores y metaclases, escribí sobre ellos en el artículo anterior.

El diseño

En lugar de dispersar verificaciones condicionales en cada endpoint, movemos la decisión de qué se ejecuta dónde a un archivo de configuración y ensamblamos los pipelines de los endpoints al iniciar.

La solución tiene dos tipos de funciones componibles:

  • Pre-checks: Funciones async que se ejecutan antes del endpoint. Actúan como compuertas — retornan silenciosamente (verificación pasó) o lanzan una HTTPException (verificación falló).
  • Wrappers: Decoradores que envuelven todo el pipeline. Manejan lógica transversal como logging o manejo de errores.

El pipeline para un endpoint dado se ve así:

wrapper (log_requests)
  -> pre-check global (require_login)
    -> pre-check por endpoint (check_permissions)
      -> función original del endpoint

Y se define completamente en un archivo de configuración TOML:

[base]
wrappers = ["log_requests"]
global_pre_checks = ["require_login"]

[pre_checks]
"/admin/users" = ["check_permissions"]
"/admin/settings" = ["check_permissions", "rate_limit"]

Sin variables de entorno. Sin condicionales en el código del endpoint. El archivo de configuración es la fuente de verdad de qué se ejecuta dónde.

Las funciones de pre-check

Un pre-check es una función async con la firma async def check(**kwargs) -> None. Recibe los keyword arguments resueltos del endpoint desde la inyección de dependencias de FastAPI (el cuerpo del request, parámetros de ruta, dependencias, etc.), y retorna silenciosamente o lanza una HTTPException.

Aquí hay un pre-check de verificación de login:

from fastapi import HTTPException, status


async def require_login(**kwargs) -> None:
    """Verify that the current user is authenticated."""
    current_user = None
    for value in kwargs.values():
        if hasattr(value, "is_authenticated"):
            current_user = value
            break

    if current_user is None:
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="require_login misconfiguration: endpoint has no "
                   "user dependency"
        )

    if not current_user.is_authenticated:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Authentication required"
        )

Observa cómo encuentra el objeto usuario desde **kwargs verificando atributos. Esto lo hace funcionar con cualquier endpoint que tenga una dependencia de usuario con un atributo is_authenticated, sin importar el modelo específico utilizado.

Si alguien agrega require_login a un endpoint que no tiene dependencia de usuario en config.toml, la función lanza un 500 con un mensaje claro de error de configuración, en lugar de saltarse la verificación silenciosamente.

El wrapper

Un wrapper es un decorador estándar de Python. Aquí hay un wrapper de logging de requests que envuelve todo el pipeline (pre-checks + endpoint):

import functools
import logging
import time

logger = logging.getLogger("api")


def log_requests(func):
    """Log request execution time and outcome."""
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        start = time.monotonic()
        try:
            result = await func(*args, **kwargs)
            elapsed = time.monotonic() - start
            logger.info(
                f"{func.__name__} completed in {elapsed:.3f}s"
            )
            return result
        except HTTPException as e:
            elapsed = time.monotonic() - start
            logger.warning(
                f"{func.__name__} failed with {e.status_code} "
                f"in {elapsed:.3f}s"
            )
            raise
        except Exception as e:
            elapsed = time.monotonic() - start
            logger.error(
                f"{func.__name__} error: {e} in {elapsed:.3f}s"
            )
            raise HTTPException(
                status_code=500,
                detail="Internal server error."
            )
    return wrapper

La diferencia clave entre un wrapper y un pre-check: un pre-check se ejecuta antes del endpoint y no necesita acceso a la función del endpoint. Un wrapper se ejecuta alrededor del endpoint y captura su resultado o excepciones. Por eso los wrappers siguen siendo decoradores mientras que los pre-checks son funciones async simples.

Cómo Starlette y FastAPI manejan los requests

Para entender por qué la mutación funciona, necesitamos ver qué pasa bajo el capó. FastAPI está construido sobre Starlette, que proporciona el enrutamiento, el manejo de request/response y la interfaz ASGI.

Cuando escribes:

@app.get("/users/{user_id}")
async def get_user_profile(user_id: int):
    ...

FastAPI hace lo siguiente en tiempo de registro:

  1. Crea un objeto APIRoute (la extensión de FastAPI del Route de Starlette).
  2. Almacena la función del endpoint en route.endpoint.
  3. Construye un objeto dependant que analiza la firma de la función — sus parámetros de ruta, parámetros de query, cuerpo del request, y dependencias Depends().
  4. Almacena una referencia a la función del endpoint en route.dependant.call.
  5. Crea una aplicación ASGI (route.app) que maneja el ciclo de vida completo del request.

Cuando llega un request:

  1. El router de Starlette itera sobre app.routes y busca la coincidencia de la ruta del request con un Route.
  2. Se llama la aplicación ASGI de la ruta coincidente.
  3. El handler de FastAPI resuelve todas las dependencias declaradas en dependant — parámetros de ruta, query params, body, funciones Depends().
  4. Los valores resueltos se pasan a dependant.call(**values) — la función real del endpoint.
  5. El valor de retorno se serializa en una respuesta HTTP.

El detalle crítico: dependant.call es solo una referencia a una función de Python almacenada en un objeto mutable. Los pasos 1-3 y 5 no les importa cuál función apunta call — solo les importa su firma (que preservamos con functools.wraps). Al reemplazar dependant.call con nuestra función de pipeline compuesto, insertamos pre-checks y wrappers en el paso 4 sin tocar nada más.

El enrutamiento de Starlette, la resolución de dependencias de FastAPI, la serialización de respuestas, la cadena de middleware y la generación del esquema OpenAPI siguen funcionando sin cambios. Solo estamos cambiando qué función se ejecuta en el último paso.

Cambiando el pipeline del endpoint

Con ese entendimiento, aquí está el núcleo. Al iniciar la aplicación, después de que todas las rutas están registradas, esta función lee el config TOML y recablea cada endpoint:

import functools
import tomllib
from pathlib import Path
from fastapi import FastAPI
from fastapi.routing import APIRoute


DECORATORS = {
    "log_requests": log_requests,
}

PRE_CHECKS = {
    "require_login": require_login,
    "check_permissions": check_permissions,
    "rate_limit": rate_limit,
}


def setup_pipeline(app: FastAPI):
    config_path = Path(__file__).parent.parent / "config.toml"
    with open(config_path, "rb") as f:
        config = tomllib.load(f)

    base_wrappers = config.get("base", {}).get("wrappers", [])
    global_pre_checks = config.get("base", {}).get("global_pre_checks", [])
    global_check_fns = [PRE_CHECKS[name] for name in global_pre_checks]
    endpoint_pre_checks = config.get("pre_checks", {})

    for route in app.routes:
        if not isinstance(route, APIRoute):
            continue

        checks = endpoint_pre_checks.get(route.path, [])
        endpoint_check_fns = [PRE_CHECKS[name] for name in checks]
        all_checks = global_check_fns + endpoint_check_fns
        original = route.endpoint

        @functools.wraps(original)
        async def composed(
            *args,
            _checks=all_checks,
            _orig=original,
            **kwargs,
        ):
            for check in _checks:
                await check(**kwargs)
            return await _orig(*args, **kwargs)

        wrapped = composed
        for wrapper_name in base_wrappers:
            wrapper_fn = DECORATORS[wrapper_name]
            wrapped = wrapper_fn(wrapped)

        route.endpoint = wrapped
        route.dependant.call = wrapped

Desglosemos qué está pasando.

Leyendo la configuración

La función lee config.toml usando el tomllib incorporado de Python (disponible desde Python 3.11, cero dependencias). Extrae tres cosas: qué wrappers aplicar globalmente, qué pre-checks ejecutar en todos los endpoints, y qué pre-checks ejecutar en endpoints específicos.

Iterando las rutas

FastAPI almacena todas las rutas registradas en app.routes. Iteramos sobre ellas, saltando cualquier cosa que no sea un APIRoute (como mount points o archivos estáticos).

Componiendo el pipeline

Para cada ruta, construimos una nueva función async que:

  1. Ejecuta todos los pre-checks globales en orden.
  2. Ejecuta todos los pre-checks por endpoint en orden.
  3. Llama al endpoint original.

Los argumentos por defecto _checks=all_checks, _orig=original son críticos. Sin ellos, la semántica de closures de Python haría que cada ruta referencie los valores de la última iteración del loop. Esta es la trampa clásica de closure-en-loop:

# Sin argumentos por defecto (roto):
funcs = []
for i in range(3):
    async def f():
        return i
    funcs.append(f)
# Las tres funciones retornan 2 (el último valor de i)

# Con argumentos por defecto (correcto):
funcs = []
for i in range(3):
    async def f(_i=i):
        return _i
    funcs.append(f)
# Las funciones retornan 0, 1, 2 respectivamente

Los wrappers se aplican como decoradores alrededor de la función compuesta. El primer wrapper en la lista de configuración se convierte en la capa más externa.

Código limpio no rendimiento horrible?

Casey Muratori presenta un argumento convincente de que los patrones de “código limpio” — polimorfismo, funciones pequeñas, capas de indirección — perjudican el rendimiento. Y generalmente tiene razón. Pero este es un caso raro donde limpiar el código también mejora el rendimiento.

Con todo el boilerplate condicional eliminado, el endpoint se convierte en pura lógica de negocio:

async def get_user_profile(
    user_id: int,
    current_user: User = Depends(get_current_user),
) -> UserProfileResponse:
    user = await user_service.get_profile(user_id)
    return UserProfileResponse(user=user)

Sin decoradores. Sin verificaciones inline. Sin condicionales. Sin boilerplate. El pipeline se ensambla al iniciar desde la configuración.

Y en api.py, después de que todas las rutas están registradas:

from .pipeline import setup_pipeline

# ... todas las definiciones @app.get, @app.post ...

setup_pipeline(app)

La razón por la que este no es el típico trade-off de “código limpio = código lento”: el enfoque con variables de entorno evalúa condicionales en cada request, incluso para verificaciones que nunca están activas en ese despliegue. Con pipelines dirigidos por configuración, las bifurcaciones ocurren una sola vez — al iniciar. Después de eso, el pipeline de cada endpoint es una cadena directa de llamadas a funciones sin overhead condicional.

Para un solo request, la diferencia es pequeña: unas pocas verificaciones if cuestan nanosegundos. Pero se acumula. A 10,000 requests por segundo en 30 endpoints, cada uno con 5 verificaciones condicionales, eso son 150,000 evaluaciones de bifurcación por segundo que no sirven para nada. Elimina esas bifurcaciones y las llamadas a funciones se convierten en una secuencia limpia que el predictor de bifurcaciones del CPU maneja trivialmente.

La ganancia más grande está al iniciar. La función setup_pipeline() valida toda la configuración cuando la aplicación arranca:

  • ¿Referencia un pre-check que no existe en el registro? KeyError inmediatamente.
  • ¿Error de tipeo en el nombre de un wrapper? La app no arranca.
  • ¿Archivo de configuración faltante? Crash al iniciar, no en el primer request que llega a esa ruta de código.

Este comportamiento fail-fast significa que los errores de configuración se detectan durante el despliegue, no en tráfico de producción. Combinado con un health check que verifica que la aplicación arrancó exitosamente, las configuraciones malas nunca llegan a los usuarios.

Agregar un nuevo check

Agregar un nuevo pre-check al sistema requiere tres pasos:

  1. Crear un módulo con una función async def my_check(**kwargs).
  2. Importarlo y agregarlo al registro PRE_CHECKS.
  3. Referenciarlo como "my_check" en config.toml.

Sin cambios a ningún endpoint. Sin nuevas variables de entorno. El nuevo check está inmediatamente disponible para la configuración de cualquier región.

Una imagen, todas las regiones

Aquí es donde todo se une. Construyes una sola imagen de contenedor con todo el código de endpoints, todas las funciones de pre-check, y todos los wrappers. El archivo config.toml es lo único que cambia entre despliegues.

En Kubernetes, montas un ConfigMap diferente. En Docker Compose, haces bind mount de un archivo diferente. En cualquier sistema de despliegue, cambias un archivo y la aplicación se ensambla diferente al iniciar.

Región A (seguridad completa, rate limiting en admin):

[base]
wrappers = ["log_requests"]
global_pre_checks = ["require_login"]

[pre_checks]
"/admin/users" = ["check_permissions"]
"/admin/settings" = ["check_permissions", "rate_limit"]
"/api/v1/upload" = ["rate_limit"]

Región B (interno, sin auth, solo logging):

[base]
wrappers = ["log_requests"]
global_pre_checks = []

Región C (auth + rate limiting en todos lados):

[base]
wrappers = ["log_requests"]
global_pre_checks = ["require_login", "rate_limit"]

[pre_checks]
"/admin/users" = ["check_permissions"]
"/admin/settings" = ["check_permissions"]

Mismo código, misma imagen, diferente comportamiento. El orden del array es el orden de ejecución, así que cada región controla no solo cuáles checks se ejecutan, sino en qué orden.

Sin cambios de código para incorporar una nueva región. Sin nuevas variables de entorno. Sin redespliegue del código de la aplicación. Solo un nuevo config.toml.

¿Por qué no dependencias a nivel de router o middleware?

Mostramos antes por qué Depends() en cada endpoint no resuelve el problema de configuración. Pero FastAPI y Starlette ofrecen otros mecanismos que vale la pena abordar:

  • Dependencias a nivel de router: Podrías agrupar endpoints en routers según qué verificaciones necesitan y adjuntar dependencias a cada router. Pero esto significa reestructurar toda tu API alrededor de combinaciones de verificaciones en lugar de lógica de dominio. Un refactor masivo, y agregar una nueva combinación de verificaciones sigue requiriendo un cambio de código.
  • Dependencias a nivel de app: Solo funciona para verificaciones globales. No resuelve la configuración por endpoint.
  • Middleware: El middleware se ejecuta en cada request, así que necesitas condicionales para hacer coincidencia de patrones de URL y decidir qué verificaciones aplicar — esencialmente reimplementando el router dentro del middleware. Vuelves a las variables de entorno para activar verificaciones por región, y cada request paga el costo de evaluar todos esos condicionales de URL incluso cuando ninguno coincide. Peor aún, el middleware opera sobre los objetos crudos Request y Response, sin acceso a los parámetros parseados de FastAPI ni a la inyección de dependencias.
  • Subclase personalizada de APIRoute: La alternativa incorporada más cercana, pero los pre-checks reciben un objeto crudo Request en lugar de kwargs parseados, perdiendo los beneficios de la inyección de dependencias de FastAPI.

La mutación de dependant.call es un trade-off pragmático: toca un solo atributo interno, pero preserva toda la maquinaria de FastAPI (DI, validación, documentación OpenAPI, middleware) y nos da control total dirigido por configuración.

Conclusiones

Empezamos con decoradores y Depends(), que funcionan bien para un solo despliegue. Probamos variables de entorno cuando llegó el multi-región, y vimos crecer la complejidad con cada nueva región y cada nueva verificación. Terminamos con un sistema donde la configuración dirige el comportamiento: una imagen, un código base, y un archivo TOML que ensambla el pipeline correcto para cada despliegue al iniciar.

En el artículo anterior, usamos metaclases para propagar logging a través de una jerarquía de clases. Aquí aplicamos el mismo principio — propagar comportamiento transversal a través de endpoints de API — pero dirigido por configuración en lugar de código.

La metaprogramación no siempre es la respuesta correcta. Agrega complejidad y puede hacer el debugging más difícil. Pero cuando la alternativa es mantener boilerplate condicional duplicado en docenas de endpoints para múltiples despliegues, una pequeña cantidad de metaprogramación controlada al iniciar puede ahorrar mucho mantenimiento continuo — y la validación fail-fast de la configuración significa que no estás sacrificando confiabilidad por conveniencia.

Tags: python, metaprogramación, FastAPI