Atlas

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:

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:

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:

  1. Cache del navegador → 2. Cache del OS (getaddrinfo, /etc/hosts primero) → 3. Resolver recursivo (ISP o 8.8.8.8) → 4. Root servers → 5. TLD servers (.com) → 6. Authoritative (el que sabe la IP real).

Puntos senior:

2. TCP + TLS: el canal seguro

Antes de mandar un byte de HTTP:

Puntos senior:

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:

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.

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:

Métodos HTTP: safe / idempotent

MétodoSafe (no muta)Idempotent (N veces = 1 vez)Uso
GETleer
HEADmetadata sin body
PUTreemplazo total (mismo resultado si repetís)
DELETEborrar (2do DELETE → 404, pero estado final igual)
PATCH❌*modificación parcial (*no idempotent salvo que lo diseñes así)
POSTcrear/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

CodeCuándo
200 OKéxito con body (GET, o POST/PUT que devuelve recurso)
201 Createdrecurso creado; devuelve Location al nuevo recurso
204 No Contentéxito sin body (DELETE, PUT sin representación)
301 Moved Permanentlyredirect permanente (SEO, cacheable)
302 Foundredirect temporal (no cachear como permanente)
304 Not Modifiedrespuesta condicional: cliente ya tiene versión válida (ETag/If-None-Match)
400 Bad RequestJSON malformado / sintaxis inválida
401 Unauthorizedno autenticado (falta/expiró credencial) → reta con WWW-Authenticate
403 Forbiddenautenticado pero sin permiso (no reintentes con misma identidad)
404 Not Foundrecurso no existe (o se oculta por seguridad en vez de 403)
409 Conflictchoque de estado: duplicado, edición concurrente, versión stale
422 Unprocessable Entitysintaxis OK pero validación de negocio falla (campos inválidos)
429 Too Many Requestsrate limit; incluye Retry-After
500 Internal Server Errorbug no capturado en la app
502 Bad Gatewayproxy recibió respuesta inválida del upstream (app crasheó/no responde bien)
503 Service Unavailableapp viva pero no puede atender ahora (sobrecarga, deploy, circuit open); Retry-After
504 Gateway Timeoutupstream 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

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()
AspectoLocalProducción
Environment.env en disco, localhost, SQLite/Postgres localenv 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 levelDEBUG (verboso, humano, a stdout coloreado)INFO/WARNING, JSON estructurado a stdout → agregador (ELK, Datadog); con X-Request-ID
Debug modeTrue (stack traces en el browser, autoreload)False SIEMPRE — un traceback expuesto filtra código, rutas y a veces secrets/RCE
Serveruvicorn --reload / flask run (single process, hot reload)gunicorn+uvicorn workers, sin reload, detrás de nginx
TLSHTTP plano o cert self-signedcert válido de CA, TLS termina en el edge
DBpool chico, datos de prueba, migraciones a manopool dimensionado, réplicas de lectura, migraciones en pipeline
Erroresmostrados al devpá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:

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:

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 def que 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 de statement_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) con DATABASE_URL=sqlite:///./dev.db, DEBUG=true, SECRET_KEY=dev-only.
  • Prod: el orquestador (K8s/ECS) inyecta DATABASE_URL y SECRET_KEY como env vars leídas del secrets manager; no hay .env, DEBUG queda en su default False.

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 (con httpx.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 un async def bloquea 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-age controla cuánto tiempo salteás el server; ETag/If-None-Match controla revalidación barata cuando sí lo tocás. max-age=0, must-revalidate + ETag = siempre revalida, pero pagás solo un 304 (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). Con Authorization en Vary, la caché no mezcla respuestas entre usuarios.
  • Bonus: la misma ETag sirve para concurrencia optimista en writes vía If-Match (→ 409 en 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.”

Q2: “What’s the difference between an idempotent and a safe method? Why does POST need an Idempotency-Key but PUT doesn’t?”

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?”

Q4: “How would you use ETag and Cache-Control to make a read-heavy endpoint cheaper without serving stale data?”

Q5: “Where should a web session live — in a JWT or a server-side store? Defend your choice for a fintech app.”

Q6: “During an incident your endpoint returns 502s, then 503s, then 504s. Walk me through what each points to and how you’d triage.”

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?”

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?”

Referencias