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 proxy ≠ HTTP server ≠ WSGI server; (2) worker ≠ proceso ≠ thread ≠ conexió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.
- HTTP server (rol): acepta conexiones TCP, parsea la request line/headers/body según el RFC de HTTP, gestiona keep-alive, chunked transfer, TLS. Su “salida” es una request estructurada. nginx, Apache, o la parte de red de gunicorn/uvicorn juegan este rol. No sabe nada de Python.
- WSGI server (rol): toma esa request ya parseada y la convierte en la llamada que la spec WSGI define —
app(environ, start_response)— invoca tu callable Python, y traduce el iterable de bytes que devuelve de vuelta a una respuesta HTTP. gunicorn (con worker sync), uWSGI, mod_wsgi hacen esto. Análogo ASGI: un ASGI server (uvicorn, hypercorn) hace lo mismo pero contraasync def app(scope, receive, send).
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:
- Elegir stack web. Flask/Django-tradicional → WSGI; FastAPI/Starlette/Django-async → ASGI. La decisión condiciona servidor, workers y modelo de concurrencia.
- WebSockets, SSE, streaming, long-polling. Requieren ASGI. Sobre WSGI no son expresables (el protocolo asume un único ciclo request→response por callable).
- Workloads I/O-bound de alta concurrencia (muchas llamadas a otros servicios/BD por request): ASGI permite miles de conexiones concurrentes por proceso con pocos workers. CPU-bound puro: da igual el protocolo, lo que importa es el número de procesos.
- Modelo mental async: el event loop de ASGI/uvicorn es análogo al de Node; WSGI es el modelo “thread/proceso por request” clásico.
Trade-off central: concurrencia I/O dentro del proceso (ASGI) vs simplicidad y aislamiento del modelo bloqueante (WSGI).
- ASGI gana en I/O-bound: un event loop atiende N conexiones concurrentes mientras esperan I/O; ideal para WebSockets y fan-out a múltiples servicios. Coste: todo el path debe ser non-blocking — una sola llamada bloqueante (
requests, driver DB sync,time.sleep, CPU pesado) congela el loop y con él todas las conexiones de ese worker. Exige disciplina async end-to-end (async drivers,run_in_executor/anyio.to_threadpara lo sync). - WSGI gana en simplicidad y robustez ante código bloqueante: cada request tiene su worker/hilo aislado; una operación lenta solo bloquea a ese trabajador, no a los demás. Coste: la concurrencia se compra con procesos/hilos (memoria, context-switch), y no puede modelar conexiones long-lived.
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:
--workers: procesos que sirven en paralelo. Punto de partida(2 * CORES) + 1para I/O-bound (fórmula histórica de gunicorn),= CORESpara CPU-bound (más procesos que cores solo añade context-switch bajo el GIL). Número real = medido bajo carga, no adivinado. Cada worker es un proceso completo → RAM ≈ workers × footprint del proceso; ojo con OOM.--timeout: gunicorn mata y respawnea un worker que no responde al arbiter en N segundos. Sizearlo por encima del request legítimo más lento pero lo bastante bajo para desalojar workers colgados. Con workers async (uvicorn) el timeout de gunicorn vigila el worker, no cada request — un request lento no bloquea a los demás en el mismo loop, pero un loop congelado (bloqueo síncrono) sí dispara el timeout.--graceful-timeout: al recibir SIGTERM (deploy, scale-down), cuánto espera a que los requests en vuelo terminen antes de forzar el cierre. Debe cubrir tu request seguro más largo para que los rolling deploys no corten conexiones.--max-requests+--max-requests-jitter: recicla workers periódicamente. Mitiga memory leaks lentos (el proceso muere y renace limpio) y fragmentación. El jitter evita que todos reciclen simultáneamente (thundering herd de arranques).--preload: carga la app una vez en el master y hace fork → arranque más rápido y menos RAM por copy-on-write. Coste: rompe el auto-reload y hay que abrir conexiones (DB pools, etc.) después del fork (enpost_forkhook), no antes, o los workers comparten sockets/FDs y corrompen datos.- Config vía entorno: los 12-factor mandan que toda config (bind, workers, DB URL, secretos) venga de variables de entorno validadas al arranque (p.ej.
pydantic-settings), no hardcodeada. El proceso debe fallar rápido si falta config, no dar 500 en runtime.
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.
| Worker | Modelo | Concurrencia por worker | Cuándo |
|---|---|---|---|
sync (default) | 1 request por proceso | 1 | WSGI, CPU-bound o requests cortos y predecibles. El más robusto. |
gthread | thread pool por proceso | N threads (I/O-wait libera GIL) | WSGI, I/O-bound moderado. Predecible. |
gevent/eventlet | greenlets (monkey-patch) | miles (cooperativo) | WSGI, muy alta concurrencia I/O. Cuidado con C-extensions bloqueantes. |
uvicorn_worker.UvicornWorker | event loop asyncio | miles (async) | ASGI (FastAPI/Starlette), WebSockets, I/O-bound alto. |
Árbol de decisión senior:
- ¿La app es ASGI? → uvicorn workers, punto. Apuntar uvicorn workers a una app WSGI no funciona.
- ¿WSGI y CPU-bound? →
sync,workers ≈ CORES. Threads/greenlets no ayudan al trabajo CPU bajo el GIL; se escala con procesos. - ¿WSGI e I/O-bound? →
gthreadpara predictibilidad, ogeventsi 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:
- Orden de registro contraintuitivo. En Starlette/FastAPI,
add_middlewareprepende: el último añadido es el más externo y corre primero en la request. CORS debe ser el más externo para que preflightOPTIONSy respuestas de error igual lleven headers CORS; auth debe ser externo respecto a cualquier cosa que asuma usuario autenticado. Bugs de orden — logging que se pierde fallos de auth por estar dentro de auth, o compresión que corre antes de un middleware que reescribe el body — son incidentes de producción clásicos. - ASGI middleware puro vs framework middleware. El puro envuelve
scope/receive/send, es agnóstico del framework, ve eventos de protocolo crudos y puede manejar WebSocket/lifespan. El de framework (@app.middleware("http")) trabaja con objetos request/response, más ergonómico pero HTTP-only y no toca frames WebSocket.
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:
- TLS termination. Descifra HTTPS y habla HTTP plano con el app server en la red interna. Centraliza certificados.
- Static files. Sirve
/static, imágenes, JS directamente desde disco — órdenes de magnitud más eficiente que hacer pasar bytes estáticos por Python. - Buffering de clientes lentos (crítico). Un cliente en 3G que tarda 30s en subir un body mantendría ocupado un worker sync todo ese tiempo (ataque slowloris trivial). nginx buffea request y response completas, y solo habla con el worker cuando la request está entera y a velocidad LAN. Esto protege el pool de workers, que es el recurso escaso.
- Load balancing entre varias instancias del app server, health checks, y desalojo de backends caídos.
- Rate limiting, compresión, caching, WAF en el borde.
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:
- Routing del reverse proxy (L7, por prefijo/host): nginx decide
/static/→ disco,/api/→ app server A,/ws/→ app server B. Grueso, por prefijo o host. Aquí vive el fan-out entre microservicios. - Routing del framework (fino, por patrón + método): dentro de la app, el router casa
GET /users/{id}con un handler, extrae y valida path/query params, y aplica dependencias/middleware de esa ruta.
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:
- Orden y especificidad. La mayoría de routers casan la primera ruta que coincide.
/users/medebe declararse antes que/users/{user_id}, omese interpreta como unuser_idy falla la validación (int("me")). - Método importa.
GET /xyPOST /xson rutas distintas. Un 405 Method Not Allowed (no 404) indica que el path existe pero no para ese método — señal de routing, no de recurso ausente. - Trailing slash.
/usersvs/users/— algunos frameworks redirigen (307) entre ellos; detrás de un proxy mal configurado ese redirect puede romper el esquema o el host. Decidir una convención y ser consistente. - Reverse routing /
url_for. Generar URLs a partir del nombre de la ruta (no hardcodear paths) evita links rotos al refactorizar.
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.
- Instrumentar la latencia por request. Un middleware de timing (ver arriba) que loguea
method path status duration_msde cada request, más un headerX-Process-Time. Con eso identificas los endpoints y percentiles lentos (mira p95/p99, no la media — la media esconde las colas). - Separar “lento en la app” de “lento en la red/proxy”. Compara el
duration_msque mide la app contra elrequest_timede nginx ($request_timevs$upstream_response_timeen 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. - Ver requests en vuelo ahora. gunicorn con
--statsd-hosto 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. - 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 unhttpx/driver DB sintimeout=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:
- N+1 queries. El clásico: un endpoint que hace 1 query para la lista y luego 1 por cada ítem. Se detecta con logging del contador de queries por request (SQLAlchemy
echo/eventos), o con un middleware que cuenta queries. Se arregla con eager loading /JOIN/ batch. - Falta de índices / queries lentas.
EXPLAIN ANALYZEsobre las queries que el paso anterior señaló; revisar slow query log de la BD. Latencia que crece con el tamaño de la tabla = full scan. - Trabajo CPU-bound en el request path. Serialización enorme, cripto, procesamiento de imágenes. En async, esto bloquea el loop; en general, esto no debe vivir en el request — va a un worker de tareas en background (Celery/RQ/arq) y el endpoint responde 202 con un id de job.
- Connection pool exhaustion. Pool de DB/HTTP demasiado pequeño para la concurrencia → requests esperan en cola por una conexión. Los síntomas parecen “lento” pero es contención, no cómputo. Dimensionar el pool contra
workers × concurrencia. - Serialización/allocations. Respuestas gigantes, JSON de miles de objetos. Paginar; hacer streaming de responses grandes en vez de materializarlas en memoria.
- Perfilar, no adivinar. Para CPU:
cProfile/py-spy(py-spy hace sampling de un proceso en producción sin pararlo — clave para prod). Para el tiempo end-to-end: tracing distribuido (OpenTelemetry) que muestra el waterfall a través de proxy, app y downstreams, y dice qué span domina.
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:
- Logging estructurado a stdout (12-factor). Logs en JSON con
request_id/trace_idque correlaciona todas las líneas de un request a través de capas. Niveles usados con criterio: DEBUG (dev), INFO (eventos de negocio), WARNING (degradación recuperable), ERROR (fallo que necesita atención). En prod, INFO/WARNING; nunca DEBUG con datos sensibles ni tracebacks al cliente. X-Request-ID. Generado en el proxy (o middleware) y propagado; permite pedirle a un usuario “dame el id” y encontrar exactamente sus líneas de log entre millones.- Error tracking (Sentry). Captura excepciones no manejadas con traceback, variables locales, y el contexto del request. Agrupa por fingerprint. Es la diferencia entre “un usuario reportó un error” y “esta excepción ocurrió 340 veces en 2 líneas concretas”.
- Reproducir localmente vs. debug en prod. Nunca ligar un debugger interactivo (
pdb) a un worker de producción — bloquea el proceso. En prod se usa observabilidad (logs, métricas, traces,py-spy dumppara ver el stack de un proceso vivo). El debugging interactivo es para reproducir el caso en local/staging. - Debug mode OFF en producción, siempre.
debug=Truede Flask / elrunserverde Django exponen tracebacks con código fuente y a veces una consola ejecutable al cliente ante un 500 — filtración crítica y RCE potencial. La config de arranque debe garantizar debug apagado en cualquier entorno no-dev. - Health checks: liveness vs readiness. Señales distintas. Liveness = “¿el proceso está vivo?” (si falla, reinícialo). Readiness = “¿puede servir tráfico ahora?” (si una dependencia como la BD no está lista, sácalo del load balancer sin reiniciarlo). Confundirlos causa loops de reinicio o tráfico enviado a un pod no listo.
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:
- 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.
- 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).
- 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. - Sin
--timeout,--max-requests, graceful shutdown: un request colgado bloquea indefinidamente; memory leaks nunca se reciclan; los deploys cortan conexiones en vuelo. - 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:
requestses un cliente HTTP síncrono y bloqueante dentro de un handlerasync 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.- 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:
- nginx debe setear los headers (ver bloque
proxy_set_headeren la teoría):X-Forwarded-For $proxy_add_x_forwarded_for,X-Forwarded-Proto $scheme,X-Real-IP $remote_addr,Host $host. - 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 elProxyHeadersMiddlewarede Starlette. Con esoclient.hostpasa a ser la IP real (arregla el rate-limit) y la app sabe que el esquema original erahttps(arregla los redirectshttps→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.”
- ❌ Wrong / trap: “Because async is faster than sync, so ASGI gives you more requests per second.” — async is not universally faster: for CPU-bound work it’s the same or worse. This answer misses the actual reason and signals a cargo-cult understanding. WSGI’s limitation is structural, not a performance tuning gap.
- ✅ Correct: “WSGI’s contract is a single synchronous
app(environ, start_response)call that maps one request to one response and returns. That shape cannot express connection-oriented, long-lived protocols like WebSockets or server-sent events, and it can’t yield the worker during I/O to serve other connections. ASGI replaces the callable withasync def app(scope, receive, send), wherereceive/sendare async channels — so a single worker can multiplex many concurrent connections on an event loop and model bidirectional protocols.” - ⭐ Optimal (senior): Correct, plus: “The win is specifically for I/O-bound and long-lived-connection workloads: one process holds thousands of mostly-idle WebSocket or streaming connections cheaply, and fan-out requests that await several downstream calls overlap their waits. The cost is that the entire request path must be non-blocking; a single sync DB driver or
requestscall stalls the whole loop. I’d reach for ASGI when I have WebSockets/SSE/streaming or high-concurrency I/O fan-out, and stay on WSGI for a straightforward CPU-bound or simple CRUD service where the operational simplicity and blocking-code tolerance of the process-per-request model is worth more than event-loop concurrency.”
Q2: “A teammate made all our Flask endpoints async def to speed them up under gunicorn’s default sync worker. What happens?”
- ❌ Wrong / trap: “The endpoints now run concurrently, so throughput improves under load.” — the default gunicorn worker is
sync(WSGI): there is no event loop. Marking handlersasyncyields zero concurrency; the premise is false. - ✅ Correct: “Nothing good. gunicorn’s default worker is a WSGI sync worker with no event loop, and Flask is a WSGI framework. An
async defview produces a coroutine that Flask has to drive to completion synchronously (via its async-to-sync bridge) — you pay coroutine overhead and get none of the concurrency. Concurrency there comes from more processes/threads, not fromasync. To actually benefit from async you need an ASGI server (uvicorn) and an ASGI framework (Quart/FastAPI/Starlette).” - ⭐ Optimal (senior): Correct, plus failure modes: “Worse, if any of those
asyncviewsawaitsomething that never completes synchronously, or if the sync worker’s single thread blocks on the bridge, you get requests timing out under load with no throughput gain — a regression disguised as an optimization. The fix is architectural, not a flag: either move the service to uvicorn/ASGI end-to-end (and then audit for hidden blocking calls — sync DB driver,requests, heavy CPU — offloading them to a thread pool), or keep it WSGI and scale withgthread/more processes. I’d push back on ‘just add async’ and ask what the actual bottleneck is; if it’s CPU-bound, ASGI is the wrong tool entirely.”
Q3: “Walk me through gunicorn worker classes. When do you pick sync, gthread, gevent, or a uvicorn worker?”
- ❌ Wrong / trap: “Always use uvicorn workers — they’re the modern default and fastest.” — uvicorn workers require an ASGI app; pointing them at a WSGI app won’t work, and ‘fastest’ ignores that the right choice depends on workload shape (CPU vs I/O) and app protocol (WSGI vs ASGI).
- ✅ Correct: “
syncis one request per worker process, simplest and most robust — good for CPU-bound or short predictable requests.gthreadgives each worker a thread pool, so one process handles several concurrent (mostly I/O-waiting) requests, releasing the GIL during I/O — good for WSGI apps with moderate I/O concurrency.gevent/eventletmonkey-patch to get greenlet-based cooperative concurrency for high I/O concurrency on WSGI, but need care with blocking C extensions.uvicorn_worker.UvicornWorkerruns an ASGI event loop per worker — required for FastAPI/Starlette and WebSockets.” - ⭐ Optimal (senior): Correct, plus trade-offs: “My decision tree: is the app ASGI? Then uvicorn workers, full stop. Is it WSGI and CPU-bound?
syncwithworkers ≈ CORES, because threads/greenlets don’t help CPU work under the GIL — you scale with processes. WSGI and I/O-bound?gthreadfor predictability, orgeventif I need very high connection counts and I’ve verified my stack is greenlet-safe (no blocking C libs, DB driver cooperates). In every case I set--max-requestswith jitter to recycle workers against memory leaks, size--timeoutabove my slowest legit request but low enough to shed stuck workers, and I’d never put uvicorn workers in front of a WSGI app or expect greenlets to speed up CPU-bound code — both are common footguns.”
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?”
- ❌ Wrong / trap: “They run top to bottom for both request and response — auth, then logging, then CORS, in the same order each way.” — middleware is an ‘onion’, not a pipeline. The response phase unwinds in reverse. Also, in some frameworks (e.g. Starlette/FastAPI’s
add_middleware) the last one added is the outermost layer, so registration order is not intuitively request order. - ✅ Correct: “Middleware wraps the app like an onion. On the way in, the outermost middleware runs first and calls the next inner one; on the way out, they unwind in reverse. So request order is outer→inner, response order is inner→outer. A middleware sees the request before the handler and the response after the handler, wrapping the
call_next/inner-app call. That’s why CORS or timing middleware can both read the incoming request and mutate the outgoing response headers.” - ⭐ Optimal (senior): Correct, plus ordering pitfalls and the ASGI-vs-framework distinction: “The subtlety is registration order. In Starlette/FastAPI,
add_middlewareprepends, so the last-added middleware is outermost and runs first on the request — the opposite of what people expect, and it matters: CORS must be outermost so preflight/OPTIONSand error responses still get CORS headers, and auth should be outer relative to anything that assumes an authenticated user. I also distinguish pure ASGI middleware (wrapsscope/receive/send, framework-agnostic, sees raw protocol events, can handle WebSocket/lifespan) from framework middleware (e.g.@app.middleware('http'), works with request/response objects, more ergonomic but HTTP-only and can’t touch WebSocket frames). Ordering bugs — logging that misses auth failures because it’s inside auth, or compression that runs before a middleware that rewrites the body — are classic production incidents, so I treat middleware order as an explicit design decision, not an accident of import order.”
Q5: “What changes when you deploy a FastAPI service from local dev to production? Give me the checklist, not platitudes.”
- ❌ Wrong / trap: “Turn off reload and run more workers — that’s basically it.” — it’s a fraction of the surface. It omits secrets management, debug/traceback exposure, health checks, log configuration, and timeouts — omissions that are exactly what a senior interview is probing for.
- ✅ Correct: “Disable auto-reload and debug tracebacks so stack traces aren’t leaked to clients; run under gunicorn+uvicorn workers sized to cores; set log level to INFO/WARNING with structured JSON logs to stdout; load all config from environment variables with no secrets in code or image; expose liveness and readiness health endpoints; set sane request timeouts and graceful shutdown so in-flight requests drain on deploy.”
- ⭐ Optimal (senior): Correct, plus reasoning and failure modes: “Concretely:
workers ≈ 2*CORESfor an I/O-bound service (measured, not guessed),--max-requestswith jitter to bound memory-leak blast radius,--graceful-timeoutmatched to the longest safe request so rolling deploys don’t drop connections. Config strictly via env vars validated at startup (pydantic-settings) so the process fails fast on missing config rather than 500-ing at request time; secrets come from a secret manager, never baked into the image or logs — and I scrub PII/tokens from structured logs. Liveness vs readiness are different signals: liveness = ‘is the process alive’ (restart if it fails), readiness = ‘can it serve traffic right now’ (pull from the load balancer if a dependency like the DB or a warm cache isn’t ready) — conflating them causes either restart loops or traffic sent to a not-ready pod. Debug mode off everywhere so internal tracebacks never reach clients. I’d also front it with proper timeouts at every hop (client, gunicorn, upstream) so a slow dependency degrades gracefully instead of exhausting workers.”
Q6: “What’s the difference between an HTTP server and a WSGI server? Where do gunicorn, nginx and Flask each fit?”
- ❌ Wrong / trap: “They’re the same thing — gunicorn is the HTTP server and it serves the Flask app directly.” — conflates two roles and skips the reverse proxy. It also misses that WSGI is a specification, not a server, and that gunicorn’s dev-vs-prod and proxy story is exactly what’s being probed.
- ✅ Correct: “An HTTP server speaks the HTTP wire protocol: it accepts TCP connections, parses request lines/headers/bodies, handles keep-alive and TLS. A WSGI server implements the WSGI specification — it takes a parsed request and calls the Python app as
app(environ, start_response), then turns the returned byte iterable back into an HTTP response. WSGI itself is a PEP, a contract, not a program. gunicorn plays both roles at once: it speaks HTTP and it drives the WSGI callable, managing a pool of workers. nginx is a pure reverse proxy / HTTP server — it terminates TLS, serves static files, buffers slow clients, and forwards to gunicorn; it knows nothing about Python. Flask is the app at the end of the chain — a WSGI application.” - ⭐ Optimal (senior): Correct, plus the dev-server trap and the ASGI parallel: “The distinction bites in production.
flask run/app.run()/ Django’srunservership a development HTTP server — single-process, no robust worker management, not hardened — explicitly ‘do not use in production’. Prod is nginx (TLS, static, buffering, load-balance) → gunicorn (worker management, WSGI) → Flask. The exact same layering holds for async: uvicorn/hypercorn are ASGI servers drivingasync def app(scope, receive, send), and you still put a reverse proxy in front. I’d also note why the proxy isn’t optional: without nginx buffering, a slow client ties up a worker for the whole upload — a trivial slowloris — because the worker pool is the scarce resource. Keeping HTTP-server, WSGI/ASGI-server and reverse-proxy as distinct roles is what lets me reason about where a failure or a bottleneck actually lives.”
Q7: “A request is taking 30 seconds. Walk me through how you’d find out why, without guessing.”
- ❌ Wrong / trap: “I’d add more workers and scale the box until it’s fast.” — throwing capacity at an unmeasured problem. A single un-timed downstream call will exhaust any number of workers; scaling hides the symptom briefly and burns money without finding the cause.
- ✅ Correct: “I’d measure first. A timing middleware logs
method path status duration_msper request and adds anX-Process-Timeheader, so I can see which endpoints and which percentiles are slow (p95/p99, not the mean). Then I split ‘slow in the app’ from ‘slow in the network’ by comparing nginx’s$upstream_response_timeagainst the app’s own measured time — if nginx says 30s but the app says 200ms, the time is in the client/network/buffering, not my code. If it’s in the app, the usual culprit is an outbound call without a timeout: a third-party HTTP call, a DB query, a lock. Every outbound call must have an explicit timeout, or one stuck upstream ties up workers indefinitely and cascades into total unavailability.” - ⭐ Optimal (senior): Correct, plus async specifics and tooling: “In an async service the equivalent of a ‘stuck worker’ is a blocked event loop: a synchronous heavy call — a sync DB driver,
requests, CPU-bound work — inside anasync defhandler freezes the loop and every connection on that worker, not just the one making the call. I’d turn onPYTHONASYNCIODEBUG/loop.slow_callback_durationto catch long callbacks, and move blocking work toasyncio.to_threador a task queue. For the app-side breakdown I lean on distributed tracing (OpenTelemetry): a per-request waterfall across proxy → app → each downstream tells me which span dominates. For CPU I’d sample a live prod process withpy-spywithout stopping it. The mental model I keep is that most web-app ‘performance problems’ are I/O and contention — un-timed calls, N+1 queries, missing indexes, pool exhaustion — not Python CPU, so I measure to locate the bottleneck before touching anything.”
Referencias
- WSGI — PEP 3333: https://peps.python.org/pep-3333/
- ASGI specification (encode): https://asgi.readthedocs.io/en/latest/specs/main.html
- Uvicorn — deployment & gunicorn worker: https://www.uvicorn.org/deployment/
uvicorn-worker(worker class, reemplazauvicorn.workers): https://github.com/Kludex/uvicorn-worker- Gunicorn — design & worker types: https://docs.gunicorn.org/en/stable/design.html
- Gunicorn — configuration & settings: https://docs.gunicorn.org/en/stable/settings.html
- Starlette — Middleware (onion /
add_middlewareorder): https://www.starlette.io/middleware/ - Starlette — Proxy headers /
forwarded-allow-ips: https://www.uvicorn.org/deployment/#running-behind-nginx - nginx — reverse proxy & buffering: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
- Flask — deployment / “do not use the dev server in production”: https://flask.palletsprojects.com/en/stable/deploying/
- py-spy — sampling profiler para procesos en producción: https://github.com/benfred/py-spy