HTTP + Request Lifecycle
El recorrido completo de un request HTTP desde el cliente hasta la app y de vuelta, qué estado vive en cada salto, y en qué difiere ese recorrido entre tu máquina local y producción.
Teoría
Qué es y por qué importa a nivel senior
HTTP es un protocolo de aplicación request/response, stateless, sobre TCP (o QUIC/UDP en HTTP/3). Cada request lleva método + URL + headers + (opcional) body; cada response lleva status code + headers + body. El “request lifecycle” es el recorrido de ese mensaje desde el cliente hasta la app y de vuelta, atravesando DNS, reverse proxy, servidor WSGI/ASGI y capa de datos.
La pregunta clásica de entrevista senior —“walk me through what happens from typing a URL to getting a response”— no evalúa trivia de HTTP: evalúa si tenés un modelo mental completo del sistema. La diferencia entre junior y senior no es saber que existe DNS, es saber qué falla en cada salto, qué estado vive dónde, y en qué difiere local de prod. Un 502 vs 503 vs 504 apunta a capas distintas; leerlos bien acorta el MTTR de un incidente de horas a minutos.
Este recorrido aplica cuando:
- Diseñás/revisás cualquier API HTTP: elegir método y status code correcto no es cosmético, define retry-safety y semántica de caché de clientes/proxies.
- Debuggeás en producción:
502(app crasheó) vs503(sobrecarga) vs504(timeout upstream) señalan capas diferentes. - Decidís dónde vive la sesión (cookie server-side vs JWT): cambia el modelo de escalado, revocación y superficie de ataque.
- Configurás idempotencia y reintentos: sin
Idempotency-Key, un POST reintentado sobre red poco fiable cobra dos veces. - Separás config local de prod: un
DEBUG=Truefiltrado a producción es una de las causas más comunes de RCE/leak de secrets en apps Python web.
El trade-off central: statelessness vs estado necesario
HTTP es stateless por diseño: cada request es autocontenido, lo que da escalado horizontal trivial (cualquier réplica atiende cualquier request) y caché sencillo. Pero las apps reales necesitan estado (quién sos, tu carrito, idempotencia). El trade-off es dónde empujás ese estado:
- Al cliente (JWT, cookies firmadas): réplicas sin estado, escala fácil; pero revocación difícil, tamaño en cada request, y el secreto/expiración se vuelven críticos.
- Al servidor (session store en Redis): revocación instantánea y payload chico; pero introducís una dependencia con estado que debe ser HA y baja latencia, y ya no sos “puramente” stateless.
No hay respuesta universal: banca/admin tiende a session server-side (revocación), APIs públicas de alto volumen tienden a tokens. Lo mismo con caché: cachear agresivo mejora latencia/costo pero arriesga servir stale; ETag/Cache-Control es cómo negociás ese eje.
El recorrido completo (URL → response)
Browser
│ 1. DNS: resuelve api.example.com → IP
│ (cache navegador → cache OS → resolver ISP → root → TLD → authoritative)
│ 2. TCP handshake (SYN/SYN-ACK/ACK) + TLS handshake
│ (ALPN negocia HTTP/2, valida cert de la CA, deriva session keys)
▼
nginx (reverse proxy / edge)
│ 3. Termina TLS, aplica rate-limit, sirve estáticos, enruta por Host/path,
│ load-balancing a upstreams, timeouts, buffering.
│ Añade X-Forwarded-For / X-Forwarded-Proto / X-Request-ID.
▼
gunicorn (WSGI, sync) · uvicorn (ASGI, async)
│ 4. Server de aplicación: acepta la conexión de nginx, gestiona
│ workers/procesos, parsea el request y lo entrega a la app vía
│ interfaz WSGI (Flask/Django sync) o ASGI (FastAPI/Starlette async).
│ Aquí vive el worker pool y se define la concurrencia real.
▼
App (routing → middleware → auth → handler)
│ 5. Middleware (CORS, auth, logging, request-id), valida input (Pydantic),
│ ejecuta lógica de negocio.
▼
DB / cache / servicios externos
│ 6. Query (connection pool), Redis, otras APIs. Suele ser el cuello de
│ botella y la fuente del 504 si no hay timeouts.
▼
...y la response sube por el mismo camino (app → uvicorn → nginx → browser),
con status code + headers (Cache-Control, ETag) que browser y proxies respetan.
1. DNS: de nombre a IP
El browser no habla con api.example.com, habla con una IP. Resolverla recorre una jerarquía de cachés antes de tocar la red:
- Cache del navegador → 2. Cache del OS (
getaddrinfo,/etc/hostsprimero) → 3. Resolver recursivo (ISP o8.8.8.8) → 4. Root servers → 5. TLD servers (.com) → 6. Authoritative (el que sabe la IP real).
Puntos senior:
- TTL: cada record DNS tiene un TTL; un TTL alto acelera (más cache hits) pero ralentiza cambios de IP en un failover/migración. Bajás el TTL antes de una migración, no durante.
- Split-horizon / split DNS: el mismo nombre resuelve a IPs distintas según quién pregunta (interno vs externo). Por eso en local
api.example.compuede apuntar a127.0.0.1vía/etc/hostsy en prod a un load balancer. - Fallo típico:
NXDOMAIN(no existe), timeout del resolver, o cache envenenada/stale tras un cambio de IP. En Python,socket.getaddrinfo("api.example.com", 443)es lo que hace por debajorequests/httpx.
2. TCP + TLS: el canal seguro
Antes de mandar un byte de HTTP:
- TCP handshake (SYN / SYN-ACK / ACK): 1 round-trip para establecer la conexión.
- TLS handshake: el cliente manda
ClientHello(versiones TLS soportadas, cipher suites, SNI con el hostname, ALPN proponiendoh2/http/1.1); el server responde con su certificado (cadena hasta una CA raíz de confianza), se valida, y se derivan las session keys. TLS 1.3 lo hace en 1-RTT (o 0-RTT con resumption).
Puntos senior:
- SNI (Server Name Indication) permite muchos dominios HTTPS en una IP: el proxy sabe qué cert servir antes de terminar el handshake.
- ALPN decide HTTP/1.1 vs HTTP/2 dentro del handshake TLS, no después.
- Certificate pinning / cadena de confianza: el cliente confía en el cert si encadena a una CA en su trust store. Un cert self-signed en local rompe esto (de ahí
verify=Falseen dev, nunca en prod).
3. TLS termination en el reverse proxy
Casi siempre TLS termina en el edge (nginx, un ALB/cloud LB, Cloudflare), no en tu app Python. Esto es central y muy preguntado:
- La app recibe HTTP plano desde el proxy; el cliente habló HTTPS con el proxy. La app no ve que era TLS salvo por el header
X-Forwarded-Proto: httpsque el proxy inyecta. - Consecuencia práctica: si tu framework genera URLs absolutas o decide
Securecookies mirando el esquema, debe confiar enX-Forwarded-Proto, no en el esquema del socket (que eshttp). En Django esSECURE_PROXY_SSL_HEADER; en FastAPI/Starlette usásProxyHeadersMiddleware/--forwarded-allow-ipsen uvicorn. - Trampa de seguridad: confiar en
X-Forwarded-*de cualquier origen deja spoofear la IP/proto. Solo confiás en esos headers si vienen de tu proxy conocido (allow-list de IPs). - Alternativas: TLS passthrough (el LB reenvía el TLS crudo, la app termina) o re-encryption / mTLS (TLS del cliente al edge, y TLS nuevo edge→app) en entornos zero-trust.
nginx como reverse proxy típicamente hace, además de terminar TLS: rate-limiting, servir archivos estáticos (que la app Python nunca debería servir), buffering de request/response, connection keep-alive/reuse hacia upstreams, health checks y balanceo. Boceto de config:
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
# limita 10 req/s por IP, con burst
limit_req zone=api burst=20 nodelay;
location / {
proxy_pass http://app_upstream; # a gunicorn/uvicorn
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; # ← la app lee esto
proxy_read_timeout 30s; # si upstream tarda más → 504
}
}
upstream app_upstream {
server 127.0.0.1:8000; # unix socket es aún mejor
keepalive 32;
}
4. HTTP server vs WSGI/ASGI server: la distinción senior
nginx es un HTTP server (habla el protocolo HTTP, TLS, sockets). No sabe ejecutar tu código Python. gunicorn/uvicorn son application servers: traducen entre el mundo de sockets/HTTP y una interfaz estándar que tu app Python entiende.
- WSGI (Web Server Gateway Interface, PEP 3333): interfaz síncrona. La app es un callable
app(environ, start_response). Un worker atiende un request a la vez, bloqueante. Frameworks: Django (modo clásico), Flask. Server: gunicorn, uWSGI. - ASGI (Asynchronous Server Gateway Interface): interfaz asíncrona. La app es
async def app(scope, receive, send). Un solo proceso/loop multiplexa miles de conexiones concurrentes mientras esperan I/O. Soporta WebSockets, HTTP/2, long-polling. Frameworks: FastAPI, Starlette, Django ASGI. Server: uvicorn, hypercorn.
El modelo de workers define la concurrencia real y es la fuente de muchos incidentes:
# WSGI sync: N workers = N requests concurrentes MÁXIMO.
# Si un handler bloquea 2s en la DB, ese worker no atiende nada más 2s.
# gunicorn app:app --workers 4 --worker-class sync
# regla de dedo: workers = (2 * núcleos) + 1
# Para I/O-bound con WSGI, workers async (gevent/eventlet) sin reescribir la app:
# gunicorn app:app --workers 4 --worker-class gevent --worker-connections 1000
# ASGI async: un worker maneja miles de requests concurrentes en I/O.
# En prod, gunicorn gestiona procesos y uvicorn provee el loop por proceso:
# gunicorn app:app --workers 4 --worker-class uvicorn.workers.UvicornWorker
Trampa clásica en ASGI: meter una llamada bloqueante (un driver DB síncrono, time.sleep, CPU pesado) dentro de un async def bloquea el event loop entero y mata la concurrencia de todos los requests de ese worker. La lógica bloqueante va en run_in_executor / asyncio.to_thread o con drivers async (asyncpg, httpx.AsyncClient).
Por qué existen los dos: sync es más simple de razonar y depurar (stack lineal), gana en CPU-bound donde async no ayuda; async gana masivamente en I/O-bound de alta concurrencia (muchas conexiones esperando DB/APIs). Elegís según el perfil de carga, no por moda.
5. La app: routing → middleware → auth → handler
Dentro de la app, el request pasa por una pipeline de middleware (cebolla: entra por fuera, sale por fuera) antes y después del handler:
# FastAPI/Starlette — middleware envuelve cada request
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid4()))
# ... entra: logging, timing, auth context
response = await call_next(request) # ← el handler corre acá dentro
response.headers["X-Request-ID"] = request_id # ... sale: headers, métricas
return response
Orden típico de la cebolla (de fuera hacia dentro): CORS → request-id/logging → auth → rate-limit por usuario → validación (Pydantic) → handler de negocio → serialización → error handling. El orden importa: querés request-id antes que logging (para correlacionar), y auth antes que la lógica cara.
6. La capa de datos: casi siempre el cuello de botella
El handler pega a DB/cache/APIs externas. Aquí nace la mayoría de los 504:
- Connection pool: no abrís una conexión TCP a Postgres por request (carísimo). Un pool (SQLAlchemy,
asyncpg.Pool) reusa conexiones. Si el pool se satura (todas ocupadas por queries lentas), los requests nuevos esperan → timeouts → cascada de 503/504. - Timeouts en cada salto: proxy→app→DB. Sin un
statement_timeouten la DB, una query colgada retiene un worker/conexión indefinidamente y tumba el servicio. - N+1 y falta de índices: el patrón más común de “endpoint lento”; se diagnostica con
EXPLAIN ANALYZEy logging de slow queries.
Métodos HTTP: safe / idempotent
| Método | Safe (no muta) | Idempotent (N veces = 1 vez) | Uso |
|---|---|---|---|
| GET | ✅ | ✅ | leer |
| HEAD | ✅ | ✅ | metadata sin body |
| PUT | ❌ | ✅ | reemplazo total (mismo resultado si repetís) |
| DELETE | ❌ | ✅ | borrar (2do DELETE → 404, pero estado final igual) |
| PATCH | ❌ | ❌* | modificación parcial (*no idempotent salvo que lo diseñes así) |
| POST | ❌ | ❌ | crear/acciones (por eso necesita Idempotency-Key) |
Safe = no muta estado del servidor (cacheable, prefetchable). Idempotent = N llamadas idénticas dejan el mismo estado que 1 (retry-safe). POST no es ninguna de las dos: por eso un POST reintentado sobre red poco fiable duplica, y necesitás Idempotency-Key para deduplicar.
Status codes que importan
| Code | Cuándo |
|---|---|
| 200 OK | éxito con body (GET, o POST/PUT que devuelve recurso) |
| 201 Created | recurso creado; devuelve Location al nuevo recurso |
| 204 No Content | éxito sin body (DELETE, PUT sin representación) |
| 301 Moved Permanently | redirect permanente (SEO, cacheable) |
| 302 Found | redirect temporal (no cachear como permanente) |
| 304 Not Modified | respuesta condicional: cliente ya tiene versión válida (ETag/If-None-Match) |
| 400 Bad Request | JSON malformado / sintaxis inválida |
| 401 Unauthorized | no autenticado (falta/expiró credencial) → reta con WWW-Authenticate |
| 403 Forbidden | autenticado pero sin permiso (no reintentes con misma identidad) |
| 404 Not Found | recurso no existe (o se oculta por seguridad en vez de 403) |
| 409 Conflict | choque de estado: duplicado, edición concurrente, versión stale |
| 422 Unprocessable Entity | sintaxis OK pero validación de negocio falla (campos inválidos) |
| 429 Too Many Requests | rate limit; incluye Retry-After |
| 500 Internal Server Error | bug no capturado en la app |
| 502 Bad Gateway | proxy recibió respuesta inválida del upstream (app crasheó/no responde bien) |
| 503 Service Unavailable | app viva pero no puede atender ahora (sobrecarga, deploy, circuit open); Retry-After |
| 504 Gateway Timeout | upstream no respondió a tiempo (app lenta/DB colgada) |
Reglas mentales: 4xx = culpa del cliente, 5xx = culpa del servidor. 401 vs 403: quién sos vs qué podés. 400 vs 422: no te entiendo vs te entiendo pero está mal. 502 vs 503 vs 504: upstream roto vs upstream se niega vs upstream lento.
Headers clave
Authorization: Bearer <jwt> # credencial; NUNCA loggear
Content-Type: application/json # formato del body que ENVÍO
Accept: application/json # formato que quiero RECIBIR (content negotiation)
Accept-Language: es-CO # negociación de idioma
User-Agent: httpx/0.27 # identifica el cliente
Cache-Control: no-store | max-age=60 # política de caché (cliente y proxies)
ETag: "a1b2c3" # huella de la versión del recurso (server la emite)
If-None-Match: "a1b2c3" # cliente la reenvía → server responde 304 si no cambió
If-Match: "a1b2c3" # concurrencia optimista en writes → 409 si cambió
Vary: Accept, Accept-Encoding # qué headers cambian la representación (clave de caché)
Idempotency-Key: 7f3e-... # cliente genera UUID; server deduplica POST reintentado
X-Request-ID: 9a8b-... # correlación de logs cross-servicio (trace un request)
X-Forwarded-For: 203.0.113.5 # IP real del cliente (el proxy la inyecta)
X-Forwarded-Proto: https # esquema original (la app termina en HTTP plano)
Request condicional (ahorra ancho de banda y CPU):
# 1er request: server responde 200 + ETag
curl -i https://api.example.com/users/42
# HTTP/1.1 200 OK
# ETag: "v7"
# 2do request: cliente manda la ETag que ya tiene
curl -i https://api.example.com/users/42 -H 'If-None-Match: "v7"'
# HTTP/1.1 304 Not Modified ← sin body, el cliente reusa su copia
Cookies vs sesiones vs tokens — dónde vive el estado
- Cookie: mecanismo de transporte (el browser la reenvía automáticamente). Flags críticos:
HttpOnly→ no accesible desde JS (mitiga robo por XSS).Secure→ solo se manda por HTTPS.SameSite=Lax|Strict|None→ controla envío cross-site (anti-CSRF).Domain/Path→ scope;Max-Age/Expires→ vida.
- Sesión server-side: la cookie solo lleva un
session_idopaco; el estado real vive en Redis/DB. Revocable al instante; requiere store con estado, HA y baja latencia. - Token (JWT): el estado (claims) va firmado dentro del token, en el cliente. Sin lookup por request (escala), pero revocar antes de la expiración es difícil (necesitás una denylist, que te devuelve al estado server-side). Un JWT es base64 firmado, no cifrado: nunca metas PII sensible ahí.
Patrón senior común: access token JWT de vida corta (minutos) + refresh token de vida larga guardado server-side y revocable. Validás requests sin tocar DB y tenés un punto de revocación. Rotás refresh tokens y detectás reuso para atrapar robos.
Diferencias local vs producción (el corazón del A3)
El mismo código corre distinto según el entorno. No dominar estas diferencias es la causa #1 de bugs “en mi máquina funciona”:
Environment / configuración. La config nunca se hardcodea ni se ramifica con if local:. Se inyecta por variables de entorno (12-factor). El código es idéntico; el entorno cambia los valores.
# settings.py — mismo código, distinto entorno vía env vars
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
environment: str = "local" # "local" | "staging" | "production"
debug: bool = False # DEFAULT seguro; solo local lo pone True
database_url: str # inyectado, nunca hardcodeado
secret_key: str # inyectado desde secrets manager en prod
log_level: str = "INFO"
class Config:
env_file = ".env" # SOLO en local; en prod NO existe .env
settings = Settings()
| Aspecto | Local | Producción |
|---|---|---|
| Environment | .env en disco, localhost, SQLite/Postgres local | env vars inyectadas por el orquestador; hosts reales |
| Secrets | .env (nunca commiteado, en .gitignore) | secrets manager (Vault, AWS/GCP Secrets Manager, K8s Secrets); rotables, auditados |
| Logging level | DEBUG (verboso, humano, a stdout coloreado) | INFO/WARNING, JSON estructurado a stdout → agregador (ELK, Datadog); con X-Request-ID |
| Debug mode | True (stack traces en el browser, autoreload) | False SIEMPRE — un traceback expuesto filtra código, rutas y a veces secrets/RCE |
| Server | uvicorn --reload / flask run (single process, hot reload) | gunicorn+uvicorn workers, sin reload, detrás de nginx |
| TLS | HTTP plano o cert self-signed | cert válido de CA, TLS termina en el edge |
| DB | pool chico, datos de prueba, migraciones a mano | pool dimensionado, réplicas de lectura, migraciones en pipeline |
| Errores | mostrados al dev | página genérica al usuario + detalle a Sentry con contexto |
Debug mode — la trampa crítica. DEBUG=True en producción es un incidente de seguridad, no una molestia:
- Django con
DEBUG=Truemuestra un traceback interactivo que lista settings, variables locales y a veces valores de secrets. - Flask/Werkzeug con
debug=Trueexpone el debugger PIN que, si se filtra, permite ejecución de código arbitrario en el server. - Además desactiva optimizaciones y chequeos de
ALLOWED_HOSTS.
Regla dura: el default del código es debug=False; solo el entorno local lo sube a True. Nunca al revés.
Logging. En local querés logs humanos y verbosos. En prod querés JSON estructurado a stdout (el orquestador/contenedor lo captura y lo manda al agregador), con nivel INFO+, e incluyendo siempre X-Request-ID para poder trazar un request a través de servicios. Nunca loggees Authorization, cookies, tokens ni bodies con PII.
import logging, json, sys
# prod: JSON estructurado a stdout, nivel desde env
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"level": record.levelname,
"msg": record.getMessage(),
"request_id": getattr(record, "request_id", None),
"logger": record.name,
})
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=settings.log_level, handlers=[handler])
# los niveles: DEBUG < INFO < WARNING < ERROR < CRITICAL
# local: DEBUG (ves todo) · prod: INFO/WARNING (señal, no ruido)
Secrets. En local, un .env en .gitignore (nunca commiteado) alcanza. En prod, los secrets viven en un secrets manager: se inyectan como env vars o se leen en runtime, son rotables sin redeploy, tienen auditoría de acceso y RBAC. El código lee settings.secret_key igual en ambos casos; cambia de dónde viene el valor. Nunca en el repo, nunca en la imagen Docker, nunca en logs.
Seguridad básica en el lifecycle (A3)
Puntos que un senior debe poder abordar sobre este recorrido:
- Access token: credencial de vida corta en
Authorization: Bearer; validada por firma sin tocar DB (JWT) o contra un store (opaco). - Secrets manager: fuente de verdad de credenciales/keys fuera del código; rotables y auditados.
- RBAC / roles / service accounts / IAM: la autorización (403) se decide por rol/permiso; los servicios entre sí usan service accounts con permisos mínimos (least privilege).
- CSRF: para apps con cookies de sesión, un sitio malicioso puede disparar requests autenticados; se mitiga con
SameSite+ tokens anti-CSRF. Las APIs conAuthorizationheader (no cookies) son inmunes por diseño. - CORS: no es seguridad del server, es política del browser sobre qué orígenes pueden leer respuestas; se configura como middleware, con allow-list explícita (no
*con credenciales).
Ejercicios
1. Trazá el recorrido y ubicá el fallo. Un usuario reporta que https://api.example.com/orders devuelve intermitentemente 504. Enumerá, salto por salto (DNS → TLS/proxy → app server → app → DB), qué revisarías en cada uno y por qué el 504 apunta más a unas capas que a otras.
Solución
Un 504 Gateway Timeout lo emite el proxy porque el upstream no respondió a tiempo (proxy_read_timeout). Por definición descarta DNS y TLS (ya se conectó al upstream) y descarta que la app haya crasheado (eso sería 502). El foco está en app → DB:
- DNS: irrelevante para un 504 (ya resolvió y conectó). Solo lo miraría si el síntoma fuera fallo de conexión, no timeout.
- TLS/proxy (nginx): verificar
proxy_read_timeout— quizás está demasiado bajo para un endpoint legítimamente lento. Revisar si el proxy tiene todos los workers ocupados esperando. - App server (gunicorn/uvicorn): ¿workers saturados? En sync, N workers = N requests; si todos esperan la DB, los nuevos hacen cola. Revisar métricas de workers busy.
- App: ¿hay una operación bloqueante en un
async defque congela el loop? ¿Falta timeout en el cliente HTTP a una API externa? - DB (foco principal): connection pool saturado, query sin índice (
EXPLAIN ANALYZE), locks, o falta destatement_timeout. Los 504 intermitentes suelen ser una query lenta que aparece bajo cierta carga/datos.
Conclusión: el 504 es casi siempre causado downstream (DB) aunque se manifieste en el proxy. Se traza upstream con X-Request-ID desde el log de nginx hasta el slow-query log.
2. Configuración local vs prod sin ramificar el código. Tenés un handler que debe usar sqlite:///./dev.db en local y un Postgres gestionado en prod, mostrar tracebacks en local pero no en prod, y leer el SECRET_KEY de un .env local pero de un secrets manager en prod. Mostrá cómo lo resolvés sin un solo if environment == "local" en la lógica.
Solución
Toda la variación se externaliza a environment variables (12-factor); el código es idéntico en ambos entornos y solo lee settings:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str # local: sqlite:///./dev.db (del .env)
# prod: postgresql://... (env var inyectada)
secret_key: str # local: del .env; prod: del secrets manager
debug: bool = False # default seguro; SOLO el .env local lo pone True
log_level: str = "INFO"
class Config:
env_file = ".env" # existe solo en local; en prod no hay archivo
settings = Settings()
# handler / app — no sabe nada del entorno:
engine = create_engine(settings.database_url)
app = FastAPI(debug=settings.debug) # False en prod porque el default es False
- Local: un
.env(en.gitignore) conDATABASE_URL=sqlite:///./dev.db,DEBUG=true,SECRET_KEY=dev-only. - Prod: el orquestador (K8s/ECS) inyecta
DATABASE_URLySECRET_KEYcomo env vars leídas del secrets manager; no hay.env,DEBUGqueda en su defaultFalse.
La clave: debug defaultea a False, así un olvido nunca expone tracebacks en prod. El “cambio” entre entornos es de datos (valores de env), no de código.
3. Idempotencia de un POST de pago. Diseñá el manejo de Idempotency-Key para POST /charges de forma que un reintento del cliente (por response perdida) no cobre dos veces, y que dos reintentos concurrentes no ejecuten el cargo dos veces por una race condition.
Solución
# El cliente genera un UUID por intento lógico y lo reenvía en cada retry.
# Server: deduplica guardando (key → resultado) con TTL, y usa una inserción
# atómica para ganar la carrera entre reintentos concurrentes.
async def create_charge(key: str, body: ChargeIn, client_id: str):
# el scope de la key incluye el cliente y el fingerprint del body
scoped = f"{client_id}:{key}"
# 1) inserción atómica: unique constraint sobre la key.
# Si dos requests llegan a la vez, solo UNO inserta; el otro choca.
try:
await db.execute(
"INSERT INTO idempotency (key, body_hash, status) VALUES ($1,$2,'pending')",
scoped, hash_body(body),
)
except UniqueViolation:
existing = await db.fetchrow("SELECT * FROM idempotency WHERE key=$1", scoped)
if existing["body_hash"] != hash_body(body):
raise HTTPException(422, "key reused with different body") # no replay silencioso
if existing["status"] == "pending":
raise HTTPException(409, "in progress, retry later") # el otro aún ejecuta
return stored_response(existing) # replay del resultado
# 2) somos el ganador de la carrera: ejecutamos el cargo UNA vez
result = await charge_provider.charge(body)
await db.execute(
"UPDATE idempotency SET status='done', response=$2 WHERE key=$1",
scoped, serialize(result),
)
return result
Puntos clave: (a) el UNIQUE constraint sobre la key resuelve la race sin lock explícito — solo un insert gana; (b) el scope incluye client_id + hash del body, así una key reusada con otro body es 422, no un replay incorrecto; (c) el estado pending maneja reintentos concurrentes (409 retry later); (d) TTL para no crecer infinito. PUT no necesita esto porque ya es idempotent por spec.
4. WSGI sync vs ASGI async bajo carga I/O. Un endpoint hace 3 llamadas HTTP a APIs externas (200ms cada una) y devuelve. Bajo 500 requests concurrentes, ¿cómo se comporta con gunicorn sync (4 workers) vs uvicorn async? ¿Qué error introduce meter un requests.get() (bloqueante) en el handler async?
Solución
-
gunicorn sync, 4 workers: cada worker atiende 1 request a la vez y se bloquea ~600ms (3×200ms secuenciales, o el tiempo de la más lenta si se paralelizan con threads). Con 4 workers, la capacidad es ~4 requests en vuelo; los otros 496 hacen cola. La latencia p99 se dispara y aparecen timeouts/503. Mitigación sin reescribir: worker-class
gevent(monkey-patch) para concurrencia por green threads. -
uvicorn async: un solo worker multiplexa cientos de requests. Mientras un handler
awaitea las llamadas externas (conhttpx.AsyncClient+asyncio.gather), el loop atiende otros requests. Las 3 llamadas se pueden paralelizar (gather→ ~200ms, no 600ms). 500 concurrentes se manejan sin problema si las dependencias aguantan. -
La trampa: meter
requests.get()(síncrono/bloqueante) dentro de unasync defbloquea el event loop completo durante los 200ms de cada llamada. Con eso, uvicorn deja de multiplexar: todos los requests de ese worker se serializan y perdés toda la ventaja async — se comporta peor que sync. La corrección: usar un cliente async (httpx.AsyncClient) o, si la lib es solo síncrona,await asyncio.to_thread(requests.get, url)para sacarla del loop.
5. Caché barata sin servir stale. Un endpoint GET /reports/{id} es read-heavy y caro de serializar, pero los datos cambian ocasionalmente. Diseñá una estrategia con ETag/Cache-Control que evite servir datos viejos y a la vez ahorre CPU/ancho de banda. Señalá la trampa de Vary.
Solución
@app.get("/reports/{id}")
async def get_report(id: int, request: Request):
version = await db.fetchval("SELECT updated_at FROM reports WHERE id=$1", id)
etag = f'"{id}-{int(version.timestamp())}"' # ETag barata desde updated_at
if request.headers.get("If-None-Match") == etag:
return Response(status_code=304, headers={"ETag": etag}) # sin body, sin serializar
report = await expensive_serialize(id) # solo si cambió
return JSONResponse(
report,
headers={
"ETag": etag,
"Cache-Control": "max-age=0, must-revalidate", # revalida siempre
"Vary": "Accept, Accept-Encoding, Authorization",
},
)
- Separá los ejes:
max-agecontrola cuánto tiempo salteás el server;ETag/If-None-Matchcontrola revalidación barata cuando sí lo tocás.max-age=0, must-revalidate+ ETag = siempre revalida, pero pagás solo un304(sin serializar ni transferir) cuando nada cambió. - ETag barata: derivala de un
updated_at/versión, no de correr la query cara completa. - Trampa
Vary: debe incluir todo header que cambie la representación (Accept,Accept-Encoding,Authorization). Si lo omitís, un proxy compartido puede servir la respuesta de un usuario a otro (cache poisoning / leak). ConAuthorizationenVary, la caché no mezcla respuestas entre usuarios. - Bonus: la misma ETag sirve para concurrencia optimista en writes vía
If-Match(→409en conflicto).
Preguntas tipo entrevista (EN)
Q1: “Walk me through what happens from typing a URL in the browser to getting the response back. Be specific about each hop.”
- ❌ Wrong / trap: “The browser sends a request to the server, the server runs my code and sends back HTML.” — collapses 6+ distinct hops into two; reads as never having debugged past the app layer — you can’t diagnose a 502 vs 504 with this model.
- ✅ Correct: DNS resolution (cache → resolver → authoritative) → TCP + TLS handshake → reverse proxy (nginx) terminates TLS and routes → WSGI/ASGI app server (gunicorn/uvicorn) hands the request to the app → routing/middleware/auth → business logic hits DB/cache → response travels back up the same path with a status code and headers.
- ⭐ Optimal (senior): Same path, but call out what can fail and what state lives where at each hop: DNS TTL and split-horizon; TLS termination at the edge (so the app sees plaintext +
X-Forwarded-Proto); nginx handling rate-limit, buffering and connection reuse; the app server’s worker model (sync gunicorn blocks a worker per request, async uvicorn multiplexes on one loop) which dictates concurrency; the DB connection pool as the usual bottleneck and the source of 504s if timeouts aren’t set. Also note where a 502 (bad upstream response — app crashed) differs from a 504 (upstream timeout — app/DB slow), because that’s the first thing to check in an incident.
Q2: “What’s the difference between an idempotent and a safe method? Why does POST need an Idempotency-Key but PUT doesn’t?”
- ❌ Wrong / trap: “Safe means read-only and idempotent means it can be retried; GET and POST are both idempotent since they don’t change data if you send the same thing.” — POST is neither safe nor idempotent by spec; this conflates the two properties and gets POST wrong.
- ✅ Correct: Safe = no server-side mutation (GET, HEAD). Idempotent = N identical calls leave the same server state as 1 (GET, PUT, DELETE). PUT is idempotent because it’s a full replacement — repeating it converges to the same resource. POST creates a new resource each time, so a retried POST duplicates; the
Idempotency-Keylets the server deduplicate by remembering the key’s result. - ⭐ Optimal (senior): Idempotency is about retry-safety under an unreliable network: the client can’t tell a lost response from a lost request, so it retries — and for POST that means double charges. The server stores the
Idempotency-Keywith the response (in Redis with a TTL, keyed per client) and replays the stored result on a repeat, returning the same status. Edge cases matter: concurrent retries need a lock or an atomic insert (unique constraint on the key) to avoid a race where both execute; and the key scope must include the request fingerprint so a reused key with a different body is a 409/422, not a silent wrong replay. PATCH is not idempotent by default unless you design it that way (e.g. conditional viaIf-Match/ETag).
Q3: “A client got a 401, then a 403, then a 404 on three different requests. What does each tell you, and how do you decide between them when designing an endpoint?”
- ❌ Wrong / trap: “They’re all auth errors — 401 and 403 mean not logged in, 404 means the URL is wrong. I’d return 403 for anything permission-related.” — 401 is authentication (who you are), 403 is authorization (what you can do); treating them as interchangeable breaks client retry logic and leaks intent.
- ✅ Correct: 401 = not authenticated: missing/expired/invalid credential; the server should send
WWW-Authenticateand the client should re-auth. 403 = authenticated but not permitted; re-sending the same credential won’t help. 404 = resource doesn’t exist. When designing, use 401 if no valid identity, 403 if identity is valid but lacks the right, 404 if the resource genuinely isn’t there. - ⭐ Optimal (senior): The subtle call is 403 vs 404 for authorization on existing resources: returning 403 leaks that the resource exists (enumeration/IDOR signal). For sensitive resources return 404 to a user who can’t access it, so an attacker can’t distinguish “forbidden” from “absent.” Also make sure 401s trigger token refresh in the client (not an infinite loop), that we never leak why auth failed in the body (timing/oracle attacks), and that these are logged with
X-Request-IDso a support ticket maps to the exact request. And 429 is its own bucket — a valid, authorized client just going too fast — withRetry-After.
Q4: “How would you use ETag and Cache-Control to make a read-heavy endpoint cheaper without serving stale data?”
- ❌ Wrong / trap: “I’d set
Cache-Control: max-age=3600so the browser caches it for an hour and skips the server.” — a long max-age serves stale data for up to an hour with no way to revalidate — fine for immutable assets, wrong for data that can change. - ✅ Correct: Emit an
ETag(a hash/version of the resource) on responses. The client stores it and sendsIf-None-Matchon the next request; if unchanged, the server returns304 Not Modifiedwith no body, saving bandwidth. Pair it withCache-Control: no-cache(revalidate every time) or a shortmax-agefor a freshness window. - ⭐ Optimal (senior): Distinguish the axes:
max-agecontrols how long you skip the server,ETag/If-None-Matchcontrols cheap revalidation when you do hit it. A strong pattern isCache-Control: max-age=0, must-revalidate+ ETag: always revalidate, but pay only for a 304 when nothing changed — still executes the (possibly cheap) version check but skips serialization/transfer. Trade-offs: ETag generation shouldn’t require the full expensive query (derive it from a cheapupdated_at/version column), weak vs strong ETags matter behind gzip, andVarymust include headers that change the representation (Accept,Accept-Encoding, auth) or a shared proxy serves one user’s response to another — a real cache-poisoning/leak risk. ETags also double as optimistic concurrency control viaIf-Matchon writes (returns 409 on conflict).
Q5: “Where should a web session live — in a JWT or a server-side store? Defend your choice for a fintech app.”
- ❌ Wrong / trap: “JWTs are stateless and modern, so always use JWT — it scales better because you don’t hit a database.” — ignores that JWTs can’t be revoked before expiry, which is disqualifying for fintech (stolen token stays valid, no forced logout).
- ✅ Correct: It’s a trade-off. JWT keeps the app stateless (claims signed in the token, no per-request lookup) so replicas scale freely, but revocation before expiry needs a denylist. Server-side sessions (opaque id → Redis) give instant revocation and small payloads at the cost of a stateful, low-latency dependency. For fintech, revocation and forced logout usually win → server-side sessions, or short-lived access tokens + refresh tokens with a revocable refresh store.
- ⭐ Optimal (senior): Go with short-lived JWT access tokens (minutes) + long-lived refresh tokens stored server-side and revocable — you get stateless request validation and a revocation point. Concretely: access token carries claims and is checked with no DB hit; on expiry the client exchanges the refresh token, which we can invalidate on logout/compromise (rotate refresh tokens and detect reuse to catch theft). Cookie hygiene is non-negotiable:
HttpOnly(no XSS token theft),Secure,SameSite=Lax/Strict(CSRF), scoped path/domain. Failure modes to design for: clock skew on token expiry, key rotation (kidheader + JWKS), and never putting sensitive PII in the JWT since it’s only base64, not encrypted. The whole thing is observable viaX-Request-IDcorrelation so a security event traces end-to-end.
Q6: “During an incident your endpoint returns 502s, then 503s, then 504s. Walk me through what each points to and how you’d triage.”
- ❌ Wrong / trap: “They’re all server errors in the 500 range, so the server is down — I’d restart the app and the load balancer.” — these are three different failure signatures at different layers; a blind restart can mask the real cause (e.g. a saturated DB) and cause repeat outages.
- ✅ Correct: 502 Bad Gateway = the proxy got an invalid/empty response from the upstream — the app process crashed or returned garbage. 503 Service Unavailable = the app is up but refusing work (overload, deploy, or a circuit breaker open); it should send
Retry-After. 504 Gateway Timeout = the upstream didn’t respond in time — the app or a downstream (DB, external API) is hanging. Triage by layer: 502 → check app process/logs for crashes; 503 → check load/autoscaling/deploy state; 504 → check DB/downstream latency and timeouts. - ⭐ Optimal (senior): Read the sequence as a cascade: a slow DB causes 504s (requests pile up waiting), worker/connection pools saturate and the app starts shedding or the LB trips → 503s, and if workers OOM or crash under the load you get 502s. So trace upstream: check DB connection pool saturation and slow queries first, because the app-layer symptoms are usually downstream-caused. Mitigations to have in place: tight timeouts at every hop (proxy → app → DB) so a hung dependency fails fast instead of exhausting the pool, a circuit breaker that returns 503 +
Retry-Afterfast instead of queueing, load-shedding, and idempotent retries with backoff+jitter so the recovery stampede doesn’t re-kill the service. Everything correlated byX-Request-IDand alerting on the ratio per code, not raw counts.
Q7: “What’s the difference between an HTTP server and a WSGI/ASGI server? Why do you need both nginx and gunicorn/uvicorn in production?”
- ❌ Wrong / trap: “nginx serves the site and gunicorn is just a faster way to run Python — in production you pick one.” — they solve different problems and sit at different layers; nginx can’t execute Python and the app server shouldn’t face the raw internet.
- ✅ Correct: An HTTP server (nginx) speaks the HTTP protocol, terminates TLS, serves static files, load-balances and buffers — but it can’t run your Python. A WSGI (gunicorn) or ASGI (uvicorn) server is the bridge between sockets/HTTP and your framework via the WSGI/ASGI interface; it manages the worker/process model that runs your code. In production you put nginx at the edge (TLS, rate-limit, static, buffering) and gunicorn/uvicorn behind it running the app.
- ⭐ Optimal (senior): WSGI is a synchronous contract (
app(environ, start_response)) — one worker, one request at a time, blocking; great for CPU-bound and simple reasoning (Django/Flask on gunicorn). ASGI is asynchronous (async def app(scope, receive, send)) — one loop multiplexing thousands of concurrent I/O-bound connections, plus WebSockets/HTTP2 (FastAPI/Starlette on uvicorn). The worker model dictates real concurrency: sync gunicorn capacity ≈ worker count, so a slow DB call blocks a whole worker; async uvicorn stays responsive unless you drop a blocking call into anasync def, which freezes the entire event loop and silently serializes every request on that worker. In prod you often combine them:gunicorn -k uvicorn.workers.UvicornWorkerso gunicorn manages processes/restarts and uvicorn provides the async loop per process. You keep nginx in front because the app server shouldn’t handle TLS, slow-client buffering, or static assets — separation of concerns and a smaller attack surface.
Q8: “What are the key differences between running your app locally and in production, and which one is a security risk if you get it wrong?”
- ❌ Wrong / trap: “Not much — I just change the database URL and deploy the same code. Locally I keep debug on because it’s convenient.” — ignores secrets handling, logging, and that shipping debug mode / a local
.envto prod is a real breach vector. - ✅ Correct: The code is the same; the environment differs and is injected via env vars (12-factor): database URL and hosts, secrets source (
.envlocally vs a secrets manager in prod), logging level (DEBUGlocally vsINFO/WARNINGstructured JSON in prod), debug mode (Truelocally, alwaysFalsein prod), and the server setup (hot-reload single process locally vs gunicorn/uvicorn workers behind nginx with valid TLS). The security-critical one is debug mode. - ⭐ Optimal (senior): The dangerous mistake is
DEBUG=Truein production: Django’s debug page leaks settings, local variables and sometimes secrets in the traceback; Flask/Werkzeug’s debugger exposes a PIN that, if leaked, allows arbitrary code execution on the server. So the code default must bedebug=False, only the local env raises it — never the reverse. Secrets never live in the repo, the Docker image, or logs — locally a git-ignored.env, in prod a secrets manager (Vault/cloud) that’s rotatable, audited and RBAC-scoped, injected as env vars the code reads identically. Logging flips from verbose human DEBUG to structured JSON at INFO+ shipped to an aggregator withX-Request-IDcorrelation, and you never logAuthorization, cookies or PII. The guiding principle: differences between environments are data (env values, secret sources), never branches in code — noif environment == "local"scattered through the logic.
Referencias
- MDN HTTP: https://developer.mozilla.org/en-US/docs/Web/HTTP
- RFC 9110 (HTTP Semantics — métodos, status, headers): https://www.rfc-editor.org/rfc/rfc9110
- RFC 9111 (HTTP Caching — Cache-Control, ETag): https://www.rfc-editor.org/rfc/rfc9111
- IETF Idempotency-Key header (draft): https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- HTTP status codes (MDN): https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
- WSGI (PEP 3333): https://peps.python.org/pep-3333/
- ASGI specification: https://asgi.readthedocs.io/en/latest/specs/main.html
- The Twelve-Factor App (config, logs, dev/prod parity): https://12factor.net/
- OWASP Cheat Sheets (auth, session, CSRF): https://cheatsheetseries.owasp.org/
- gunicorn — deployment & worker types: https://docs.gunicorn.org/en/stable/design.html
- uvicorn — deployment: https://www.uvicorn.org/deployment/
- MDN — TLS / HTTPS: https://developer.mozilla.org/en-US/docs/Web/Security/Transport_Layer_Security