Atlas

WSGI vs ASGI + Middleware, Servidores y Troubleshooting de Web Apps

Cómo una app Python llega al mundo: la cadena cliente → reverse proxy → HTTP/WSGI/ASGI server → workers → app, las especificaciones de interfaz (WSGI/ASGI), el modelo “onion” de middleware, la configuración de cada capa, y cómo se diagnostica una web app en producción (requests lentos, performance, debugging).

Teoría

El panorama: de dónde viene un request

Una web app Python nunca habla TCP crudo con el navegador. Hay una cadena de capas, cada una con una responsabilidad:

cliente ──HTTP──▶ reverse proxy ──HTTP──▶ HTTP/app server ──callable──▶ workers ──▶ tu app
(browser)         (nginx/CDN)             (gunicorn/uvicorn)  (WSGI/ASGI)  (proceso)  (Flask/FastAPI)
   TLS, HTTP/2      TLS termination,        parsea HTTP,        WSGI/ASGI    tu código
                    static, buffering,      gestiona procesos   interface
                    load balance            worker

Entender A3 senior es entender qué hace cada caja, por qué existe, cómo se configura y qué falla en cada una. Los tres conceptos que la gente confunde: (1) reverse proxyHTTP serverWSGI server; (2) workerprocesothreadconexión; (3) interfaz (WSGI/ASGI, una spec) ≠ servidor (gunicorn/uvicorn, un programa) ≠ framework (Flask/FastAPI, tu app).

HTTP server vs WSGI server (la distinción central A3)

Esta es la pregunta que abre el tema en una entrevista senior. La respuesta corta: un HTTP server habla el protocolo HTTP por la red; un WSGI server traduce entre HTTP y el callable Python de tu app. En la práctica un servidor como gunicorn o uvicorn hace ambas cosas, pero conceptualmente son responsabilidades distintas.

Por qué la separación importa: el runserver de Django o flask run o uvicorn --reload traen su propio HTTP server de desarrollo — single-process, sin gestión robusta de workers, sin TLS de grado producción, a menudo con logging verboso y sin límites. Están explícitamente marcados “do not use in production”. En producción quieres: un app server (gunicorn/uvicorn) que gestione N workers y hable WSGI/ASGI con tu app, y normalmente un reverse proxy (nginx) por delante que haga TLS, sirva estáticos y absorba clientes lentos. La confusión clásica de junior: “puse debug=True y app.run() en prod” — eso es correr el HTTP server de desarrollo, sin concurrencia real y filtrando tracebacks.

Regla mnemónica: WSGI/ASGI es un contrato (PEP/spec), no un programa. “HTTP server” describe quién habla el protocolo de red; “WSGI/ASGI server” describe quién habla el contrato con tu app Python. gunicorn es las dos cosas a la vez; nginx es solo lo primero; Flask es solo la app al final de la cadena.

WSGI vs ASGI (las especificaciones)

WSGI y ASGI son especificaciones de interfaz (PEPs/specs, no librerías) entre un servidor y una aplicación. WSGI (PEP 3333) es síncrono: la app es un callable app(environ, start_response) y un request bloquea un worker de principio a fin. ASGI (spec mantenida por encode) es asíncrono: la app es async def app(scope, receive, send) y soporta protocolos connection-oriented (WebSocket, HTTP/2, SSE, long-lived streams) sobre un event loop. ASGI existe porque WSGI, por diseño (un callable sync request→response), no puede modelar conexiones concurrentes de larga vida ni concurrencia I/O-bound dentro de un worker.

Cuándo aplica cada uno:

Trade-off central: concurrencia I/O dentro del proceso (ASGI) vs simplicidad y aislamiento del modelo bloqueante (WSGI).

Regla operativa: un endpoint async def servido bajo WSGI no aporta nada — el servidor WSGI no tiene event loop; en el mejor caso el framework ejecuta la corrutina hasta completarla de forma bloqueante (o falla), perdiendo toda concurrencia. La ganancia async solo existe si el servidor es ASGI.

# ---- WSGI (PEP 3333): app = callable síncrono ----
def app(environ, start_response):
    # environ: dict con la request (método, path, headers, wsgi.input...)
    status = "200 OK"
    headers = [("Content-Type", "text/plain")]
    start_response(status, headers)      # se llama UNA vez
    return [b"hello wsgi"]                # iterable de bytes (body)

# Servido con: gunicorn module:app
# ---- ASGI (scope/receive/send): app = callable async ----
async def app(scope, receive, send):
    # scope: dict del "sobre" de la conexión (type: http|websocket|lifespan)
    assert scope["type"] == "http"
    await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [(b"content-type", b"text/plain")],
    })
    await send({"type": "http.response.body", "body": b"hello asgi"})

# Servido con: uvicorn module:app

Configuración de servidores HTTP y WSGI/ASGI

El patrón de producción es gunicorn como process manager + uvicorn como worker ASGI (o gunicorn con workers WSGI para Flask/Django). gunicorn gestiona el ciclo de vida de los procesos worker; cada worker corre tu app.

# gunicorn gestiona N procesos worker; cada worker corre un event loop uvicorn.
# Clase de worker moderna: uvicorn_worker.UvicornWorker (paquete `uvicorn-worker`).
# La clásica `uvicorn.workers.UvicornWorker` (dentro de uvicorn) está deprecada.
gunicorn app.main:app \
  --worker-class uvicorn_worker.UvicornWorker \
  --workers 4 \                       # ~= (2 * CORES) para I/O-bound; = CORES para CPU-bound
  --bind 0.0.0.0:8000 \
  --timeout 30 \                      # mata al worker si un request excede 30s
  --graceful-timeout 30 \             # margen para drenar in-flight al recargar/parar
  --keep-alive 5 \                    # segundos que mantiene viva una conexión ociosa
  --max-requests 1000 \               # recicla el worker tras N requests
  --max-requests-jitter 100 \         # +/- aleatorio para que no reciclen todos a la vez
  --access-logfile - --error-logfile - \  # logs a stdout/stderr (12-factor)
  --forwarded-allow-ips="*"           # confía en X-Forwarded-* del proxy (ver reverse proxy)

Parámetros que un senior justifica, no copia:

Alternativa: uvicorn app.main:app --workers N (spawnea su propio process manager). gunicorn+uvicorn se prefiere en prod por su gestión de workers más madura (graceful reload, max-requests, hooks on_starting/post_fork/worker_exit). Para HTTP/2 nativo o Trio, hypercorn es el servidor ASGI de referencia.

Worker types (gunicorn) y su elección

El “worker type” define cómo un worker sirve concurrencia. Elegir mal es el footgun más común del tema.

WorkerModeloConcurrencia por workerCuándo
sync (default)1 request por proceso1WSGI, CPU-bound o requests cortos y predecibles. El más robusto.
gthreadthread pool por procesoN threads (I/O-wait libera GIL)WSGI, I/O-bound moderado. Predecible.
gevent/eventletgreenlets (monkey-patch)miles (cooperativo)WSGI, muy alta concurrencia I/O. Cuidado con C-extensions bloqueantes.
uvicorn_worker.UvicornWorkerevent loop asynciomiles (async)ASGI (FastAPI/Starlette), WebSockets, I/O-bound alto.

Árbol de decisión senior:

  1. ¿La app es ASGI? → uvicorn workers, punto. Apuntar uvicorn workers a una app WSGI no funciona.
  2. ¿WSGI y CPU-bound?sync, workers ≈ CORES. Threads/greenlets no ayudan al trabajo CPU bajo el GIL; se escala con procesos.
  3. ¿WSGI e I/O-bound?gthread para predictibilidad, o gevent si necesitas conteos de conexión muy altos y has verificado que tu stack es greenlet-safe (sin C-libs bloqueantes, driver DB coopera).

En todos los casos: --max-requests con jitter, --timeout sizeado, y nunca poner uvicorn workers delante de una app WSGI ni esperar que greenlets aceleren código CPU-bound.

Worker ≠ conexión ≠ request. Un worker sync = 1 request a la vez. Un worker uvicorn = 1 event loop que multiplexa miles de conexiones/requests concurrentes mientras esperan I/O. Contar capacidad como “workers × concurrencia-por-worker” es clave para dimensionar.

Middleware: el modelo “onion”

Middleware maneja cross-cutting concerns (auth, logging, tracing, CORS, compresión, rate-limit) que deben correr en cada request sin ensuciar los handlers. El modelo es una cebolla: cada middleware envuelve al siguiente. En el camino de entrada, el más externo corre primero y llama al interno; en la salida, se desenrollan en orden inverso. Así un middleware ve la request antes del handler y la response después.

# ---- ASGI middleware "onion": envuelve la app, ve request Y response ----
class TimingMiddleware:
    def __init__(self, app):
        self.app = app                    # la "siguiente capa"

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)  # pass-through ws/lifespan
        start = time.perf_counter()

        async def send_wrapper(message):
            if message["type"] == "http.response.start":
                elapsed_ms = int((time.perf_counter() - start) * 1000)
                message["headers"].append(
                    (b"x-process-time-ms", str(elapsed_ms).encode())
                )
            await send(message)

        await self.app(scope, receive, send_wrapper)  # inbound → app → outbound

Dos sutilezas que separan al senior:

Reverse proxy

Un reverse proxy (nginx, HAProxy, Envoy, o un CDN/ALB) se pone delante del app server y recibe el tráfico de internet. No es opcional en una arquitectura seria; hace lo que gunicorn/uvicorn no deberían hacer:

upstream app {
    server 127.0.0.1:8000;              # gunicorn/uvicorn escuchando aquí
    # server 127.0.0.1:8001;            # más instancias → load balance
}

server {
    listen 443 ssl http2;
    server_name api.example.com;
    # ... ssl_certificate / ssl_certificate_key ...

    location /static/ { alias /srv/app/static/; }   # nginx sirve estáticos

    location / {
        proxy_pass http://app;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;   # http | https
        proxy_read_timeout 60s;            # debe ser >= timeout del app server
        # proxy_buffering on;  (default) → absorbe clientes/backends lentos
    }

    location /ws/ {                        # WebSockets requieren upgrade explícito
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;          # conexiones long-lived
    }
}

Consecuencia que muerde en producción: detrás de un proxy, el client.host que ve la app es la IP del proxy (127.0.0.1), no la del cliente real. La IP real viaja en X-Forwarded-For y el esquema en X-Forwarded-Proto. Hay que (1) configurar el proxy para setear esos headers, y (2) decirle al app server que confíe en ellos: en gunicorn --forwarded-allow-ips, en uvicorn --proxy-headers --forwarded-allow-ips, o el ProxyHeadersMiddleware de Starlette. Sin esto, logs con IP equivocada, rate-limit por IP roto, y redirects https→http porque la app cree que sirve HTTP. Confiar en X-Forwarded-For de una fuente no confiable es un vector de spoofing — solo confía cuando el proxy es tuyo y filtra el header entrante del cliente.

Routing

Routing es el mapeo de (método HTTP, path) → función handler. Ocurre en dos niveles y conviene no mezclarlos:

from fastapi import FastAPI, APIRouter

app = FastAPI()
users = APIRouter(prefix="/users", tags=["users"])

@users.get("/{user_id}")          # method + path pattern → handler
async def get_user(user_id: int): # path param tipado; framework valida y coacciona
    ...

app.include_router(users)          # composición modular de routers

Puntos senior sobre routing:

Troubleshooting: determinar requests de larga duración

El síntoma más común: “la app va lenta / se cuelga bajo carga”. El primer trabajo es localizar dónde se va el tiempo, midiendo, no adivinando.

  1. Instrumentar la latencia por request. Un middleware de timing (ver arriba) que loguea method path status duration_ms de cada request, más un header X-Process-Time. Con eso identificas los endpoints y percentiles lentos (mira p95/p99, no la media — la media esconde las colas).
  2. Separar “lento en la app” de “lento en la red/proxy”. Compara el duration_ms que mide la app contra el request_time de nginx ($request_time vs $upstream_response_time en el log de nginx). Si nginx dice 30s pero la app dice 200ms, el tiempo se fue en el cliente/red/buffering, no en tu código.
  3. Ver requests en vuelo ahora. gunicorn con --statsd-host o señales; o un endpoint de debug que liste tareas activas del event loop (asyncio.all_tasks()); o simplemente el log de acceso con timestamps de inicio sin cierre correspondiente. Un worker que no completa = candidato a request colgado.
  4. La causa raíz habitual es I/O sin timeout: una llamada HTTP a un tercero, una query DB, un lock que no se libera. Toda llamada saliente debe tener timeout. Un requests.get(url) o un httpx/driver DB sin timeout= puede colgar un worker indefinidamente; N requests así agotan el pool y la app entera deja de responder aunque el resto esté sano (cascada de agotamiento de workers).
# ❌ sin timeout: cuelga el worker para siempre si el upstream no responde
resp = httpx.get(url)

# ✅ timeout explícito + comportamiento definido ante fallo
try:
    resp = httpx.get(url, timeout=httpx.Timeout(5.0, connect=2.0))
except httpx.TimeoutException:
    ...  # degradar: fallback, cache, o error controlado — no colgar

En async, el equivalente de “worker colgado” es “event loop bloqueado”: una llamada síncrona pesada (CPU o I/O sync) dentro de un handler async def congela el loop y con él todas las conexiones del worker. Herramientas: activar PYTHONASYNCIODEBUG=1 o loop.slow_callback_duration para que asyncio avise de callbacks que tardan demasiado; mover lo bloqueante a asyncio.to_thread/run_in_executor o a un worker de tareas.

Troubleshooting: diagnóstico de problemas de performance

Cuando la latencia es real y está en la app, el orden de sospecha:

Regla de oro: mide antes de optimizar. El cuello de botella casi nunca está donde la intuición dice; el 80% de los “problemas de performance” en web apps son I/O (BD, red) y contención, no CPU de Python.

Troubleshooting: debugging

Debugging de una web app abarca desde el error de un request hasta el crash de un worker:

Ejercicios

1. Un compañero despliega una app Flask con python app.py (que llama a app.run()) directamente expuesta al puerto 80 en producción. Enumera al menos cuatro cosas que están mal y qué arquitectura correcta propondrías.

Solución

app.run() arranca el HTTP server de desarrollo de Werkzeug/Flask, explícitamente marcado como no apto para producción. Problemas:

  1. Single-process, sin gestión de workers: sirve esencialmente un request a la vez (o muy poca concurrencia), sin process manager que respawnee workers caídos ni recicle memoria.
  2. Sin reverse proxy delante: ningún buffering de clientes lentos → vulnerable a slowloris; ningún servido eficiente de estáticos; TLS habría que hacerlo en Python (mal).
  3. Riesgo de debug=True: si está activo, filtra tracebacks con código fuente y una consola ejecutable ante 500 — filtración de datos y RCE.
  4. Sin --timeout, --max-requests, graceful shutdown: un request colgado bloquea indefinidamente; memory leaks nunca se reciclan; los deploys cortan conexiones en vuelo.
  5. Corriendo como root en puerto 80: superficie de privilegio innecesaria.

Arquitectura correcta: nginx (TLS, estáticos, buffering) → gunicorn (--workers, --timeout, --max-requests, worker sync/gthread) → Flask app, config vía variables de entorno, debug off, logs estructurados a stdout, y un usuario no-root.

2. Una API FastAPI empieza a responder lentísimo bajo carga. En los logs de nginx ves upstream_response_time de 30s en muchos requests, y varios workers de uvicorn parecen “atascados”. Inspeccionando el código encuentras un endpoint que hace data = requests.get("https://third-party/api").json(). ¿Cuál es el problema y cómo lo arreglas?

Solución

Dos problemas superpuestos:

  1. requests es un cliente HTTP síncrono y bloqueante dentro de un handler async def. Cada llamada congela el event loop del worker uvicorn, y con él todas las conexiones concurrentes de ese worker, no solo la que hace la llamada. Bajo carga, esto colapsa la concurrencia que ASGI debería dar.
  2. No hay timeout. Si el tercero se degrada, la llamada cuelga indefinidamente; los workers quedan atascados esperando, se agota la capacidad y la API entera deja de responder (cascada de agotamiento).

Arreglo: usar un cliente async con timeout explícito

async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
    resp = await client.get("https://third-party/api")
data = resp.json()

Así el await cede el loop a otras conexiones mientras espera I/O, y el timeout garantiza que un tercero lento se degrada (fallback/error controlado) en vez de colgar workers. Si por alguna razón hay que usar código síncrono, envolverlo en await asyncio.to_thread(...) para no bloquear el loop. Además: revisar el --timeout de gunicorn/uvicorn y el proxy_read_timeout de nginx para que sean coherentes.

3. Tienes registrado en una app Starlette/FastAPI, en este orden con add_middleware: primero AuthMiddleware, luego LoggingMiddleware, luego CORSMiddleware. Un cliente reporta que las respuestas de error (401) no traen headers CORS y el navegador las bloquea. ¿Por qué, y cómo lo arreglas?

Solución

En Starlette/FastAPI, add_middleware prepende: el último añadido queda más externo. Con ese orden de registro, la cebolla de fuera hacia dentro es CORS → Logging → Auth → app. Eso suena correcto (CORS externo)… pero cuidado con la interpretación: como CORS fue el último añadido, es el más externo y sí envuelve todo — lo cual es lo deseado.

El bug real aparece cuando el orden es el inverso (CORS añadido primero, luego auth): auth queda más externo, rechaza con 401 antes de que CORS pueda añadir sus headers a la respuesta de error → el navegador ve una respuesta sin Access-Control-Allow-Origin y la bloquea, ocultando el 401 real tras un error CORS confuso.

Regla: CORS debe ser el middleware más externo para que toda respuesta — incluidas las de error generadas por middleware internos como auth — reciba los headers CORS. En FastAPI, dado que add_middleware prepende, hay que añadir CORSMiddleware de último (o asegurarse de que quede el más externo). Moraleja general: el orden de middleware es una decisión de diseño explícita, no un accidente del orden de import.

4. Dimensiona los workers y explica el tipo de worker para dos servicios en una máquina de 4 cores: (a) un servicio que redimensiona imágenes (CPU puro) escrito en Flask; (b) una API FastAPI que por cada request hace fan-out a 3 microservicios downstream vía HTTP.

Solución

(a) Redimensionado de imágenes (CPU-bound, WSGI/Flask): el trabajo es CPU puro, así que threads y greenlets no ayudan (el GIL serializa el cómputo Python; aunque parte del trabajo de imagen libere el GIL en C, el patrón robusto es escalar con procesos). Worker sync, --workers ≈ CORES = 4 (a veces CORES + 1). Más workers que cores solo añade context-switching sin ganancia. Idealmente, además, sacar el redimensionado del request path a un worker de tareas (Celery) y responder async con un job id, porque un request CPU-largo mantiene un worker entero ocupado.

(b) API con fan-out I/O (I/O-bound, ASGI/FastAPI): cada request pasa la mayor parte del tiempo esperando a 3 downstreams. Worker uvicorn_worker.UvicornWorker; con asyncio.gather las 3 llamadas se solapan (esperas concurrentes). --workers ≈ 2*CORES ≈ 8 como punto de partida, ajustado por medición — cada worker (event loop) ya multiplexa miles de conexiones concurrentes, así que el número de workers escala el paralelismo de CPU, no la concurrencia de I/O. Requisitos: clientes HTTP async con timeout, y nada bloqueante en el path (o el loop se congela). Dimensionar el connection pool contra la concurrencia esperada.

5. Detrás de nginx, tu app FastAPI loguea client.host como 127.0.0.1 para todos los usuarios, tu rate-limiter por IP no funciona, y algunos redirects mandan al usuario de https a http. Explica la causa común y la solución completa (proxy + app).

Solución

Causa única: la app ve la conexión del proxy, no la del cliente. Detrás de un reverse proxy, la IP de origen TCP es la del proxy (127.0.0.1) y el esquema que la app percibe es http (el proxy terminó el TLS y habla HTTP plano internamente). La IP real y el esquema original viajan en headers X-Forwarded-For / X-Forwarded-Proto, pero por defecto la app no confía en ellos.

Solución en dos partes:

  1. nginx debe setear los headers (ver bloque proxy_set_header en la teoría): X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, X-Real-IP $remote_addr, Host $host.
  2. La app debe confiar en ellos, y solo desde el proxy: en uvicorn --proxy-headers --forwarded-allow-ips=<ip_del_proxy>; en gunicorn --forwarded-allow-ips; o el ProxyHeadersMiddleware de Starlette. Con eso client.host pasa a ser la IP real (arregla el rate-limit) y la app sabe que el esquema original era https (arregla los redirects https→http).

Nota de seguridad: nunca confíes en X-Forwarded-For de una fuente arbitraria — un cliente puede falsificarlo. Confía solo cuando el proxy es tuyo y sobrescribe/filtra cualquier header entrante del cliente; de lo contrario un atacante spoofea su IP para saltarse rate-limits o envenenar logs.

Preguntas tipo entrevista (EN)

Q1: “Why does ASGI exist if WSGI already worked for a decade? Be concrete.”

Q2: “A teammate made all our Flask endpoints async def to speed them up under gunicorn’s default sync worker. What happens?”

Q3: “Walk me through gunicorn worker classes. When do you pick sync, gthread, gevent, or a uvicorn worker?”

Q4: “Explain the middleware execution order. If I register auth, then logging, then CORS, in what order do they see the request and the response?”

Q5: “What changes when you deploy a FastAPI service from local dev to production? Give me the checklist, not platitudes.”

Q6: “What’s the difference between an HTTP server and a WSGI server? Where do gunicorn, nginx and Flask each fit?”

Q7: “A request is taking 30 seconds. Walk me through how you’d find out why, without guessing.”

Referencias