Ronda de Diseño ML · APIs de serving · ejemplos de C01

API

El gateway: FastAPI en producción

Para escribir desde cero, con la documentación abierta, la API que está entre los clientes y el servidor de modelos, y para diagnosticar por qué va lenta, devuelve 503 o se traba. El gateway del C01 recibe la foto, la valida, la decodifica en CPU sin frenar a los demás requests, llama a Triton con un plazo, rechaza rápido lo que no puede atender a tiempo y deja una línea de log y sus métricas por cada request. Cada sección trae la sintaxis como plantilla con variables, las piezas, un ejemplo real del curso, una figura, un truco para recordarla, la trampa típica y un laboratorio opcional. En la versión web, al final hay un diagnosticador de síntomas, un revisor de código del gateway y un quiz de errores.

Cómo recordarlaTrampa típicavariablela cambias túEjemplos en verde

En las figuras:CPU del gatewayespera: red, cola o turnola GPU (Triton)okrechazado a propósito: 4xx o 503falla: 500, 502, 504 o se cortafuera del gateway

0

Números de bolsillo: códigos HTTP, timeouts, límites del gateway del C01 y Little's law

¿Qué códigos, tiempos y límites conviene saberse de memoria para leer una respuesta del gateway, o un incidente, en segundos?

Sintaxis con variables

curl -si -H "Content-Type: tipo" --data-binary @{foto} "host/v1/classify?top_k=k"

Manda una foto como lo hace el backend de publicación (el servidor del marketplace que llama al C01). Con -i ves la línea de estado (el código y su frase) y los headers: X-Request-ID viene en el 200, en los errores que decide la función y en el 422 de FastAPI, que pasa por su exception handler (un 500 por bug no lo trae), y Retry-After en los 503 de carga y de Triton. Si le hablas directo a uvicorn (el servidor que corre la app), la frase la pone él según tu Python: desde 3.13 dice 413 Content Too Large y 422 Unprocessable Content, los nombres del RFC 9110 (el documento que define qué significa cada código).

raise HTTPException(codigo, "mensaje", headers={"Retry-After": "segundos"})

Así responde el C01 cada error que decide: FastAPI lo convierte en {"detail": "…"} con ese código y esos headers. Un 4xx dice «el request está mal: no lo repitas igual» (el 429 es la excepción: «espera y reintenta»). Un 503 con Retry-After dice «estoy lleno o mi dependencia no está: vuelve en segundos s».

left = deadline - time.perf_counter()
resultado = await asyncio.wait_for(llamada, timeout=min(tope, left))

Deadline propagation (propagar la hora límite): el request tiene un presupuesto total (250 ms en el C01) y cada etapa usa lo que queda, nunca más que su propio tope (150 ms para Triton). Si ya no queda nada, el C01 responde 504 sin llamar a Triton.

python3.13 -c 'from http import HTTPStatus; print(HTTPStatus(codigo).phrase)'

La frase oficial de cada código en tu versión de Python; uvicorn la toma de ahí. Con 3.12 o anterior verás los nombres viejos: Request Entity Too Large y Unprocessable Entity.

Variables

VariableQué esEjemplo
tipoformato de la foto, en el header Content-Typeimage/jpeg (también image/png e image/webp)
fotoarchivo que mandas como body, en bytes crudosfoto.jpg
hostdónde escucha el gatewaylocalhost:8080 con compose; en EKS (el Kubernetes de AWS), el balanceador interno (ALB)
kcuántas categorías pides5 por defecto, de 1 a 20
codigo · mensajecódigo HTTP y texto para el cliente503 · gateway saturado: reintenta más tarde
segundoscuánto esperar antes de reintentar1 en el C01
llamada · topela espera lenta que acotas y el máximo propio de esa etapaclient.infer(...) · INFER_TIMEOUT_S = 0.15
LLrequests en vuelo: aceptados y todavía sin respuesta, en cualquier etapa≈ 3–5 por pod en el pico
λ\lambdarequests por segundo que llegan a un pod500 req/s ÷ 5 pods = 100
WWtiempo promedio que pasa cada request adentro (el promedio, no el p99 de los más lentos)≈ 50 ms normal; 200 ms si todo va lento
tcaller…tcolat_{\text{caller}} \ldots t_{\text{cola}}timeout de cada nivel: quien llama, el gateway, la llamada a Triton y la cola de Triton300 · 250 · 150 · 100 ms
t0t_{0}el momento en que el request llegó a la funcióntime.perf_counter() al entrar a classify
r, nr,\ nreintentos por capa y cuántas capas reintentan por su cuenta3 y 3 → hasta 64 intentos por request

Fórmulas

L=λ W100 reqs×0.05 s=5100 reqs×0.2 s=20  →  MAX_INFLIGHT=16L = \lambda\,W \qquad 100\ \tfrac{\text{req}}{\text{s}} \times 0.05\ \text{s} = 5 \qquad 100\ \tfrac{\text{req}}{\text{s}} \times 0.2\ \text{s} = 20 \;\to\; \texttt{MAX\_INFLIGHT} = 16
tcaller>tdeadline>tTriton>tcola300>250>150>100 mst_{\text{caller}} > t_{\text{deadline}} > t_{\text{Triton}} > t_{\text{cola}} \qquad 300 > 250 > 150 > 100\ \text{ms}
tTriton=min⁡(150 ms,  t0+250 ms−ahora)≤0  ⇒  504 sin llamart_{\text{Triton}} = \min\big(150\ \text{ms},\; t_0 + 250\ \text{ms} - \text{ahora}\big) \qquad \le 0 \;\Rightarrow\; 504\ \text{sin llamar}
RAM≈80⏟Python+2×320⏟decodes de 40 MP+16×10⏟bodies en vuelo=880 MB<1 GiB≈1,074 MB\text{RAM} \approx \underbrace{80}_{\text{Python}} + \underbrace{2 \times 320}_{\text{decodes de 40 MP}} + \underbrace{16 \times 10}_{\text{bodies en vuelo}} = 880\ \text{MB} < 1\ \text{GiB} \approx 1{,}074\ \text{MB}
intentos=(1+r)nr=3, n=3  ⇒  43=64\text{intentos} = (1 + r)^{n} \qquad r = 3,\ n = 3 \;\Rightarrow\; 4^{3} = 64

Las piezas

PiezaQué haceEjemplo
200 OKRespuesta con las k categorías, su score, model_version y latency_ms.header X-Request-ID para reclamar un caso
400 Bad RequestEl request está roto: body vacío, bytes que no son una imagen, o una relación de aspecto mayor que 1:8.no se pudo decodificar la imagen
413 Content Too LargeDemasiado grande: más de 10 MB (por Content-Length o contando los bytes) o más de 40 MP (40 millones de píxeles, leídos del header de la imagen).la imagen supera 10 MB
415 Unsupported Media TypeFormato no aceptado: el Content-Type no es image/jpeg, image/png ni image/webp.un GIF, o application/octet-stream
422 Unprocessable ContentLo genera FastAPI cuando un parámetro no pasa la validación, antes de llamar a tu función. El body trae la lista de errores.top_k=0 o top_k=500
499 (el cliente se fue)No está en el RFC: es la convención de nginx para «el cliente cerró la conexión antes de la respuesta». El C01 lo anota cuando el cliente corta a mitad del upload; nadie recibe esa respuesta, y no cuenta como error del servidor.el cliente cerró la conexión antes de terminar el upload
429 Too Many RequestsLímite por cliente (rate limit, del RFC 6585): «mandaste demasiados en este rato». Puede traer Retry-After.el main.py del C01 no lo emite: la cuota por caller va en el borde (una regla del WAF, el firewall de aplicaciones de AWS) o en un contador por identidad. Ojo: el WAF bloquea con 403 salvo que le configures una respuesta 429
500 Internal Server ErrorAlgo que nadie atrapó: un bug. Lo responde la capa de Starlette (la base de FastAPI) que atrapa cualquier excepción suelta. El C01 también lo usa a propósito: si Triton devuelve logits con NaN, responde un 500 propio.el C01 arranca su contador con status = 500, por si algo explota
502 Bad GatewayQuien está delante recibió una respuesta inválida, o una conexión cerrada, del que está detrás.el ALB, si uvicorn cierra una conexión ociosa (keep-alive) que el ALB iba a reusar: por eso --timeout-keep-alive 75 > 60
503 + Retry-After: 1 (carga)Load shedding (rechazar al entrar en vez de encolar): ya hay 16 requests en vuelo, o en 50 ms no se liberó ningún turno de CPU (slot) para decodificar.gateway saturado: reintenta más tarde
503 + Retry-After: 1 (Triton)Falló la dependencia: Triton no responde (error de conexión) o rechazó la inferencia.Triton no disponible
503 de otros/readyz sin modelo (lo lee el kubelet, el agente de Kubernetes que corre los probes, los chequeos de salud; va sin Retry-After); el ALB cuando no le queda ningún pod listo; uvicorn con --limit-concurrency (texto plano: Service Unavailable). Ninguno pasa por tu manejo de errores.el modelo no está listo en Triton
504 Gateway TimeoutSe acabó el tiempo: Triton no respondió en min(150 ms, lo que queda), o los 250 ms se gastaron antes de llamarlo.timeout de inferencia
Retry-AfterHeader que dice en cuántos segundos reintentar (o desde qué fecha).Retry-After: 1
X-Request-IDId del request: el C01 usa el que trae el cliente o un proxy (un intermediario), y si no viene, inventa uno. Une la respuesta con su línea de log.abc123, o uuid4().hex
LímiteDefaultEn el C01Por qué
Requests en vuelo por procesosin límite (--limit-concurrency de uvicorn no tiene default)MAX_INFLIGHT = 16 → 503 + Retry-After: 1Little: ≈ 100 req/s × 0.2 s ≈ 20, recortado por RAM
Hilos del threadpool40 (el de AnyIO: rutas y dependencias def, y run_in_threadpool)40, pero solo 2 decodes a la vez (un semáforo: un contador de turnos)un decode de 40 MP ocupa ≈ 320 MB
Espera por un slot de CPU—SLOT_WAIT_S = 50 ms → 503 + Retry-After: 1esperar más ya rompe el SLO
Tamaño del bodysin límite (FastAPI y uvicorn; en Starlette, max_body_size=None)10 MB → 413, por Content-Length y contando bytesla app móvil manda ≈ 150–250 KB
PíxelesPillow avisa sobre 89,478,485 px y falla sobre el doble40 MP → 413 (guardia propia); Pillow a 40 MP y 80 MPun PNG de 50 KB puede declarar 30,000 × 30,000 px
Relación de aspecto—hasta 1:8 → si no, 4001 × 4,000 px pasa el límite de píxeles y ocuparía ≈ 1 GB al reescalar
top_k—5; de 1 a 20 → si no, 422nadie necesita 500 sugerencias
Timeout hacia Tritontritonclient aio: conn_timeout = 60 s, un mismo valor para todas las llamadasasyncio.wait_for con min(150 ms, lo que queda); conn_timeout = 1 s de respaldoel timeout de infer() son µs de la cola de Triton, no del cliente
Presupuesto del requestningunoREQUEST_DEADLINE_S = 250 ms → 50450 ms antes que los 300 ms del caller
Conexiones a Tritonconn_limit = 10064acota lo que va en vuelo hacia Triton
Readiness hacia Triton—is_model_ready con tope de 0.5 sel probe del kubelet corta a los 2 s
Keep-alive de uvicorn5 s75 s (más que los 60 s de idle del ALB)si uvicorn cierra primero, el ALB ve un 502
Apagado de uvicorn--timeout-graceful-shutdown: sin límite20 s; preStop de 15 s; plazo del pod de 45 s45 − 15 = 30 s para la app: 20 de drenado + 10 de margen
Procesos por contenedor--workers 1 (o $WEB_CONCURRENCY)--workers 1, escrito explícitose escala con pods, no con procesos

Versiones de hoy contra las del C01: FastAPI 0.160.0 (0.141.1), Starlette 1.9.0 (1.7.0), Pydantic 2.14.1 (2.13.5), Pillow 13.0.0 (12.3.0), aiohttp 3.14.5 (3.14.3), tritonclient 2.73.0 (2.72.0), uvloop 0.23.0 (0.22.1), httptools 0.9.0 (0.8.0); uvicorn 0.54.0, AnyIO 4.15.1 y prometheus-client 0.26.0 siguen iguales. En el medio, FastAPI sumó soporte nativo de OpenTelemetry, el estándar de trazas y métricas (0.142; desde 0.143 el exportador ya no se configura solo) y dejó de validar los defaults que el cliente omite (0.148). Ningún cambio mueve un número de esta tabla.

Los límites del gateway: lo que trae cada librería por defecto contra lo que fija el C01. Verificado contra el código y la documentación de las versiones del C01 (FastAPI 0.141.1, Starlette 1.7.0, uvicorn 0.54.0, AnyIO 4.15.1, Pillow 12.3.0, tritonclient 2.72.0, Python 3.13), en octubre de 2026.

Ejemplo real · La configuración del C01: todos los números de bolsillo en un solo lugar

Pythonreference/c01/gateway/app/main.py (líneas 41–60, extracto; # … marca líneas omitidas)
INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.15"))  # 150 ms para Triton dentro del p99 ≤ 200 ms
# …
# Decodes simultáneos por proceso: acota CPU y RAM. Pico medido de UN PNG de 40 MP: ≈ 320 MB
# (Pillow guarda RGB en 4 bytes/px = 160 MB, más las copias de exif_transpose y convert).
PREPROCESS_CONCURRENCY = int(os.getenv("PREPROCESS_CONCURRENCY", "2"))
# Load shedding: requests aceptados a la vez por proceso; el resto → 503 inmediato con Retry-After.
# ≈ lo que un pod completa en ≈ 200 ms (≈ 100 req/s × 0.2 s ≈ 20) y acotado por RAM: 16 bodies de
# hasta 10 MB = 160 MB, que caben en 1Gi junto a los decodes. Al pico hay ≈ 3–5 en vuelo por pod.
MAX_INFLIGHT = int(os.getenv("MAX_INFLIGHT", "16"))
SLOT_WAIT_S = 0.05  # espera máxima por un slot de CPU: si hay que esperar más, ya no cumple el SLO → 503
# Presupuesto total server-side (< 300 ms del caller). Cada etapa usa lo que queda: deadline propagation
REQUEST_DEADLINE_S = float(os.getenv("REQUEST_DEADLINE_S", "0.25"))
MAX_BYTES = 10 * 1024 * 1024  # 10 MB: tope de subida web (el app móvil manda ~150–250 KB)
MAX_PIXELS = 40_000_000  # 40 MP: por encima es casi seguro un error o un ataque
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}  # los únicos formatos que decodificamos
# …
MAX_TOP_K = 20  # top_k acotado: nadie necesita 500 sugerencias y el log crecería sin control
# Segunda red de Pillow: avisa > 40 MP y falla > 80 MP aunque alguien se salte la guardia de decode()
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
  • INFER_TIMEOUT_S (150 ms) y REQUEST_DEADLINE_S (250 ms) son la mitad de la cadena de timeouts. Las otras dos puntas: los 300 ms del caller y los 100 ms que Triton deja esperar en su cola (en su config.pbtxt).
  • MAX_INFLIGHT = 16 sale de Little: un pod atiende ≈ 100 req/s; si cada request tardara 200 ms, habría ≈ 20 adentro. Se recorta a 16 por RAM: 16 bodies de hasta 10 MB son 160 MB.
  • PREPROCESS_CONCURRENCY = 2: solo 2 decodes a la vez por proceso, aunque el threadpool (el grupo de hilos de reserva) tenga 40. Un PNG de 40 MP ocupa ≈ 320 MB al decodificarse, y el pod tiene 1 GiB.
  • SLOT_WAIT_S = 0.05 no se configura por variable de entorno: si un request espera más de 50 ms por un slot de CPU, ya no llega al SLO (la meta: p99 ≤ 200 ms, el tiempo bajo el que quedan 99 de cada 100 requests) y recibe 503.
  • Lo que viene de os.getenv se cambia sin reconstruir la imagen: k8s/gateway.yaml fija INFER_TIMEOUT_S, PREPROCESS_CONCURRENCY y MAX_INFLIGHT. Image.MAX_IMAGE_PIXELS deja a Pillow como segunda red: avisa sobre 40 MP y falla sobre 80 MP.

Cómo recordarla

4xx: el cliente se equivocó, no lo repitas igual (salvo 429: espera el Retry-After). 503: vuelve en un rato (Retry-After). 504: se acabó el tiempo. 500: bug tuyo. Los números del C01: 16 en vuelo, 2 decodes, 10 MB, 40 MP, y timeouts 300 → 250 → 150 → 100 ms.

Trampa

Leer todo 503 como «el gateway se cayó». En el C01 hay al menos cinco: saturado (16 en vuelo o sin slot de CPU), Triton caído, Triton rechazando, /readyz sin modelo y el del ALB cuando ya no queda ningún pod listo, que ni pasa por el gateway. El detail y el Retry-After dicen cuál es, y el ALB cuenta aparte los 503 que genera él mismo (HTTPCode_ELB_5XX_Count). La otra mitad de la trampa: responder 500 a un timeout del upstream (el servicio de atrás, aquí Triton); para eso existe el 504.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Calcular tú mismo los números de bolsillo: las frases que manda uvicorn, Little, la RAM del peor caso, la cadena de timeouts y la amplificación de reintentos.

Necesitas: solo Python 3.13 (la versión del C01); no instala nada

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway

    Qué deberías ver: Sin salida: es la carpeta de los labs de esta hoja.

  2. Paso 2
    python3.13 -c '
    from http import HTTPStatus
    for c in (200, 400, 413, 415, 422, 429, 500, 502, 503, 504):
        print(c, HTTPStatus(c).phrase)
    '

    Qué deberías ver: Diez líneas, de 200 OK a 504 Gateway Timeout. Fíjate en 413 Content Too Large y 422 Unprocessable Content: son los nombres del RFC 9110, que Python usa desde 3.13. Con 3.12 o anterior verías Request Entity Too Large y Unprocessable Entity, y uvicorn mandaría esas frases.

  3. Paso 3
    cat > numeros.py <<'EOF'
    # Números de bolsillo del gateway del C01 (todo en ms, MB y req/s)
    lam = 500 / 5                                  # pico de diseño repartido en 5 pods
    for w in (0.05, 0.2):                          # latencia media: normal y degradada
        print(f"Little: {lam:.0f} req/s × {w} s = {lam * w:.0f} en vuelo por pod")
    print(f"Llamadas abiertas a Triton si se frena: {lam:.0f} × 0.15 s = {lam * 0.15:.0f} por pod")
    ram = 80 + 2 * 320 + 16 * 10                   # Python + 2 decodes de 40 MP + 16 bodies de 10 MB
    print(f"RAM del peor caso: 80 + 2 × 320 + 16 × 10 = {ram} MB de {2**30 / 1e6:.0f} MB (1 GiB)")
    cadena = [300, 250, 150, 100]                  # caller > deadline > Triton > cola de Triton
    print("Timeouts:", " > ".join(f"{t}" for t in cadena), "ms:", all(a > b for a, b in zip(cadena, cadena[1:])))
    for gastado in (40, 130, 260):                 # ms ya usados antes de llamar a Triton
        queda = 250 - gastado
        print(f"gastado {gastado} → queda {queda} → a Triton:", f"{min(150, queda)} ms" if queda > 0 else "504 sin llamar")
    for capas in (1, 2, 3):
        print(f"3 reintentos en {capas} capa(s): hasta {4 ** capas} intentos por request")
    EOF
    python3.13 numeros.py

    Qué deberías ver: Little: 100 req/s × 0.05 s = 5 en vuelo por pod · Little: 100 req/s × 0.2 s = 20 en vuelo por pod · Llamadas abiertas a Triton si se frena: 100 × 0.15 s = 15 por pod · RAM del peor caso: 80 + 2 × 320 + 16 × 10 = 880 MB de 1074 MB (1 GiB) · Timeouts: 300 > 250 > 150 > 100 ms: True · gastado 40 → queda 210 → a Triton: 150 ms · gastado 130 → queda 120 → a Triton: 120 ms · gastado 260 → queda -10 → a Triton: 504 sin llamar · y 4, 16 y 64 intentos con 1, 2 y 3 capas que reintentan. Cada línea es una fila de esta sección: Little explica el 16, la RAM explica los 2 decodes, y el 64 explica por qué reintenta una sola capa.

  4. Paso 4
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si python3.13 no existe en tu Mac, usa python3: cualquier 3.13 o más nuevo da la misma salida; con 3.12 o anterior cambian las frases de 413 y 422. Si un número no te da, revisa las unidades: λ en req/s, W en segundos y la RAM en MB.

Probado de verdad en macOS (Apple M4), con Python 3.13.16 y 3.14.3. La misma salida. Las frases de HTTPStatus se compararon también con Python 3.9.6, que da Request Entity Too Large y Unprocessable Entity.

↳

La cadena: el viaje de un request dentro del gateway

¿Qué le pasa a un request, en orden, desde que uvicorn recibe los bytes hasta que sale la respuesta y queda su línea de log?

Sintaxis con variables

uvicorn modulo:app --host 0.0.0.0 --port puerto --workers 1 --no-access-log

uvicorn es el servidor ASGI (ASGI: la interfaz estándar entre un servidor y una app async de Python). Abre el puerto, lee el HTTP con httptools (una librería en C que lo interpreta rápido) y le pasa cada request a tu app dentro del event loop (el único hilo que atiende a todos los requests async por turnos). Con uvloop instalado, como en el C01, usa ese event loop, más rápido.

@app.post("ruta")
async def funcion(request: Request, param: int = Query(default, ge=min, le=max)):

FastAPI elige la función por método y ruta, y valida los parámetros antes de llamarla: un top_k fuera de rango recibe 422 sin que corra una sola línea tuya. Con request: Request lees headers y body a mano, como el C01.

resultado = await run_in_threadpool(funcion_cpu, datos)

El paso de CPU (decode, resize, normalizar) va a un hilo del threadpool, el grupo de hilos de reserva. Mientras ese hilo trabaja, el event loop sigue atendiendo a los demás requests.

try:
    ...                                  # admisión, validación, CPU, Triton
finally:
    REQUESTS.labels(status=str(status), source=source, model_version=MODEL_VERSION).inc()
    log.info(json.dumps(record, ensure_ascii=False))

El último eslabón: en el finally se cuenta la métrica y se escribe una línea JSON pase lo que pase, también en errores. Ojo: solo ve los requests que llegaron a tu función. El 422 no llega: el C01 lo anota aparte, con un exception handler.

Variables

VariableQué esEjemplo
modulo:apparchivo (como módulo de Python) y variable que tiene la appapp.main:app
puertopuerto donde escucha8080
ruta · funcionpath del endpoint y la función que lo atiende/v1/classify · classify
param · default · min · maxparámetro de query, su valor por defecto y su rangotop_k · 5 · 1 · 20
funcion_cpu · datosla función que bloquea y sus argumentos_decode_and_hash · los bytes de la foto
resultadolo que devuelve el hilobatch, digest
tALB…tPythont_{\text{ALB}} \ldots t_{\text{Python}}lo que tarda cada tramo, según el presupuesto del C01ver la figura
tslott_{\text{slot}}lo que espera un request por uno de los 2 turnos de CPU (slots) para decodificar≈ 0 normal; tope de 50 ms, después 503
tred, tcola, tGPUt_{\text{red}},\ t_{\text{cola}},\ t_{\text{GPU}}las tres partes de la llamada a Triton: ida y vuelta, cola de batching y cómputo≈ 2–4 · 0–2 · 3–6 ms
p50, p99\text{p50},\ \text{p99}el tiempo bajo el que quedan la mitad, y 99 de cada 100 requests≈ 20–45 ms · ≈ 150 ms (estimación pesimista)
SLO\text{SLO}la meta del servicio: p99 ≤ 200 ms, medido en el servidoralerta si se pasa 10 minutos seguidos

Fórmulas

ttotal≈tALB+tbody+tslot+tdecode+tTriton+tpost+tPythont_{\text{total}} \approx t_{\text{ALB}} + t_{\text{body}} + t_{\text{slot}} + t_{\text{decode}} + t_{\text{Triton}} + t_{\text{post}} + t_{\text{Python}}
tTriton=tred+tcola+tGPU≈(2–4)+(0–2)+(3–6) ms en el p50t_{\text{Triton}} = t_{\text{red}} + t_{\text{cola}} + t_{\text{GPU}} \approx (2\text{–}4) + (0\text{–}2) + (3\text{–}6)\ \text{ms en el p50}
p50≈20–45 msp99 pesimista≈150 ms  <  SLO=200 ms\text{p50} \approx 20\text{–}45\ \text{ms} \qquad \text{p99 pesimista} \approx 150\ \text{ms} \;<\; \text{SLO} = 200\ \text{ms}

Las piezas

PiezaQué haceEjemplo
ALBEl balanceador de AWS: descifra la conexión (TLS), verifica el JWT (un token firmado que prueba quién llama) y elige un pod listo.solo deja pasar /v1/classify
uvicornServidor ASGI: acepta la conexión, lee el HTTP y llama a la app. Un proceso por contenedor.--workers 1 --no-access-log
event loop (uvloop)El único hilo que atiende a todos los requests async, por turnos; uvloop es una versión más rápida.uvicorn lo usa solo si uvloop está instalado
ServerErrorMiddlewareLa capa de más afuera (un middleware: una capa que envuelve a toda la app). Atrapa cualquier excepción suelta y responde 500.el C01 no agrega middlewares propios
ExceptionMiddlewareConvierte un HTTPException en una respuesta JSON con su código y sus headers.{"detail": "gateway saturado: reintenta más tarde"}
Router y validaciónElige la función por método y path, y valida los parámetros con Pydantic (la librería que valida datos según tus tipos). Si fallan: 422, y tu función no corre.top_k=0 → 422
AdmisiónControl de admisión: si ya hay 16 requests en vuelo, 503 inmediato, sin leer el body.state.inflight >= MAX_INFLIGHT
Validación barataContent-Type (415) y Content-Length (413) se miran en los headers, sin leer un byte del body.image/jpeg, image/png, image/webp
Lectura del bodyrequest.stream(): el body llega por partes, y se corta apenas pasa de 10 MB._read_body_limited
PresupuestoAntes de cada etapa cara se mira cuánto queda de los 250 ms; si no queda nada, 504._budget_left(deadline)
Slot de CPU + threadpoolUn semáforo (un contador de turnos) deja 2 decodes a la vez; el decode corre en un hilo aparte.await run_in_threadpool(_decode_and_hash, data)
Llamada a TritonUn cliente async por proceso, tensores en binario y timeout de min(150 ms, lo que queda)._infer → 504 o 503
Postproceso y respuestaSoftmax, top-k, nombres de categoría, model_version y latency_ms, con el header X-Request-ID.{"model_version": …, "predictions": […]}
Métricas y logEn el finally: contador por status, histograma de latencia y una línea JSON por request.classifier_requests_total{status="503"}

Los p99 de los pasos, uno detrás del otro: ≈ 150 ms · en el p50, 20–45 ms

A escala, de 0 a 300 ms; cada color es un paso de la lista, con su número. «Espera de un slot de CPU» (paso 6) no entra en la suma: es un tope, no una medida.

Paso a paso: barras a escala de 0 a 50 ms · p50 · p99

  1. ALB + routing

    borde (AWS)

    p50 1–3p99 5–10 ms

    TLS ya terminado; conexiones abiertas que se reusan (keep-alive)

  2. uvicorn + validación de top_k

    gateway: FastAPI

    µs microsegundos: no se ve a esta escala

    Si no pasa: 422

    antes de tu función: lo anota un exception handler aparte

  3. Admisión

    gateway: classify

    µs microsegundos: no se ve a esta escala

    Si no pasa: 503+ Retry-After: 1

    16 en vuelo por proceso

  4. Content-Type y Content-Length

    gateway: _classify

    µs microsegundos: no se ve a esta escala

    Si no pasa: 415413

    sin leer el body

  5. Lectura del body

    gateway: _read_body_limited

    p50 1–3p99 10–30 ms

    Si no pasa: 413400

    200 KB del celular contra hasta 10 MB de la web; si el cliente corta a la mitad, se anota como 499

  6. Espera de un slot de CPU

    gateway: semáforo de 2

    p50 0p99 0–50 mstope, fuera de la suma

    Si no pasa: 503+ Retry-After: 1

    ≈ 0 normalmente; 50 ms es el tope (SLOT_WAIT_S), no una medida, y no entra en la suma

  7. Decode + resize 256 + crop 224

    gateway: threadpool

    p50 5–12p99 25–50 ms

    Si no pasa: 413(> 40 MP)400

    en JPEG grandes, el decoder achica mientras decodifica (draft mode)

  8. Normalizar → tensor FP32

    gateway: threadpool

    p50 1p99 2 ms

    602 KB por foto

  9. Salto a Triton

    red interna de AWS (la VPC)

    p50 2–4p99 5–10 ms

    Si no pasa: 504503

    timeout de min(150 ms, lo que queda)

  10. Cola de dynamic batching

    Triton

    p50 0–2p99 2 ms

    Si no pasa: 503(rechazo a los 100 ms)

    espera hasta 2 ms para juntar requests

  11. Cómputo en la T4

    Triton (GPU)

    p50 3–6p99 8–15 ms

    TensorRT FP16, batch de 1 a 8

  12. Softmax + top-k + JSON + log

    gateway

    p50 1p99 2–3 ms

    Si no pasa: 500(logits con NaN)

    sobre 500 valores

  13. Overhead de Python

    gateway: event loop, threadpool y recolector de basura (GC)

    p50 5–10p99 10–30 ms

    repartido entre todos los pasos

fuera del gateway (ALB)CPU del gatewayespera: red, cola o turnola GPU (Triton)rechazado a propósito (4xx o 503)falla (500, 502, 504)

Un request de /v1/classify por dentro, con el presupuesto del C01 (valores típicos a medir, no benchmarks). Las barras van a escala con el p99; las revisiones baratas cuestan microsegundos. Los p99 no se suman de verdad: su suma (≈ 150 ms) es una estimación pesimista que deja ≈ 50 ms de margen al SLO de 200 ms.

Ejemplo real · classify: admisión, la cadena con su deadline, y el finally que siempre anota

Pythonreference/c01/gateway/app/main.py (líneas 206–249, extracto; # … marca líneas omitidas)
@app.post("/v1/classify")
async def classify(request: Request, response: Response, top_k: int = Query(5, ge=1, le=MAX_TOP_K)):
    """Body = bytes crudos de la imagen (no multipart: menos parsing, sin archivos temporales)."""
    t0 = time.perf_counter()  # el reloj arranca con el request ya recibido (el SLO es server-side)
    # Si el cliente (o un proxy) ya trae un id lo respetamos: así se traza el request de punta a punta
    request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
    # …
    status = 500  # si algo inesperado explota, se cuenta como 500
    try:
        state = request.app.state
        if state.inflight >= MAX_INFLIGHT:  # fallar rápido (µs) > encolar lo que va a vencer de todos modos
            raise HTTPException(503, "gateway saturado: reintenta más tarde", headers={"Retry-After": "1"})
        state.inflight += 1
        try:
            result = await _classify(request, top_k, record, deadline=t0 + REQUEST_DEADLINE_S)
        finally:
            state.inflight -= 1  # siempre, también si _classify lanza
        status = 200
        response.headers["X-Request-ID"] = request_id  # el cliente puede reportar un caso con este id
        result["latency_ms"] = round((time.perf_counter() - t0) * 1000, 1)
        return result
    except HTTPException as exc:
        status = exc.status_code
        record["error"] = exc.detail
        # Re-lanzamos con el X-Request-ID para que también los errores sean trazables
        headers = {**(exc.headers or {}), "X-Request-ID": request_id}
        raise HTTPException(exc.status_code, exc.detail, headers=headers) from None
    finally:
        elapsed = time.perf_counter() - t0
        # …
        REQUESTS.labels(status=str(status), source=source, model_version=MODEL_VERSION).inc()
        if not 400 <= status < 500:  # los 4xx son rechazos rápidos: incluirlos "mejoraría" el p99 falsamente
            LATENCY.labels(source=source).observe(elapsed)
        # …
        log.info(json.dumps(record, ensure_ascii=False))
  • Antes de la primera línea de classify ya pasaron uvicorn, los middlewares y la validación de top_k (Query(5, ge=1, le=MAX_TOP_K)). Un 422 se responde ahí y no llega a este código: su línea y su conteo los escribe un exception handler aparte (validation_error).
  • La admisión es la primera decisión de la función: con 16 en vuelo responde 503 en microsegundos, sin leer el body ni gastar CPU. El contador no necesita lock (un candado entre hilos): lo toca un solo hilo, el event loop.
  • t0 + REQUEST_DEADLINE_S es el deadline (la hora límite) que viaja a _classify. Ahí se revisan los headers (415 y 413), se lee el body, se espera el slot de CPU, se decodifica en el threadpool y se llama a Triton. Antes del decode y antes de Triton se mira cuánto queda: si nada, 504; Triton recibe min(150 ms, lo que queda).
  • Los errores se vuelven a lanzar con X-Request-ID: el cliente puede reclamar un 503 o un 504 con su id, y tú lo encuentras en el log.
  • El finally corre siempre: cuenta el request por status, por quién llama (caller) y por modelo, mide la latencia (sin los 4xx, que «mejorarían» el p99 falsamente) y escribe una línea JSON sin la imagen ni datos personales.

Cómo recordarla

Lo barato primero, la CPU después, la GPU al final, y el log siempre. Cada eslabón puede cortar con su código (422, 503, 415, 413, 400 o 499, 504 y 500), y el finally anota todo lo que llegó a classify; el 422 no llega, y lo anota su exception handler. Si el p99 sube, la cadena te dice dónde mirar: el body, el decode o Triton.

Trampa

Creer que tu métrica y tu log ven todos los requests. El 422 de top_k lo responde FastAPI antes de llamar a classify: no pasa por su finally. Por eso el C01 registra un exception handler (una función que FastAPI llama ante ese error) que escribe su línea y lo cuenta. Un 405 (un GET a /v1/classify) no pasa ni por ahí, y el C01 apagó el access log de uvicorn (la línea que uvicorn escribe por cada request: --no-access-log): ese solo lo ve el ALB, en sus 4xx.

Laboratorio (opcional)8 pasos

Qué vas a provocar: Armar una cadena en miniatura (admisión, validación, CPU en el threadpool, un Triton falso y el log en el finally) y comprobar qué requests llegan a tu log y cuáles no.

Necesitas: Python 3.13 (la versión del C01) e internet para pip: FastAPI, Starlette, Pydantic y uvicorn, en las versiones del C01. curl ya viene en macOS. Sin Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    python3.13 -m venv .venv && . .venv/bin/activate
    pip install fastapi==0.141.1 starlette==1.7.0 pydantic==2.13.5 uvicorn==0.54.0

    Qué deberías ver: La última línea empieza con Successfully installed y trae fastapi-0.141.1, starlette-1.7.0, pydantic-2.13.5 y uvicorn-0.54.0, más sus dependencias (pydantic-core, anyio, h11, click…). Son las versiones del C01. Si ya tenías el venv de otro lab, dice Requirement already satisfied.

  2. Paso 2
    cat > cadena.py <<'EOF'
    import asyncio, json, logging, sys, time, uuid
    from contextlib import asynccontextmanager
    
    from fastapi import FastAPI, HTTPException, Query, Request, Response
    from starlette.concurrency import run_in_threadpool
    
    MAX_INFLIGHT = 2                               # 16 en el C01; 2 para verlo con 6 requests
    log = logging.getLogger("lab")                 # como el C01: un logger propio a stdout
    log.addHandler(logging.StreamHandler(sys.stdout))
    log.setLevel(logging.INFO)
    log.propagate = False
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        app.state.inflight = 0
        yield
    
    app = FastAPI(lifespan=lifespan)
    
    def decode(data: bytes) -> int:                # CPU simulada: corre en el threadpool
        time.sleep(0.05)
        return len(data)
    
    async def triton() -> list[float]:             # I/O simulado: el await cede el turno
        await asyncio.sleep(0.02)
        return [0.7, 0.2, 0.1]
    
    @app.post("/v1/classify")
    async def classify(request: Request, response: Response, top_k: int = Query(5, ge=1, le=20)):
        t0 = time.perf_counter()
        rid = request.headers.get("x-request-id") or uuid.uuid4().hex
        status = 500
        try:
            if request.app.state.inflight >= MAX_INFLIGHT:
                raise HTTPException(503, "gateway saturado", headers={"Retry-After": "1"})
            request.app.state.inflight += 1
            try:
                if request.headers.get("content-type") != "image/jpeg":
                    raise HTTPException(415, "Content-Type debe ser image/jpeg")
                data = await request.body()        # el lab no pone tope; el C01 cuenta los bytes
                n = await run_in_threadpool(decode, data)
                scores = await triton()
            finally:
                request.app.state.inflight -= 1
            status = 200
            response.headers["X-Request-ID"] = rid
            return {"bytes": n, "scores": scores[:top_k]}
        except HTTPException as exc:
            status = exc.status_code
            raise
        finally:
            ms = round((time.perf_counter() - t0) * 1000, 1)
            log.info(json.dumps({"request_id": rid, "status": status, "ms": ms}))
    EOF

    Qué deberías ver: Sin salida: el archivo queda en ~/lab-gateway/cadena.py. Admite 2 requests en vuelo, simula 50 ms de decode en un hilo y 20 ms de Triton, y escribe una línea JSON por request en el finally.

  3. Paso 3
    uvicorn cadena:app --port 8080 --no-access-log > server.log 2>&1 &
    echo $! > uvicorn.pid
    sleep 2; cat server.log

    Qué deberías ver: Cuatro líneas de uvicorn: Started server process [PID], Waiting for application startup., Application startup complete. y Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit). La terminal muestra además el número del job en segundo plano.

  4. Paso 4
    curl -si -H "Content-Type: image/jpeg" --data-binary "hola" "localhost:8080/v1/classify?top_k=2"
    echo
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/png" --data-binary "hola" localhost:8080/v1/classify
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/jpeg" --data-binary "hola" "localhost:8080/v1/classify?top_k=0"

    Qué deberías ver: Primero HTTP/1.1 200 OK, los headers (entre ellos x-request-id con 32 caracteres hexadecimales: Starlette, la base de FastAPI, guarda los nombres en minúsculas) y el body {"bytes":4,"scores":[0.7,0.2]}. Después {"detail":"Content-Type debe ser image/jpeg"} 415. Y por último {"detail":[{"type":"greater_than_equal","loc":["query","top_k"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]} 422.

  5. Paso 5
    seq 6 | xargs -P 6 -I{} curl -s -o /dev/null -w '%{http_code}\n' -H "Content-Type: image/jpeg" --data-binary x localhost:8080/v1/classify | sort | uniq -c

    Qué deberías ver: Seis requests a la vez. Por lo general salen 2 × 200 y 4 × 503: solo caben 2 en vuelo y cada uno tarda ≈ 70 ms. Si tu Mac lanza los curl más despacio, puede colarse un 200 más.

  6. Paso 6
    grep -o '"status": [0-9]*' server.log | sort | uniq -c

    Qué deberías ver: Los status que vio el finally: los 200 (el primero y los de la ráfaga), un 415 y los 503. Ningún 422: el request con top_k=0 nunca entró a classify, así que su finally no corrió y no dejó línea. El C01 lo resuelve con un exception handler para el 422.

  7. Paso 7
    kill $(cat uvicorn.pid); sleep 1; tail -4 server.log

    Qué deberías ver: INFO: Shutting down, Waiting for application shutdown., Application shutdown complete. y Finished server process [PID]: uvicorn atrapó el SIGTERM de kill y se apagó en orden.

  8. Paso 8
    deactivate; cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida: borra el venv, el código y server.log.

Cómo arreglarlo y confirmarlo

Si curl dice Connection refused, el servidor no arrancó: lee server.log (un error de import, o el puerto 8080 ocupado; en ese caso usa --port 8081 en todos los pasos). Si la ráfaga te da seis 200, sube el time.sleep de decode a 0.2 y repite.

Verificado contra la documentación; no ejecutado aquí. Sin instalar nada: cadena.py se corrió en macOS con FastAPI 0.143.0, Starlette 1.7.0, Pydantic 2.14.1 y uvicorn 0.54.0 (con h11 y asyncio, como en el venv del lab) y dio exactamente estas salidas. Para FastAPI 0.141.1 y Pydantic 2.13.5, las versiones del C01, se verificó en el código: el 422 sale antes de llamar a la función, con {"detail": errores} y sin la clave url.

1

Qué hace un gateway y por qué existe

¿Por qué hay un servicio tuyo entre los clientes y Triton, y qué trabajo le toca a cada capa?

Sintaxis con variables

POST /v1/classify?top_k=k
Content-Type: tipo

<bytes de la imagen>

→ 200 {"model_version": "version", "predictions": [{"label": "categoria", "score": score}], "latency_ms": ms}

El contrato (lo que tu API promete a quien la llama): los bytes crudos de la foto en el body, el top-k en la query y una respuesta con model_version. Un 200 nunca trae tensores ni logits (los puntajes crudos del modelo), ni los nombres del modelo o de los tensores en Triton.

VARIABLE = os.getenv("VARIABLE", "default")

Configuración por variables de entorno: la misma imagen corre en tu máquina, en staging (el ambiente de pruebas) y en producción, y Kubernetes le pone los valores. En el C01: TRITON_URL, MODEL_VERSION, INFER_TIMEOUT_S, MAX_INFLIGHT, entre otras.

app.state.triton = triton_http.InferenceServerClient(url="host:8000", conn_limit=n, conn_timeout=s)

Hacia atrás, el gateway habla KServe v2 (un protocolo estándar de servidores de modelos, el que habla Triton) con un solo cliente por proceso, creado en el lifespan (el código que corre al arrancar y al apagar el proceso). La URL va sin http://.

- path: /v1/classify
  pathType: Exact
  backend:
    service:
      name: servicio

Hacia adelante, el Ingress (la regla que le dice al ALB, el balanceador de AWS, qué rutas publicar) solo expone la API. /metrics, /healthz, /readyz y /docs existen en el pod, pero no salen del cluster.

Variables

VariableQué esEjemplo
k · tipocuántas categorías pide el cliente y el formato de la foto5 · image/jpeg
version · categoria · score · mslo que vuelve: versión del modelo, nombre de la categoría, su probabilidad y la latencia en el servidor2026-09-01-resnet50-fp16 · Celulares y telefonía > Smartphones · 0.83 · 41.2
VARIABLE · defaultnombre de la variable de entorno y el valor si no estáINFER_TIMEOUT_S · 0.15
host · n · sdónde está Triton, conexiones máximas y timeout de respaldotriton.classifier.svc.cluster.local · 64 · 1.0
servicioel Service de Kubernetes del gatewaygateway
λ\lambdarequests por segundo en el pico de diseño500
tCPUt_{\text{CPU}}CPU por foto: decode, resize y normalizar≈ 6 ms con la mezcla de fotos del C01
0.6×1 core0.6 \times \text{1 core}la meta del HPA (el autoscaler de pods de Kubernetes): agrega pods cuando el uso promedio pasa del 60 % de la CPU pedida0.6 cores por pod
BBbytes del tensor que viaja a Triton602,112 (FP32: 3 × 224 × 224 × 4)

Fórmulas

cores de decode=λ⋅tCPU=500 reqs×6 ms=3\text{cores de decode} = \lambda \cdot t_{\text{CPU}} = 500\ \tfrac{\text{req}}{\text{s}} \times 6\ \text{ms} = 3
pods del gateway≈cores0.6×1 core por pod=30.6=5(el HPA va de 3 a 12)\text{pods del gateway} \approx \frac{\text{cores}}{0.6 \times 1\ \text{core por pod}} = \frac{3}{0.6} = 5 \qquad (\text{el HPA va de 3 a 12})
red gateway→Triton=λ×B×8=500×602,112 B×8≈2.4 Gb/s\text{red gateway} \to \text{Triton} = \lambda \times B \times 8 = 500 \times 602{,}112\ \text{B} \times 8 \approx 2.4\ \text{Gb/s}

Las piezas

PiezaQué haceEjemplo
TLS y autenticaciónCifrar la conexión y verificar quién llama, con un JWT de servicio (un token firmado) o mTLS (TLS con certificado en los dos lados).en el ALB, antes del gateway
Identidad, no un headerx-client-source lo escribe el propio cliente: sirve para partir las métricas, nunca para cuotas ni permisos.source: mobile, web, internal u other
Cuota por callerLimitar cuántos requests manda cada cliente, con 429 cuando se pasa.en el borde (una regla del WAF, el firewall de aplicaciones de AWS) o un contador por identidad; el main.py no la trae (el WAF responde 403 salvo que le configures 429)
Validar baratoRechazar lo inválido antes de gastar CPU: tipo, bytes, píxeles, top_k.415, 413, 400, 422
Decode y preprocesoConvertir los bytes en el tensor que espera el modelo, con el mismo código del entrenamiento.preprocess.py → float32 [3, 224, 224]
Separar CPU de GPUEl decode corre en nodos de CPU baratos que escalan solos; Triton solo recibe tensores.HPA por CPU al 60 % (gateway); KEDA, que escala según una métrica, por tiempo en cola (Triton)
Admisión y backpressureNo aceptar más de lo que se puede terminar a tiempo, y avisar hacia atrás con 503 y Retry-After.MAX_INFLIGHT = 16 y 2 slots de CPU
Timeouts y deadlineCada etapa usa lo que queda del presupuesto: no se trabaja para un caller que ya se fue.250 ms en total, 150 ms hacia Triton
Traducir errores y degradarConvertir cada falla de atrás en un código útil y rápido, para que el cliente degrade con gracia: en el C01, la app muestra el selector manual de categorías y el vendedor publica igual.Triton lento → 504; caído o rechazando → 503 + Retry-After
PostprocesoLogits → probabilidades → top-k → nombres. Cambia con el producto, no con el modelo.softmax y labels.json en el gateway
Contrato y versiónUna API estable (/v1) aunque cambie el modelo; cada respuesta dice qué modelo contestó.model_version en el body
ObservabilidadUna línea de log y métricas por request, con el tiempo de cada etapa.preprocess_ms e inference_ms por separado
SaludLiveness: ¿el proceso responde? Readiness: ¿puede atender? (aquí: ¿el modelo está listo en Triton?)./healthz y /readyz
Lo que NO haceBatching, colas por modelo y GPU: eso es de Triton. Y Triton nunca queda expuesto a los clientes.una NetworkPolicy (una regla de red de Kubernetes) solo deja entrar a Triton a los pods del gateway
ResponsabilidadALB (borde)Gateway (FastAPI, CPU)Triton (GPU)
TLS y autenticacióntermina TLS y verifica el JWT——
Cuota por calleruna regla del WAF (opcional)contador por identidad (no está en la referencia)—
Validar tipo, bytes y píxeles—415 · 413 · 400, antes de decodificarrevisa nombre, tipo y shape del tensor
Decode y preproceso—preprocess.py en el threadpool (hilos aparte)—
Batching—una foto por requestdynamic batching: junta requests hasta 2 ms
Inferencia——engine TensorRT FP16
Postproceso—softmax, top-k y labelsdevuelve logits
Admisión y backpressure—16 en vuelo y 2 slots de CPU → 503rechaza lo que espera 100 ms en cola
Timeoutsidle timeout de 60 sdeadline de 250 ms; 150 ms a Triton → 504100 ms de cola
Saludhealth check a /readyz/healthz y /readyz/v2/health/live y /v2/health/ready
Métricas5xx y latencia en CloudWatch (el monitoreo de AWS)classifier_* en /metricsnv_* en el puerto 8002
Escala con—CPU: HPA al 60 %, de 3 a 12 podstiempo en cola: KEDA, de 2 a 4 pods

La línea del corte: el gateway sabe del producto (callers, formatos, top-k, errores); Triton, solo de tensores.

Quién hace qué en el camino de un request del C01. Una fila por responsabilidad; un guion quiere decir que esa capa no la toca.

Ejemplo real · main.py se presenta solo: por qué existe y qué endpoints tiene

Pythonreference/c01/gateway/app/main.py (líneas 1–8 y 38–41; # … marca líneas omitidas)
"""Gateway HTTP del clasificador de categorías (caso c01).

Por qué existe un gateway en CPU delante de Triton: validar, decodificar y redimensionar imágenes es
trabajo de CPU; si lo hiciera Triton (python backend o DALI) competiría con la GPU cara. Así cada
capa escala por separado: gateway por CPU (HPA) y Triton por cola de GPU (KEDA).

Endpoints: POST /v1/classify?top_k=5 · GET /healthz (liveness) · GET /readyz (readiness) · GET /metrics
"""
# …
TRITON_URL = os.getenv("TRITON_URL", "localhost:8000")  # host:puerto HTTP de Triton, SIN "http://" (lo exige el cliente)
MODEL_NAME = os.getenv("MODEL_NAME", "classifier")  # nombre del modelo en el model repository de Triton
MODEL_VERSION = os.getenv("MODEL_VERSION", "dev")  # se devuelve y se loguea: cada predicción queda atada a su modelo
INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.15"))  # 150 ms para Triton dentro del p99 ≤ 200 ms
  • La razón en una frase: decodificar y redimensionar es CPU, y la CPU del nodo con GPU es poca y cara. Separadas, cada capa escala con su propia señal: el gateway por CPU con el HPA, Triton por tiempo en cola con KEDA (el que escala según una métrica de Prometheus).
  • Esos cuatro endpoints son todos los que escribe el C01 (FastAPI suma /docs, /redoc y /openapi.json). Solo /v1/classify sale del cluster; /healthz y /readyz son para los probes (los chequeos de salud) del kubelet, el agente de Kubernetes en cada nodo, y el ALB también mira /readyz; /metrics es para Prometheus (el que junta las métricas).
  • TRITON_URL va sin http:// porque así lo exige tritonclient; en EKS (el Kubernetes de AWS) apunta al Service de Triton (su dirección estable dentro del cluster) y en compose a triton:8000.
  • MODEL_VERSION no la decide el gateway: viene de la release, vuelve en cada respuesta y queda en cada línea de log, para unir una predicción con su modelo.
  • El precio de separar: un salto de red con 602 KB por foto (≈ 2.4 Gb/s en el pico, 3× el JPEG del celular), un componente más que operar y la obligación de rutear por zona (cada gateway le habla al Triton de su misma zona de AWS, porque el tráfico entre zonas se paga). Con poco tráfico, un solo proceso con el modelo adentro es la respuesta correcta: el MVP del C01, su primera versión.

Cómo recordarla

El gateway sabe del producto; Triton, solo de tensores. Lo de CPU va en CPU barata, lo de GPU en la GPU cara, y el cliente solo ve un contrato estable con model_version.

Trampa

Pensar el gateway como «un proxy que reenvía». Si solo reenvía, no protege nada: una ráfaga de fotos de 10 MB, un PNG de 50 KB que declara 30,000 × 30,000 px o un Triton lento tumban el servicio entero. Y la trampa inversa: exponer Triton a los clientes «para ahorrar un salto». Triton deja abiertas por defecto APIs de administración (el repositorio de modelos, los logs, las trazas) y no sabe de cuotas ni de identidades; vive detrás del gateway.

Laboratorio (opcional)5 pasos

Qué vas a provocar: Encontrar en el código real del C01 cada responsabilidad del gateway (errores, configuración, rutas publicadas) y calcular cuánta CPU y red cuesta separarlo de Triton.

Necesitas: el repo del curso y Python 3.13; no instala nada ni crea archivos

  1. Paso 1
    cd apps/ml-system-design/reference/c01
    grep -oE 'HTTPException\([0-9]{3}' gateway/app/main.py | sort | uniq -c

    Qué deberías ver: 2 HTTPException(400 · 3 HTTPException(413 · 1 HTTPException(415 · 1 HTTPException(499 · 1 HTTPException(500 · 5 HTTPException(503 · 2 HTTPException(504: los errores que el gateway decide. El 499 es el cliente que corta a mitad del upload, y el 500, Triton devolviendo logits con NaN. No hay 422: lo genera FastAPI (el handler del C01 solo lo anota). Un bug sin atrapar también sale como 500, pero lo responde Starlette, no esta lista.

  2. Paso 2
    grep -c '"Retry-After": "1"' gateway/app/main.py

    Qué deberías ver: 4: cuatro de los cinco 503 traen Retry-After. El quinto es el de /readyz, que lo lee el kubelet, no un cliente.

  3. Paso 3
    grep -oE 'getenv\("[A-Z_]+"' gateway/app/main.py

    Qué deberías ver: Ocho líneas: TRITON_URL, MODEL_NAME, MODEL_VERSION, INFER_TIMEOUT_S, LABELS_PATH, PREPROCESS_CONCURRENCY, MAX_INFLIGHT y REQUEST_DEADLINE_S, las perillas que se cambian sin reconstruir la imagen.

  4. Paso 4
    grep -nE '^@app\.' gateway/app/main.py
    grep -nE 'path: /v1|pathType' k8s/gateway.yaml

    Qué deberías ver: 119 @app.get("/metrics", include_in_schema=False), 206 @app.post("/v1/classify"), 319 @app.exception_handler(RequestValidationError), 342 @app.get("/healthz") y 348 @app.get("/readyz"); y en el Ingress, 186 path: /v1/classify con 187 pathType: Exact. Cuatro rutas en el pod (la 319 no es una ruta: es el handler del 422), una sola publicada.

  5. Paso 5
    python3.13 -c '
    lam, t_cpu = 500, 0.006                 # req/s en el pico de diseño; CPU por foto (≈ 6 ms)
    cores = lam * t_cpu
    print(f"CPU de decode: {lam} × {t_cpu} s = {cores:.0f} cores")
    print(f"Pods del gateway al 60 % de 1 core: {cores / 0.6:.0f}")
    print(f"Red gateway → Triton: {lam} × 602,112 B × 8 = {lam * 602112 * 8 / 1e9:.1f} Gb/s")
    '

    Qué deberías ver: CPU de decode: 500 × 0.006 s = 3 cores · Pods del gateway al 60 % de 1 core: 5 · Red gateway → Triton: 500 × 602,112 B × 8 = 2.4 Gb/s. Los 3 cores, en el nodo con GPU, competirían con Triton por 4 vCPU; separados, los pone el HPA en nodos de CPU.

Cómo arreglarlo y confirmarlo

Si grep no encuentra nada, revisa que estés en apps/ml-system-design/reference/c01 (los pasos se corren desde la raíz del repo). En Linux, grep -E funciona igual.

Probado de verdad en un Mac. Ejecutado en macOS con el grep de BSD y Python 3.13.16 sobre los archivos del repo, en octubre de 2026.

2

FastAPI básico: app, rutas, modelos, response_model, códigos, dependencias y lifespan

¿Qué piezas de FastAPI vas a escribir en cualquier gateway, y qué hace cada una por ti?

Sintaxis con variables

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.recurso = crear(...)      # una vez por proceso, antes del tráfico
    yield                                    # aquí la app atiende requests
    await app.state.recurso.cerrar()    # al apagar

app = FastAPI(title="nombre", lifespan=lifespan)

El lifespan (el código de arranque y apagado del proceso) reemplaza a @app.on_event, que está deprecado (marcado para desaparecer). Lo de antes del yield corre antes de aceptar requests; lo de después, cuando el servidor ya dejó de atenderlas. app.state guarda lo compartido: clientes, labels, un modelo.

class Salida(BaseModel):
    model_version: str
    predictions: list[Prediccion]

@app.post("/v1/ruta")
async def funcion(request: Request, top_k: Annotated[int, Query(ge=1, le=max)] = default) -> Salida:
    ...

Una ruta (path operation, en la doc de FastAPI) es una función con un decorador que dice el método y el path. Los tipos hacen el trabajo: Query(ge=1, le=20) valida la query y responde 422 si no cumple; el tipo de retorno (o response_model=) valida lo que devuelves, filtra lo que sobra y lo documenta en OpenAPI (la descripción de tu API, que FastAPI genera sola).

def get_recurso(request: Request) -> Tipo:
    return request.app.state.recurso

Alias = Annotated[Tipo, Depends(get_recurso)]

@app.post("/v1/ruta")
async def funcion(nombre_param: Alias): ...

Una dependencia (dependency) es una función que FastAPI llama por ti antes de la ruta, y cuyo resultado te pasa como parámetro: el cliente de Triton, la identidad ya verificada de quien llama, una sesión. Se llama una vez por request aunque varias partes la pidan; si la escribes con def, corre en el threadpool (el grupo de hilos de reserva).

raise HTTPException(status_code=codigo, detail="mensaje", headers={"Retry-After": "1"})

@app.exception_handler(Excepcion)
async def manejador(request: Request, exc: Excepcion) -> JSONResponse: ...

Un error que decides se lanza como HTTPException: FastAPI responde {"detail": …} con ese código y esos headers. Para traducir una excepción tuya en todas las rutas a la vez, registras un exception handler (una función que FastAPI llama ante ese tipo de error). El C01 registra uno para el 422 de FastAPI: así también deja su línea de log y su conteo.

Variables

VariableQué esEjemplo
recurso · crear · cerrarlo que creas una vez por proceso y cómo se cierratriton · InferenceServerClient · close
nombretítulo de la app en OpenAPIclassifier-gateway
Salida · Prediccionmodelos de Pydantic (clases que declaran campos con tipos) de la respuestaSalida con model_version y predictions
funcion · rutala función que atiende y su pathclassify · classify
max · defaultrango y valor por defecto del parámetro20 · 5
Tipo · Alias · nombre_paramlo que entrega la dependencia, un nombre corto para reusarla y el parámetro que la recibeInferenceServerClient · Triton · triton
codigo · mensajecódigo HTTP y texto para el cliente503 · Triton no disponible
Excepcion · manejadorel tipo de error que traduces y la función que respondeImageTooLarge · too_large
ccel código que pusiste en la HTTPException413

Fórmulas

respuesta=JSON(filtrarmodelo de salida(tu_funcioˊn(validar(request))))\text{respuesta} = \text{JSON}\Big(\text{filtrar}_{\text{modelo de salida}}\big(\text{tu\_función}(\text{validar}(\text{request}))\big)\Big)
entrada invaˊlida⇒422HTTPException(c)⇒csalida invaˊlida o excepcioˊn suelta⇒500\text{entrada inválida} \Rightarrow 422 \qquad \texttt{HTTPException}(c) \Rightarrow c \qquad \text{salida inválida o excepción suelta} \Rightarrow 500

Las piezas

PiezaQué haceEjemplo
FastAPI()La app: junta rutas, validación, OpenAPI y el lifespan. Por debajo es una app de Starlette, el framework ASGI (la interfaz estándar entre un servidor y una app async) sobre el que está hecha.FastAPI(title="classifier-gateway", lifespan=lifespan)
@app.get / @app.postDecorador que registra una ruta: método HTTP + path → tu función.@app.post("/v1/classify")
Parámetros de path y queryLo que viene en la URL. FastAPI lo convierte al tipo que declaras y lo valida.top_k: int = Query(5, ge=1, le=MAX_TOP_K)
Annotated[…, Query(…)]La forma recomendada desde FastAPI 0.95 para poner validaciones; la de Query como default, la del C01, sigue funcionando.Annotated[int, Query(ge=1, le=20)] = 5
Body con un modeloUn parámetro con tipo BaseModel hace que FastAPI lea el JSON, lo valide y te pase el objeto.opciones: Opciones
RequestEl request crudo: headers, body en streaming y app.state. Lo usas cuando el body no es JSON, como los bytes de la foto en el C01.request.headers.get("content-type")
Response como parámetroUna respuesta temporal para poner headers o cookies; FastAPI los copia a la respuesta final.response.headers["X-Request-ID"] = request_id
Tipo de retorno / response_modelValida lo que devuelves, quita los campos de más y lo documenta. Si tu salida no cumple, es un 500: el bug es tuyo.-> Salida
status_codeEl código del caso exitoso, en el decorador. Por defecto es 200.@app.post("/v1/jobs", status_code=202)
HTTPExceptionUn error que decides: código, detail y headers. Hereda del HTTPException de Starlette.HTTPException(503, "Triton no disponible", headers={"Retry-After": "1"})
DependsInyección de dependencias: FastAPI llama la función y te pasa el resultado, una vez por request.Annotated[Cliente, Depends(get_triton)]
Dependencia con yieldAbre algo antes y lo cierra después: por defecto, después de enviar la respuesta; con scope="function", antes.una sesión de base de datos
lifespan + app.stateRecursos de todo el proceso: se crean antes del tráfico y se cierran al apagar.app.state.triton, app.state.labels
OpenAPI y /docsFastAPI genera /openapi.json (la descripción de tu API) y las páginas /docs y /redoc. include_in_schema=False saca una ruta de ahí./metrics no aparece en el schema
  1. Routing

    @app.post("/v1/classify")

    Método + path → tu función. Sin ruta: 404; con otro método: 405.

  2. Body

    opciones: Opciones

    Si la ruta declara un modelo, FastAPI lee el body entero, sin tope, y lo interpreta como JSON solo si el Content-Type es de JSON (desde 0.132, si falta, tampoco). Un JSON roto ya es 422. Con request: Request, el body lo lees tú.

  3. Parámetros y dependencias

    top_k: int = Query(5, ge=1, le=20)

    Convierte y valida query, path, headers, dependencias y el body. Si algo falla: un solo 422 con la lista de todos los errores, y tu función no corre.

  4. Tu funciónlo escribes tú

    async def classify(...)

    async def corre en el event loop; def, en el threadpool. Aquí lanzas HTTPException con tus códigos.

  5. Errores

    raise HTTPException(503, ...)

    ExceptionMiddleware lo convierte en {"detail": ...}. Una excepción que nadie atrapa termina en 500 (ServerErrorMiddleware).

  6. Modelo de salida

    -> Salida

    Valida y filtra lo que devolviste. Si no cumple el modelo: 500, porque el bug es tuyo.

  7. Serializar y enviar

    JSONResponse(...)

    JSON, status_code y headers, más los del Response que recibiste como parámetro. Las dependencias con yield cierran después, salvo scope="function".

Lo que hace FastAPI con cada request, en orden. Tú escribes el paso 4; los demás los pone FastAPI (con Starlette debajo) a partir de tus tipos.

Ejemplo real · El lifespan del C01: lo que se crea una vez por proceso

Pythonreference/c01/gateway/app/main.py (líneas 97–116)
@asynccontextmanager
async def lifespan(app: FastAPI):
    """Recursos compartidos: se crean con el event loop ya corriendo y se cierran al apagar."""
    # Etiquetas en memoria: 500 strings, índice = id de clase del training (mismo orden que el dataset)
    app.state.labels = json.loads(Path(LABELS_PATH).read_text(encoding="utf-8"))
    # Sin este límite el threadpool (40 hilos por defecto) decodificaría 40 imágenes a la vez en un
    # pod de 1 CPU: más latencia para todas y riesgo de OOMKilled. Los demás esperan como mucho
    # SLOT_WAIT_S; si no, 503.
    app.state.cpu_slots = asyncio.Semaphore(PREPROCESS_CONCURRENCY)
    app.state.inflight = 0  # requests aceptados en este proceso; sin lock: el event loop es un solo hilo
    # UN cliente por proceso: reutiliza conexiones keep-alive (sin handshake TCP por request).
    # conn_limit = máximo de requests en vuelo hacia Triton (backpressure); conn_timeout es un
    # timeout total de aiohttp para CUALQUIER llamada: queda de respaldo (el fino va en _infer).
    # Se crea aquí y no al importar: aiohttp necesita un event loop corriendo.
    app.state.triton = triton_http.InferenceServerClient(url=TRITON_URL, conn_limit=64, conn_timeout=1.0)
    yield  # aquí la app atiende tráfico
    await app.state.triton.close()  # cierra el pool de conexiones limpiamente en el SIGTERM


app = FastAPI(title="classifier-gateway", lifespan=lifespan)  # lifespan reemplaza a on_event
  • @asynccontextmanager convierte la función con yield en el context manager (un objeto con un «al entrar» y un «al salir») que pide FastAPI(lifespan=…). Antes del yield: carga los 500 labels, crea el semáforo de CPU, el contador de requests en vuelo y el cliente de Triton.
  • El cliente se crea aquí y no al importar el módulo: el cliente aio de Triton usa aiohttp, que exige un event loop corriendo (el hilo que atiende por turnos todo lo async). uvicorn importa la app con el loop ya andando, pero el test del C01 hace from app import main sin loop, y ahí fallaría con RuntimeError: no running event loop.
  • app.state es un objeto donde guardas lo compartido; las rutas lo leen con request.app.state. Un cliente por proceso reusa las conexiones abiertas (keep-alive): no paga en cada request el saludo inicial de TCP (handshake) para abrir una nueva.
  • Path(LABELS_PATH).read_text() es I/O que bloquea, y está bien aquí: corre una vez, antes de aceptar tráfico. Dentro de una ruta async sería un problema.
  • Después del yield se cierra el pool de conexiones: corre en el apagado ordenado, cuando uvicorn ya dejó de aceptar requests. En los tests, with TestClient(app) también corre el lifespan.

Cómo recordarla

Tú escribes funciones con tipos; FastAPI lee, valida, llama y serializa. Entrada mala: 422. Tu HTTPException: su código. Salida mala o excepción suelta: 500. Lo caro, una sola vez en el lifespan y en app.state.

Trampa

Mezclar lifespan con @app.on_event("startup"): si la app tiene lifespan, los handlers de on_event no se llaman, y no hay error (solo un DeprecationWarning que casi nunca ves). Y la variante: crear el cliente de aiohttp, o el aio de Triton, al importar el módulo. Con uvicorn parece andar, porque importa la app con el event loop corriendo, pero tus tests lo importan sin loop y fallan con RuntimeError: no running event loop.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Escribir un servicio con lifespan, un modelo de salida, una dependencia y una ruta fuera del schema, y ver qué valida, qué filtra y qué publica FastAPI en OpenAPI.

Necesitas: Python 3.13 (la versión del C01) e internet para pip: FastAPI, Starlette, Pydantic y uvicorn, en las versiones del C01. curl ya viene en macOS. Sin Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    python3.13 -m venv .venv && . .venv/bin/activate
    pip install fastapi==0.141.1 starlette==1.7.0 pydantic==2.13.5 uvicorn==0.54.0

    Qué deberías ver: La última línea empieza con Successfully installed y trae fastapi-0.141.1, starlette-1.7.0, pydantic-2.13.5 y uvicorn-0.54.0, más sus dependencias (pydantic-core, anyio, h11, click…). Son las versiones del C01. Si ya tenías el venv de otro lab, dice Requirement already satisfied.

  2. Paso 2
    cat > basico.py <<'EOF'
    import logging
    from contextlib import asynccontextmanager
    from typing import Annotated
    
    from fastapi import Depends, FastAPI, HTTPException, Query, Request
    from pydantic import BaseModel
    
    log = logging.getLogger("uvicorn.error")       # sale con el mismo formato que los logs de uvicorn
    
    class Prediccion(BaseModel):
        label: str
        score: float
    
    class Salida(BaseModel):
        model_version: str
        predictions: list[Prediccion]
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        log.info("lifespan: cargo los labels")
        app.state.labels = ["Smartphones", "Fundas para celular", "Cargadores de celular"]
        yield
        log.info("lifespan: cierro todo")
    
    app = FastAPI(title="lab-gateway", lifespan=lifespan)
    
    def get_labels(request: Request) -> list[str]:
        return request.app.state.labels
    
    Labels = Annotated[list[str], Depends(get_labels)]
    
    @app.post("/v1/classify")
    async def classify(labels: Labels, top_k: Annotated[int, Query(ge=1, le=3)] = 2) -> Salida:
        if not labels:
            raise HTTPException(503, "sin labels", headers={"Retry-After": "1"})
        preds = [{"label": l, "score": s, "debug": "interno"} for l, s in zip(labels, (0.83, 0.07, 0.04))]
        return {"model_version": "lab-1", "predictions": preds[:top_k], "costo_ms": 3}
    
    @app.get("/healthz", include_in_schema=False)
    async def healthz():
        return {"status": "ok"}
    EOF

    Qué deberías ver: Sin salida: el archivo queda en ~/lab-gateway/basico.py. La ruta devuelve dos campos que el modelo Salida no declara (costo_ms, y debug en cada predicción).

  3. Paso 3
    uvicorn basico:app --port 8080 > server.log 2>&1 &
    echo $! > uvicorn.pid
    sleep 2; cat server.log

    Qué deberías ver: Las líneas de arranque de uvicorn y, entre Waiting for application startup. y Application startup complete., la tuya: INFO: lifespan: cargo los labels. El lifespan corre antes de aceptar requests.

  4. Paso 4
    curl -s -X POST "localhost:8080/v1/classify?top_k=2"
    echo
    curl -s -w ' %{http_code}\n' -X POST "localhost:8080/v1/classify?top_k=9"

    Qué deberías ver: {"model_version":"lab-1","predictions":[{"label":"Smartphones","score":0.83},{"label":"Fundas para celular","score":0.07}]}: sin costo_ms ni debug, porque el modelo de salida los filtró. Después {"detail":[{"type":"less_than_equal","loc":["query","top_k"],"msg":"Input should be less than or equal to 3","input":"9","ctx":{"le":3}}]} 422.

  5. Paso 5
    curl -s localhost:8080/openapi.json | python3 -c '
    import json, sys
    d = json.load(sys.stdin)
    print(sorted(d["paths"]))
    p = d["paths"]["/v1/classify"]["post"]["parameters"][0]
    print(p["name"], p["in"], p["schema"]["minimum"], p["schema"]["maximum"], p["schema"]["default"])
    print(sorted(d["components"]["schemas"]))
    '

    Qué deberías ver: ['/v1/classify'] (sin /healthz, por include_in_schema=False), después top_k query 1 3 2, y ['HTTPValidationError', 'Prediccion', 'Salida', 'ValidationError']: el rango de top_k, los modelos de la respuesta y el formato del 422 quedan documentados sin escribir nada. La request (Request) y la dependencia no aparecen como parámetros. Abre http://localhost:8080/docs en el navegador para verlo como página.

  6. Paso 6
    kill $(cat uvicorn.pid); sleep 1; tail -5 server.log

    Qué deberías ver: INFO: Shutting down, Waiting for application shutdown., INFO: lifespan: cierro todo, Application shutdown complete. y Finished server process [PID]: lo de después del yield corre al apagar.

  7. Paso 7
    deactivate; cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si el primer curl devuelve Internal Server Error, revisa server.log: un 500 con ResponseValidationError quiere decir que lo que devolviste no cumple Salida (por ejemplo, un score que no es número). Si el puerto 8080 está ocupado, usa --port 8081 en todos los pasos.

Verificado contra la documentación; no ejecutado aquí. Sin instalar nada: basico.py se corrió en macOS con FastAPI 0.143.0, Starlette 1.7.0, Pydantic 2.14.1 y uvicorn 0.54.0 y dio exactamente estas salidas. Para la versión del C01 se verificó en la documentación y el código de FastAPI 0.141.1 (el modelo de salida filtra campos; ge y le salen como minimum y maximum en OpenAPI; include_in_schema=False saca la ruta; el 422 sale antes de la función) y en uvicorn 0.54.0 (orden de los mensajes de arranque y apagado).

3

async contra hilos: event loop, async def contra def, trabajo bloqueante, threadpool, workers y GIL

¿Por qué un solo time.sleep, un requests.get o un decode dentro de async def frena a todos los requests del proceso, y dónde va cada tipo de trabajo?

Sintaxis con variables

async def funcion(...):
    datos = await cliente_async.metodo(...)   # I/O: suelta el turno mientras espera

Una corrutina (coroutine: lo que define async def) corre en el event loop, el único hilo que atiende a todos los requests async por turnos. Cada await de I/O (red, timers) le devuelve el turno al loop, que atiende a otro request mientras tanto. Entre dos await, tu código tiene el hilo para él solo.

resultado = await run_in_threadpool(funcion_cpu, args)   # Starlette/FastAPI: pool de AnyIO, 40 hilos
resultado = await asyncio.to_thread(funcion_cpu, args)   # pool de asyncio: min(32, CPUs + 4) hilos

Lo que bloquea (CPU, librerías sin await) se manda a un hilo y se espera con await. Son dos pools distintos: run_in_threadpool usa el de AnyIO (la librería async debajo de Starlette), con 40 hilos que comparten las rutas y dependencias def; asyncio.to_thread usa el grupo de hilos propio de asyncio (su executor por defecto), que no cuenta en esos 40.

@app.post("ruta")
def funcion(...):          # sin async: FastAPI la corre en el threadpool
    return llamada_sincrona

Si tu ruta usa una librería sin await (requests, psycopg2, boto3), declárala con def: FastAPI la corre en el threadpool y el event loop sigue libre. Lo mismo hace con las dependencias def. Un async def «por si acaso» con código bloqueante adentro es lo peor de los dos mundos.

PYTHONASYNCIODEBUG=1 comando
asyncio.get_running_loop().slow_callback_duration = segundos   # default: 0.1

El modo debug de asyncio avisa cada paso que tuvo el loop ocupado más de slow_callback_duration (100 ms por defecto), con el nombre de la corrutina culpable. Con el umbral en 20 ms, como en el lab: Executing <Task … coro=<funcion() running at …> …> took 0.055 seconds. Úsalo en desarrollo o en una prueba de carga (load test), no en producción.

Variables

VariableQué esEjemplo
ruta · funcionel path de la ruta y la función que la atiende/v1/classify · classify
cliente_async.metodoun cliente con métodos async, creado una vez al arrancar (en el lifespan)app.state.triton.infer
datos · resultadolo que devuelve la esperaresult · batch, digest
funcion_cpu · argsla función que bloquea y sus argumentos_decode_and_hash · data, los bytes de la foto
llamada_sincronauna llamada sin awaitrequests.get(url, timeout=0.2).json()
comando · segundoscómo corres el proceso y desde cuánto un paso cuenta como lentopython3.13 bloqueo.py · 0.02
N, tbloqueoN,\ t_{\text{bloqueo}}requests que bloquean el loop uno detrás de otro, y cuánto dura cada bloqueo3 fotos · 50 ms de decode
λ, tCPU\lambda,\ t_{\text{CPU}}requests por segundo por pod y CPU por foto100 · 6 ms
CPUs\text{CPUs}núcleos que puede usar el proceso (os.process_cpu_count())10 en una Mac M4 → 14 hilos

Fórmulas

tespera del uˊltimo≈N×tbloqueo3×50 ms=150 ms(/healthz espera lo mismo)t_{\text{espera del último}} \approx N \times t_{\text{bloqueo}} \qquad 3 \times 50\ \text{ms} = 150\ \text{ms} \quad (\texttt{/healthz}\ \text{espera lo mismo})
hilos ocupados=λ×tCPU=100 reqs×0.006 s=0.6 por pod(tope del C01: 2)\text{hilos ocupados} = \lambda \times t_{\text{CPU}} = 100\ \tfrac{\text{req}}{\text{s}} \times 0.006\ \text{s} = 0.6\ \text{por pod} \quad (\text{tope del C01: } 2)
AnyIO (rutas def, run_in_threadpool)=40asyncio.to_thread=min⁡(32, CPUs+4)\text{AnyIO (rutas def, run\_in\_threadpool)} = 40 \qquad \texttt{asyncio.to\_thread} = \min\big(32,\ \text{CPUs} + 4\big)

Las piezas

PiezaQué haceEjemplo
event loopEl único hilo que atiende a todos los requests async del proceso, por turnos. Si un turno no termina, nadie más avanza.uvloop (un event loop más rápido), en el C01
async def (corrutina)Una función que puede pausarse en cada await y dejar pasar a las demás.classify, _classify, _infer
await«Espero esto, y mientras tanto atiende a otro». Solo cede el turno si lo que esperas es async de verdad.await asyncio.wait_for(client.infer(...), ...)
Llamada bloqueanteUna función que no vuelve hasta terminar y no tiene await: time.sleep, requests, open(), un decode, model(x).Image.open(...).convert("RGB")
I/O-bound y CPU-boundI/O: el tiempo se va esperando a otro (red, disco). CPU: el tiempo se va calculando. async ayuda con lo primero; para lo segundo, hilos o procesos.Triton es I/O para el gateway; el decode es CPU
def en FastAPILas rutas y dependencias def corren en el threadpool: bloquear ahí no frena el loop.def metrics() en el C01
Threadpool de AnyIOLos 40 hilos que comparten las rutas def, las dependencias def y run_in_threadpool.anyio.to_thread.current_default_thread_limiter().total_tokens
run_in_threadpoolManda una función que bloquea al threadpool y la espera con await.await run_in_threadpool(_decode_and_hash, data)
asyncio.to_threadLo mismo, con el pool de asyncio: min(32, CPUs + 4) hilos, aparte de los 40 de AnyIO.await asyncio.to_thread(decode, data)
Semáforoasyncio.Semaphore: un contador de turnos que deja pasar a N a la vez; el resto espera con await. Acota CPU y RAM aunque haya 40 hilos.cpu_slots = asyncio.Semaphore(2)
Un hilo no se cancelaPython no puede detener código que ya corre en un hilo: si cortas la espera, el hilo sigue trabajando hasta terminar.por eso el C01 no le pone timeout al decode
GILEl candado de CPython (el intérprete normal de Python): en un proceso, un solo hilo ejecuta código Python a la vez. El código en C que lo suelta (Pillow, NumPy, hashlib) sí corre en paralelo. Desde 3.13 existe una build aparte sin GIL; la imagen del C01 usa la normal.el sha256 y el decode del C01, en el threadpool
Workers (procesos)uvicorn --workers N lanza N procesos, cada uno con su loop y su GIL. En Kubernetes se prefiere 1 por pod, y más pods.--workers 1 en el Dockerfile
Modo debug de asyncioAvisa los pasos que tuvieron el loop ocupado más de 100 ms (configurable) y dice qué corrutina fue.PYTHONASYNCIODEBUG=1

Mal: el decode dentro del async def

Un solo mesero (el event loop) atiende todas las mesas. Si se pone a cocinar (un decode dentro de async def), ninguna mesa avanza: ni siquiera /healthz.

Medido en el laboratorio: healthz 160 · foto1 165 · foto2 165 · foto3 176 ms

Bien: el decode en el threadpool, con 2 slots

El mesero manda el plato a la cocina (los hilos del threadpool) y sigue atendiendo: /healthz responde al instante.

Medido en el laboratorio: healthz 0 · foto1 70 · foto2 70 · foto3 123 ms

bloqueo.py con Python 3.13 en una Mac M4: time.sleep se pasa ≈ 5 ms en cada llamada.

CPU: el decodeCPU dentro del event loop: bloquea a todosespera: a Triton, a un slot o a su turnorecibe la respuestaeje en ms, a escala

Tres fotos y un /healthz en el mismo proceso: 50 ms de decode y 10 ms de espera a Triton por foto (valores ideales; en el lab, en una Mac, salen ≈ 10–20 % más). Arriba, el decode corre dentro del event loop: cada foto bloquea a las demás y /healthz espera ≈ 145 ms. Abajo, el decode va al threadpool con 2 slots, como en el C01: /healthz responde enseguida y la tercera foto espera su turno con await, sin bloquear a nadie.

Ejemplo real · El decode del C01: CPU en un hilo, con un tope de 2 y sin timeout

Pythonreference/c01/gateway/app/main.py (líneas 150–154 y 269–288; # … marca líneas omitidas)
def _decode_and_hash(data: bytes) -> tuple[np.ndarray, str]:
    """CPU pura (bloqueante): corre en el threadpool. Pillow y numpy sueltan el GIL en decode y resize."""
    digest = hashlib.sha256(data).hexdigest()  # huella de la imagen: deduplicar y cruzar con labels
    tensor = preprocess(data, max_pixels=MAX_PIXELS)  # float32 [3,224,224], idéntico a training/eval
    return tensor[np.newaxis], digest  # batch de 1: Triton junta requests con dynamic batching
# …
    # Decode + resize son CPU pura. Una ruta `async def` que los corre directo BLOQUEA el event loop:
    # mientras decodifica una foto de 12 MP ninguna otra request avanza. En el threadpool el loop sigue
    # atendiendo I/O (otras llamadas a Triton, health checks). Máximo PREPROCESS_CONCURRENCY a la vez;
    # esperar un slot más de SLOT_WAIT_S → 503 (el pod está saturado de CPU).
    slots = request.app.state.cpu_slots
    try:
        await asyncio.wait_for(slots.acquire(), timeout=SLOT_WAIT_S)
    except TimeoutError:
        raise HTTPException(503, "gateway saturado (CPU)", headers={"Retry-After": "1"}) from None
    t = time.perf_counter()  # el reloj arranca con el slot tomado: mide CPU, no cola
    try:
        # Aquí no hay timeout a propósito: un hilo no se puede cancelar. Si cortáramos el await, el
        # slot se liberaría con el hilo todavía decodificando y el tope de RAM dejaría de valer.
        batch, digest = await run_in_threadpool(_decode_and_hash, data)
    except (ImageTooLarge, Image.DecompressionBombError) as exc:  # ImageTooLarge antes que ValueError
        raise HTTPException(413, str(exc)) from None
    except (OSError, ValueError, SyntaxError):  # lo que lanza Pillow con archivos corruptos o truncados
        raise HTTPException(400, "no se pudo decodificar la imagen") from None
    finally:
        slots.release()  # siempre: también en 400/413
  • _decode_and_hash es def, sin await: CPU pura. Nunca se llama directo desde una corrutina, solo a través de run_in_threadpool.
  • El semáforo cpu_slots deja 2 decodes a la vez por proceso, aunque el threadpool tenga 40 hilos: un PNG de 40 MP ocupa ≈ 320 MB al decodificarse, y el pod tiene 1 GiB.
  • La espera por el slot sí tiene tope (SLOT_WAIT_S, 50 ms): esperar es un await, se puede cortar, y cortarlo no deja trabajo corriendo. Pasado el tope: 503 con Retry-After.
  • El decode no tiene timeout a propósito: un hilo no se puede cancelar. Si cortaras el await, el finally liberaría el slot con el hilo todavía decodificando, y el tope de RAM dejaría de valer.
  • El reloj de preprocess_ms arranca con el slot ya tomado: mide CPU, no la fila. Así el log separa «había que esperar» de «la foto era pesada».

Cómo recordarla

await para esperar, hilos para calcular, pods para escalar. Entre dos await, tu corrutina tiene el event loop para ella sola: si ese tramo tarda 50 ms, todo el proceso espera 50 ms.

Trampa

Escribir async def «porque es más rápido» y adentro llamar algo sin await: requests.get, time.sleep, un decode de Pillow o model(x). Bloquea el event loop: el p99 (el tiempo de los requests más lentos) sube con la concurrencia sin que la CPU esté llena, y hasta /healthz y /readyz esperan su turno (si la espera pasa el límite de los probes, los chequeos de salud de Kubernetes, el pod sale del balanceo y hasta lo reinician). Subir --workers o los 40 hilos no lo arregla; sacar el bloqueo del loop, sí.

Laboratorio (opcional)5 pasos

Qué vas a provocar: Ver el event loop bloqueado: tres fotos y un /healthz con el decode dentro del loop y en un hilo, el aviso del modo debug y el efecto del GIL en hilos.

Necesitas: solo Python 3.13 (la versión del C01); no instala nada. Con 3.11 o 3.12 también corre, salvo os.process_cpu_count, que es de 3.13.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway

    Qué deberías ver: Sin salida.

  2. Paso 2
    cat > bloqueo.py <<'EOF'
    import asyncio, time
    
    DECODE_S, TRITON_S = 0.05, 0.01        # 50 ms de CPU por foto; 10 ms esperando a Triton
    
    def decode():                           # simula el decode: no tiene await (Pillow, igual, suelta el GIL)
        time.sleep(DECODE_S)
    
    async def foto_mal(t0, nombre, fin, slots):
        decode()                            # MAL: corre en el event loop y no suelta el turno
        await asyncio.sleep(TRITON_S)       # I/O: aquí sí cede el turno
        fin[nombre] = round((time.perf_counter() - t0) * 1000)
    
    async def foto_bien(t0, nombre, fin, slots):
        async with slots:                   # como el C01: 2 decodes a la vez
            await asyncio.to_thread(decode) # BIEN: un hilo decodifica; el loop sigue libre
        await asyncio.sleep(TRITON_S)
        fin[nombre] = round((time.perf_counter() - t0) * 1000)
    
    async def escenario(foto):
        asyncio.get_running_loop().slow_callback_duration = 0.02  # modo debug: avisa desde 20 ms (default: 100 ms)
        t0, fin, slots = time.perf_counter(), {}, asyncio.Semaphore(2)
        llega = t0 + 0.005                  # un /healthz llega a los 5 ms
        def responder_healthz():
            fin["healthz"] = round((time.perf_counter() - llega) * 1000)
        asyncio.get_running_loop().call_later(0.005, responder_healthz)
        await asyncio.gather(*(foto(t0, f"foto{i}", fin, slots) for i in (1, 2, 3)))
        return fin
    
    for titulo, foto in (("mal ", foto_mal), ("bien", foto_bien)):
        fin = asyncio.run(escenario(foto))
        print(titulo, "ms hasta responder:", dict(sorted(fin.items(), key=lambda kv: kv[1])))
    EOF
    python3.13 bloqueo.py

    Qué deberías ver: Dos líneas. mal: healthz ≈ 155–180 ms, foto1 y foto2 ≈ 160–185, foto3 ≈ 170–195: cada decode de 50 ms bloquea a todos, y el /healthz que llegó a los 5 ms espera los tres. bien: healthz 0 o 1, foto1 y foto2 ≈ 60–75 (decodifican juntas, en dos hilos) y foto3 ≈ 115–135 (esperó un slot). No da 145 · 150 · 160 exactos porque time.sleep en macOS se pasa 5–10 ms en cada llamada, más si la Mac está ocupada.

  3. Paso 3
    PYTHONASYNCIODEBUG=1 python3.13 bloqueo.py 2>&1 | grep -oE 'coro=<foto_[a-z]+\(\)|took [0-9.]+ seconds' | paste - -

    Qué deberías ver: Tres líneas, coro=<foto_mal() con took ≈ 0.055–0.060 seconds: el modo debug dice qué corrutina tuvo el loop ocupado. En el escenario bien no hay ninguna. El script baja el umbral a 20 ms; con el default de 100 ms, estos bloqueos de 50 ms no aparecerían.

  4. Paso 4
    cat > gil.py <<'EOF'
    import hashlib, os, threading, time
    
    datos = os.urandom(32 * 1024 * 1024)        # 32 MB al azar
    
    def hashear():                               # C que suelta el GIL (como el sha256 del C01)
        for _ in range(8):
            hashlib.sha256(datos).hexdigest()
    
    def sumar():                                 # Python puro: necesita el GIL en cada paso
        n = 0
        for i in range(3_000_000):
            n += i
    
    def medir(f, hilos):
        t = time.perf_counter()
        ts = [threading.Thread(target=f) for _ in range(hilos)]
        for x in ts: x.start()
        for x in ts: x.join()
        return time.perf_counter() - t
    
    for f in (hashear, sumar):
        uno, cuatro = medir(f, 1), medir(f, 4)
        print(f"{f.__name__}: 1 hilo {uno:.2f} s · 4 hilos {cuatro:.2f} s → {4 * uno / cuatro:.1f}× trabajo por segundo")
    print("CPUs:", os.process_cpu_count(), "· pool de asyncio.to_thread:", min(32, (os.process_cpu_count() or 1) + 4), "hilos")
    EOF
    python3.13 gil.py

    Qué deberías ver: hashear: 4 hilos tardan lo mismo que 1 (≈ 0.17 s en una M4) → ≈ 4× trabajo por segundo (hashlib suelta el GIL, como Pillow en el decode). sumar: 1 hilo ≈ 0.09 s · 4 hilos ≈ 0.36 s → 1.0× (Python puro: un hilo a la vez). La última línea dice tus núcleos y el tamaño del pool de asyncio.to_thread: en una Mac M4, 10 y 14 hilos.

  5. Paso 5
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si en bien foto3 sale igual que foto1, falta el semáforo: revisa el async with slots. Si el paso del modo debug no muestra nada, revisa que PYTHONASYNCIODEBUG=1 vaya pegado antes de python3.13, en la misma línea. Con menos de 4 núcleos, hashear da menos de 4×.

Probado de verdad en un Mac. Ejecutado varias veces con Python 3.13.16 y 3.14.3 en macOS (Apple M4, 10 núcleos): los tiempos varían entre 5 y 15 ms de una corrida a otra, según la carga de la Mac (con la Mac ocupada, time.sleep(0.05) llegó a durar ≈ 59 ms). El texto del aviso del modo debug es el de asyncio en 3.13 y 3.14.

4

Validación y contrato: Pydantic v2, uploads con límite, 400/413/415/422, versionado y OpenAPI

¿Qué acepta y qué devuelve tu API, quién lo valida, y con qué código respondes cuando el cliente manda algo que no sirve?

Sintaxis con variables

class Modelo(BaseModel):
    model_config = ConfigDict(extra="forbid")             # un campo de más → 422
    campo: Annotated[int, Field(ge=min, le=max)] = default

Un modelo de Pydantic (la librería que convierte y valida datos según tus tipos) declara el JSON que aceptas. Por defecto Pydantic convierte lo razonable ("3" → 3: el modo lax) e ignora los campos de más; extra="forbid" los rechaza, y así atrapas los errores de tipeo del cliente (un campo mal escrito).

declared = request.headers.get("content-length", "")
if declared.isdigit() and int(declared) > MAX:
    raise HTTPException(413, "mensaje")             # sin leer un byte
size = 0
async for chunk in request.stream():                 # y contando los bytes reales
    size += len(chunk)
    if size > MAX:
        raise HTTPException(413, "mensaje")

Así limita el C01 un body de bytes crudos: primero por el header Content-Length (rechazo gratis), y después contando lo que llega, porque ese header puede mentir o faltar: con Transfer-Encoding: chunked (el body llega en trozos, sin decir el total) no viene.

@app.post("/v1/recurso")
async def funcion(file: UploadFile):
    data = await file.read(MAX + 1)                # nunca más de MAX + 1 bytes en memoria
    if len(data) > MAX:
        raise HTTPException(413, "mensaje")

Con multipart (el formato de los formularios con archivos; necesita python-multipart), FastAPI recibe el archivo entero antes de llamar a tu función: hasta 1 MB en memoria y el resto en un archivo temporal, sin tope. Leer con tamaño máximo protege tu memoria; para cortar el upload mismo, limita el body antes, con el RequestBodyLimitMiddleware de Starlette (la base de FastAPI; desde 1.6) o en el proxy que está delante (por ejemplo, client_max_body_size en nginx).

v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")
app.include_router(v1)
app.include_router(v2)      # /v2 convive con /v1 mientras los clientes migran

Se versiona el contrato, no el modelo: cambiar de modelo no rompe a nadie; renombrar o quitar un campo sí, y eso es /v2 conviviendo con /v1. Agregar un campo a la respuesta no rompe a nadie, siempre que los clientes ignoren los campos que no conocen.

Variables

VariableQué esEjemplo
Modelo · campola clase del JSON que aceptas y uno de sus camposOpciones · top_k
min · max · defaultrango permitido y valor si no viene1 · 20 · 5
MAX · mensajetope en bytes y texto del 41310 * 1024 * 1024 · la imagen supera 10 MB
recurso · funcionpath y función del endpointclassify
W, HW,\ Hancho y alto que declara el header de la imagen8,000 × 6,000 = 48 MP → 413
4 B\text{4 B}bytes por píxel con que Pillow guarda RGB160 MB para 40 MP, antes de las copias
MAX_INFLIGHT, MAX_BYTES\texttt{MAX\_INFLIGHT},\ \texttt{MAX\_BYTES}requests en vuelo y tope de bytes por request16 · 10 MB

Fórmulas

RAM al decodificar≈W×H×4 B40 MP×4 B=160 MB(≈320 MB de pico medido, con las copias)\text{RAM al decodificar} \approx W \times H \times 4\ \text{B} \qquad 40\ \text{MP} \times 4\ \text{B} = 160\ \text{MB} \quad (\approx 320\ \text{MB de pico medido, con las copias})
1×4,000 px  → lado corto→256   256×1,024,000 px×4 B≈1 GB1 \times 4{,}000\ \text{px} \;\xrightarrow{\ \text{lado corto} \to 256\ }\; 256 \times 1{,}024{,}000\ \text{px} \times 4\ \text{B} \approx 1\ \text{GB}
RAM de bodies=MAX_INFLIGHT×MAX_BYTES=16×10 MB=160 MB\text{RAM de bodies} = \texttt{MAX\_INFLIGHT} \times \texttt{MAX\_BYTES} = 16 \times 10\ \text{MB} = 160\ \text{MB}

Las piezas

PiezaQué haceEjemplo
400 Bad RequestEl request está mal formado y el servidor no puede arreglarlo.body vacío; bytes que no son imagen; aspecto mayor que 1:8
413 Content Too LargeMás grande de lo que aceptas, en bytes o en píxeles.más de 10 MB; más de 40 MP
415 Unsupported Media TypeEl formato (Content-Type) no es uno que proceses.image/gif → 415
422 Unprocessable ContentEl error de validación de FastAPI: tipos, rangos, campos que faltan o sobran. Body: {"detail": [errores]}.top_k=0
Un error de PydanticCada error trae type, loc (dónde), msg, input y, si aplica, ctx. FastAPI los devuelve así, sin la clave url.greater_than_equal en ["query", "top_k"]
JSON roto → 422FastAPI responde 422 con type json_invalid, no 400. Es su convención: el RFC 9110 (el estándar que define cada código HTTP) reserva el 422 para contenido bien formado."msg": "JSON decode error"
Content-Type de JSONDesde FastAPI 0.132, un body que llega sin Content-Type de JSON (application/json o …+json) no se lee como JSON, y la validación falla con 422. Antes, si el header faltaba, FastAPI lo leía igual.strict_content_type=False lo desactiva
Modo lax y modo strictLax, el default: convierte "3" en 3. Strict: el tipo tiene que llegar exacto.Field(strict=True)
extra="forbid"Rechaza los campos que el modelo no declara; por defecto se ignoran.{"top_k": 3, "debug": true} → 422 extra_forbidden
Content-LengthEl tamaño que el cliente dice que manda. Sirve para rechazar gratis, pero puede mentir o faltar.con chunked no viene
request.stream()El body por partes, a medida que llega: cortas al pasar el tope sin leerlo todo._read_body_limited
UploadFileUn archivo de formulario multipart. FastAPI lo recibe entero antes de tu función: hasta 1 MB en memoria y el resto en disco.file.read(MAX + 1)
Versión en la ruta/v1 versiona el contrato; model_version en el body dice qué modelo respondió./v1/classify
OpenAPIEl documento JSON que describe tu API, generado de tus tipos. Lo que lees a mano (bytes) no aparece, salvo que lo declares con openapi_extra./openapi.json y /docs

llega POST /v1/classify

  1. ¿Existe la ruta con ese método?

    µs
    no →404405FastAPI405 si la ruta existe con otro método; no deja línea en el gateway
  2. ¿top_k es un entero entre 1 y 20?

    µs
    no →422FastAPIantes de tu función: lo anota el exception handler del C01
  3. ¿Hay menos de 16 en vuelo?

    µs
    no →503Retry-After: 1classifygateway saturado
  4. ¿El Content-Type es jpeg, png o webp?

    µs
    no →415_classify
  5. ¿El Content-Length dice 10 MB o menos?

    µs
    no →413_classifysin leer el body
  6. ¿Los bytes reales son 10 MB o menos, y hay alguno?

    1–30 ms
    no →413400_read_body_limited400 si el body vino vacío
  7. ¿Queda algo de los 250 ms?

    µs
    no →504_budget_leftni siquiera decodifica
  8. ¿Se libera un turno de CPU (slot) en 50 ms o menos?

    0–50 ms
    no →503Retry-After: 1_classifygateway saturado (CPU)
  9. ¿Pillow reconoce la imagen y el header dice 40 MP o menos?

    header de la imagen
    no →400413preprocess.decode400 si no es una imagen; 413 si pasa de 40 MP
  10. ¿El aspecto es 1:8 o menos, y se decodifica entera?

    5–50 ms
    no →400preprocess.decodeno se pudo decodificar la imagen
  11. ¿Todavía queda presupuesto?

    µs
    no →504_budget_leftno manda a la GPU un request abandonado
  12. ¿Triton respondió en min(150 ms, lo que queda)?

    5–30 ms
    no →504_infertimeout de inferencia
  13. ¿Sin error de red y sin rechazo de Triton?

    no →503Retry-After: 1_inferTriton no disponible, o rechazó la inferencia

sí a todo →200top-k, model_version y latency_ms, con X-Request-ID

Fuera de este árbol

  • 429cuota por cliente: en el borde (una regla del WAF, el firewall de AWS), no en este main.py
  • 500una excepción que nadie atrapó: un bug
  • 502lo genera el ALB si el gateway cierra una conexión que el ALB iba a reusar

okrechazado a propósito: 4xx o 503 (con Retry-After, «vuelve en un momento»)falla: se acabó el tiempo o un bugµslo que cuesta esa revisión

Qué código devuelve el C01, en el orden en que lo decide. Cada pregunta se hace solo si las anteriores pasaron; al lado, lo que cuesta llegar hasta ahí.

Ejemplo real · Validación en capas del C01: header, bytes y píxeles, antes de decodificar

Pythonreference/c01/gateway/app/main.py (líneas 258–264 y 130–147) y app/preprocess.py (líneas 51–62, extracto; # … marca líneas omitidas)
# gateway/app/main.py: _classify (extracto)
    ctype = request.headers.get("content-type", "").split(";")[0].strip().lower()  # sin "; charset=…"
    if ctype not in ALLOWED_TYPES:
        raise HTTPException(415, "Content-Type debe ser image/jpeg, image/png o image/webp")
    declared = request.headers.get("content-length", "")
    if declared.isdigit() and int(declared) > MAX_BYTES:  # rechazo sin leer ni un byte del body
        raise HTTPException(413, "la imagen supera 10 MB")
    data = await _read_body_limited(request)  # segunda verificación: los bytes reales

# gateway/app/main.py: _read_body_limited
async def _read_body_limited(request: Request) -> bytes:
    """Lee el body en streaming y corta en cuanto pasa de MAX_BYTES (no confía en Content-Length)."""
    chunks, size = [], 0  # acumulamos trozos para no copiar el buffer en cada iteración
    try:
        async for chunk in request.stream():  # llega por partes: podemos abortar sin leer 1 GB
            size += len(chunk)
            if size > MAX_BYTES:  # un cliente con chunked encoding o que miente en Content-Length
                raise HTTPException(413, "la imagen supera 10 MB")
            chunks.append(chunk)
    except ClientDisconnect:
        # El cliente cortó a mitad del upload (red móvil, app cerrada): no es una falla del servidor.
        # Sin esto contaría como 500 (con traceback) e inflaría ClassifierHigh5xxRate. 499 es la
        # convención de nginx ("client closed request"): queda con los 4xx, fuera de la tasa de 5xx.
        # Nadie recibe esta respuesta: uvicorn descarta lo que se manda a un cliente que ya se fue.
        raise HTTPException(499, "el cliente cerró la conexión antes de terminar el upload") from None
    if size == 0:  # body vacío: error del cliente, no vale la pena llegar al decoder
        raise HTTPException(400, "body vacío: envía los bytes de la imagen")
    return b"".join(chunks)

# gateway/app/preprocess.py: decode (extracto)
    img = Image.open(io.BytesIO(data), formats=ALLOWED_FORMATS)
    width, height = img.size  # dimensiones declaradas en el header
    # …
    if max_pixels is not None and width * height > max_pixels:
        raise ImageTooLarge(f"{width}x{height} px excede el límite de {max_pixels} px")
    if max_pixels is not None and max(width, height) > MAX_ASPECT * min(width, height):
        # …
        raise ValueError(f"relación de aspecto {width}x{height} fuera de rango (máx 1:{MAX_ASPECT})")
  • El orden es el del costo: primero lo que está en los headers (415, y 413 por Content-Length), después los bytes reales a medida que llegan, y recién al final el decode. Cada rechazo cuesta menos que el paso siguiente.
  • split(";")[0].strip().lower() acepta image/jpeg; charset=… o IMAGE/JPEG: compara solo el tipo.
  • _read_body_limited no confía en Content-Length: un cliente con Transfer-Encoding: chunked no lo manda, y uno malicioso puede mentir. Cuenta los bytes y corta en cuanto pasa de 10 MB, sin leer 1 GB.
  • Si el cliente corta a mitad del upload (red móvil, app cerrada), request.stream() lanza ClientDisconnect. El C01 lo atrapa y lo anota como 499 (la convención de nginx para «el cliente cerró la conexión»): queda con los 4xx, fuera de la tasa de 5xx, y nadie recibe la respuesta. Sin ese except contaría como un 500, con traceback.
  • Image.open solo lee el header: ancho y alto salen gratis. Con eso se rechaza un PNG que declara 48 MP (413) o una tira de 1 × 4,000 px (400) antes de gastar RAM en los píxeles; formats= limita los decoders a JPEG, PNG y WebP.
  • Ningún modelo de Pydantic valida la foto: el body son bytes crudos (no multipart: menos parsing y sin archivos temporales), así que el C01 la valida a mano. Pydantic solo valida top_k, y por eso /openapi.json no documenta el body: habría que declararlo con openapi_extra.

Cómo recordarla

Rechaza en el orden del costo: los headers (415, 413) antes que el body, los bytes antes que el decode (40 MP y 1:8 se leen del header de la imagen), la CPU antes que la GPU. 4xx = el cliente se equivocó: no se reintenta igual (salvo el 429, que pide esperar el Retry-After) y no cuenta contra el SLO (la meta del servicio).

Trampa

Creer que un límite «ya está puesto» porque usas UploadFile o porque miras Content-Length. FastAPI recibe el upload entero antes de llamar a tu función (lo que pasa de 1 MB va a disco, sin tope), y Content-Length puede mentir o no venir. Con los píxeles pasa lo mismo: un PNG de 50 KB puede declarar 30,000 × 30,000 px, y el límite de bytes no lo ve.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Provocar cada código del contrato con curl: 200, 415, 413 por Content-Length y contando bytes, 400, y los 422 de Pydantic (rango, campo de más, JSON roto y Content-Type olvidado).

Necesitas: Python 3.13 (la versión del C01) e internet para pip: FastAPI, Starlette, Pydantic y uvicorn, en las versiones del C01. curl ya viene en macOS. Sin Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    python3.13 -m venv .venv && . .venv/bin/activate
    pip install fastapi==0.141.1 starlette==1.7.0 pydantic==2.13.5 uvicorn==0.54.0

    Qué deberías ver: La última línea empieza con Successfully installed y trae fastapi-0.141.1, starlette-1.7.0, pydantic-2.13.5 y uvicorn-0.54.0, más sus dependencias (pydantic-core, anyio, h11, click…). Son las versiones del C01. Si ya tenías el venv de otro lab, dice Requirement already satisfied.

  2. Paso 2
    cat > contrato.py <<'EOF'
    from typing import Annotated
    
    from fastapi import FastAPI, HTTPException, Request
    from pydantic import BaseModel, ConfigDict, Field
    
    MAX_BYTES = 1000                               # 1 KB en el lab; 10 MB en el C01
    TIPOS = {"image/jpeg", "image/png"}
    app = FastAPI()
    
    class Opciones(BaseModel):
        model_config = ConfigDict(extra="forbid")
        top_k: Annotated[int, Field(ge=1, le=20)] = 5
    
    @app.post("/v1/opciones")
    async def opciones(o: Opciones) -> Opciones:
        return o
    
    @app.post("/v1/subir")
    async def subir(request: Request):
        ctype = request.headers.get("content-type", "").split(";")[0].strip().lower()
        if ctype not in TIPOS:
            raise HTTPException(415, "Content-Type debe ser image/jpeg o image/png")
        declarado = request.headers.get("content-length", "")
        if declarado.isdigit() and int(declarado) > MAX_BYTES:
            raise HTTPException(413, "supera 1 KB (por Content-Length)")
        total = 0
        async for chunk in request.stream():
            total += len(chunk)
            if total > MAX_BYTES:
                raise HTTPException(413, "supera 1 KB (contando bytes)")
        if total == 0:
            raise HTTPException(400, "body vacío")
        return {"bytes": total}
    EOF

    Qué deberías ver: Sin salida: el archivo queda en ~/lab-gateway/contrato.py, con un endpoint de bytes crudos al estilo del C01 (tope de 1 KB) y uno JSON con un modelo de Pydantic.

  3. Paso 3
    uvicorn contrato:app --port 8080 --no-access-log > server.log 2>&1 &
    echo $! > uvicorn.pid
    sleep 2; cat server.log
    head -c 500 /dev/zero > chica.bin && head -c 2000 /dev/zero > grande.bin

    Qué deberías ver: Las cuatro líneas de arranque de uvicorn. Los dos archivos quedan creados: 500 y 2,000 bytes en cero.

  4. Paso 4
    U=localhost:8080/v1/subir
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/jpeg" --data-binary @chica.bin $U
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/gif" --data-binary @chica.bin $U
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/jpeg" --data-binary @grande.bin $U
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/jpeg" -H "Transfer-Encoding: chunked" --data-binary @grande.bin $U
    curl -s -w ' %{http_code}\n' -H "Content-Type: image/jpeg" --data-binary "" $U

    Qué deberías ver: {"bytes":500} 200 · {"detail":"Content-Type debe ser image/jpeg o image/png"} 415 · {"detail":"supera 1 KB (por Content-Length)"} 413 · {"detail":"supera 1 KB (contando bytes)"} 413 · {"detail":"body vacío"} 400. El cuarto llega con chunked, sin Content-Length: el primer control lo deja pasar y lo frena el segundo, el que cuenta los bytes.

  5. Paso 5
    U=localhost:8080/v1/opciones; J='Content-Type: application/json'
    curl -s -w ' %{http_code}\n' -H "$J" -d '{"top_k": 50}' $U
    curl -s -w ' %{http_code}\n' -H "$J" -d '{"top_k": 3, "debug": true}' $U
    curl -s -w ' %{http_code}\n' -H "$J" -d '{"top_k": "3"}' $U
    curl -s -w ' %{http_code}\n' -H "$J" -d '{"top_k": 3' $U
    curl -s -o /dev/null -w '%{http_code}\n' -d '{"top_k": 3}' $U

    Qué deberías ver: {"detail":[{"type":"less_than_equal","loc":["body","top_k"],"msg":"Input should be less than or equal to 20","input":50,"ctx":{"le":20}}]} 422 · {"detail":[{"type":"extra_forbidden","loc":["body","debug"],"msg":"Extra inputs are not permitted","input":true}]} 422 · {"top_k":3} 200 (modo lax: el texto "3" se convirtió en 3) · {"detail":[{"type":"json_invalid","loc":["body",11],"msg":"JSON decode error","input":{},"ctx":{"error":"Expecting ',' delimiter"}}]} 422, un JSON roto también es 422 · y 422 a secas: sin -H, curl -d manda Content-Type: application/x-www-form-urlencoded, y FastAPI no lee como JSON un body con ese Content-Type (si el header falta del todo, desde 0.132 también es 422).

  6. Paso 6
    kill $(cat uvicorn.pid); sleep 1; tail -4 server.log

    Qué deberías ver: Las cuatro líneas del apagado de uvicorn, de Shutting down a Finished server process [PID].

  7. Paso 7
    deactivate; cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si el 413 por chunked sale como 200, tu curl no mandó chunked: revisa que el header diga exactamente Transfer-Encoding: chunked. Si un 422 trae otro orden de claves o una clave url, estás con otra versión de FastAPI: el venv tiene que decir fastapi 0.141.1 (pip show fastapi).

Verificado contra la documentación; no ejecutado aquí. Sin instalar nada: contrato.py se corrió en macOS con FastAPI 0.143.0, Starlette 1.7.0, Pydantic 2.14.1 y uvicorn 0.54.0 y dio exactamente estas salidas, incluidos el 413 por chunked y el json_invalid en la posición 11. Para la versión del C01 se verificó en el código de FastAPI 0.141.1 (JSON roto → json_invalid con loc ["body", posición] y msg JSON decode error; sin Content-Type de JSON, el body no se lee como JSON; 422 con {"detail": errores} sin url), en los mensajes de pydantic-core (less than or equal to, Extra inputs are not permitted) y en Python 3.13, que da Expecting ',' delimiter en la posición 11 para ese JSON.

5

Llamar al servidor de modelos: cliente, pool, timeouts, reintentos y deadline

¿Cómo llama tu gateway a Triton (o a cualquier servidor de modelos) sin quedarse colgado cuando el otro lado se pone lento, y cuándo vale la pena reintentar?

Sintaxis con variables

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.cliente = triton_http.InferenceServerClient(
        url="host:puerto", conn_limit=max_conexiones, conn_timeout=respaldo_s)
    yield
    await app.state.cliente.close()

Un solo cliente por proceso, creado en el lifespan (el bloque que corre al arrancar y al apagar la app) y cerrado al final. Reutiliza conexiones keep-alive (conexiones TCP que quedan abiertas entre requests), así no paga el handshake (el saludo para abrir una conexión) en cada request. El pool es ese grupo de conexiones abiertas y conn_limit es su tope. La URL va sin http://.

client = httpx.AsyncClient(
    base_url="http://host:puerto",
    timeout=httpx.Timeout(por_operacion_s, connect=conectar_s),
    limits=httpx.Limits(max_connections=max_conexiones, max_keepalive_connections=ociosas),
)
r = await client.post("ruta", content=cuerpo)
r.raise_for_status()
# al apagar, en el lifespan: await client.aclose()

Si tu servidor de modelos no trae cliente propio (la API HTTP de otro equipo, un servidor de modelos de lenguaje), usas httpx. Sus timeouts son por operación, de 5 s de inactividad por defecto: connect (abrir la conexión), read (esperar cada trozo de la respuesta), write (mandar cada trozo) y pool (esperar una conexión libre). No hay un tope total: ese lo pones tú, como en la plantilla siguiente. httpx2 es su continuación, mantenida por Pydantic: la misma API con import httpx2.

deadline = t0 + presupuesto_s  # una sola vez, al entrar
restante = deadline - time.perf_counter()
try:
    async with asyncio.timeout(min(tope_s, restante)):
        resultado = await llamada
except TimeoutError:
    raise HTTPException(504, "mensaje") from None

El deadline es la hora límite del request: se fija una vez, al entrar. Cada salto usa lo que queda (deadline propagation: propagar el plazo hacia adentro). asyncio.timeout (Python 3.11+) y asyncio.wait_for cancelan la llamada al vencer y lanzan TimeoutError. C01 usa wait_for.

for intento in range(intentos):
    try:
        return await llamada
    except errores_transitorios:
        espera = random.uniform(0, min(tope_espera_s, base_s * 2 ** intento))
        if intento == intentos - 1 or time.perf_counter() + espera >= deadline:
            raise
        await asyncio.sleep(espera)

Reintenta solo si la operación es idempotente (repetirla deja el mismo efecto que hacerla una vez) y el error es transitorio (puede salir bien después: conexión rechazada, 503, 429). Backoff exponencial: la espera máxima se duplica en cada intento. Full jitter: la espera real cae al azar entre 0 y ese máximo, para que los clientes no vuelvan todos juntos. Si el error trae Retry-After (el header que dice cuántos segundos esperar), espera al menos eso; si la espera ya no cabe en el deadline, no reintentes. En el camino interactivo de C01 (una persona esperando en el formulario) nadie reintenta.

Variables

VariableQué esEjemplo
clientenombre del cliente en app.statetriton
host:puertodónde escucha el servidor de modelos; tritonclient lo quiere sin http://triton.classifier.svc.cluster.local:8000
max_conexionestope de conexiones abiertas a la vez en el pool64 en C01; el default es 100 en tritonclient aio, aiohttp y httpx
respaldo_stope total por llamada, para toda la sesión del cliente1.0 en C01; el default es 60.0
por_operacion_stope de httpx para cada espera de read, write y pool1.0 (ilustrativo); el default es 5.0
conectar_stope de httpx para abrir la conexión TCP0.2 (ilustrativo)
ociosasconexiones sin uso que el pool deja abiertas20 (default de httpx; las cierra tras 5 s sin uso)
ruta, cuerpoendpoint del servidor de modelos y los bytes que mandas"/v1/predict" y el JSON del request (ilustrativo)
presupuesto_spresupuesto total del request dentro del gateway0.25 (REQUEST_DEADLINE_S)
tope_stope de este salto0.15 (INFER_TIMEOUT_S)
llamadala corrutina (la llamada async que esperas con await) al upstream, el servicio al que llamasclient.infer(MODEL_NAME, [inp], outputs=[out], request_id=request_id)
mensajeel detail (el texto del error) que lee el caller, quien llama al gatewaytimeout de inferencia
intentosintentos en total, contando el primero2 (un reintento), solo para callers batch (scripts sin una persona esperando)
errores_transitoriosexcepciones que valen otro intentocon tritonclient aio (el cliente de C01): aiohttp.ClientConnectorError (no pudo conectar) o un InferenceServerException con status() "503"; con httpx: httpx.ConnectError, httpx.ConnectTimeout o un 503 o 429 convertido en excepción
base_s, tope_espera_sespera base del backoff y su techo0.1 y 2.0 (los del lab)
deadline\text{deadline}hora límite del request en el reloj time.perf_counter(): llegada + 250 mst0 + 0.25
ahora\text{ahora}time.perf_counter() en ese momentot0 + 0.20 si el decode tardó 200 ms: a Triton le quedan 50 ms
L, λ, WL,\ \lambda,\ Wllamadas abiertas a la vez, llamadas por segundo en un pod, cuánto dura cada una15 = 100 × 0.15
nnnúmero de reintento, desde 00, 1, 2
U(a, b)U(a,\ b)un número al azar entre a y b, todos igual de probablesrandom.uniform(0, 0.4)
A, r, cA,\ r,\ cintentos que llegan al fondo por cada request de arriba, reintentos por capa, capas que reintentan64 = (1 + 3)³

Fórmulas

tTriton=min⁡(150 ms, deadline−ahora)t_{\text{Triton}} = \min\big(150\ \text{ms},\ \text{deadline} - \text{ahora}\big)
300 ms (caller)  >  250 ms (deadline)  >  150 ms (Triton)  >  100 ms (cola de Triton)300\ \text{ms (caller)} \;>\; 250\ \text{ms (deadline)} \;>\; 150\ \text{ms (Triton)} \;>\; 100\ \text{ms (cola de Triton)}
L=λ⋅W100 req/s×0.15 s=15 llamadas abiertas por pod con Triton colgadoL = \lambda \cdot W \qquad 100\ \text{req/s} \times 0.15\ \text{s} = 15\ \text{llamadas abiertas por pod con Triton colgado}
esperan∼U(0, min⁡(tope, base⋅2n))base=100 ms: 100, 200, 400 ms\text{espera}_n \sim U\big(0,\ \min(\text{tope},\ \text{base} \cdot 2^{n})\big) \qquad \text{base} = 100\ \text{ms}:\ 100,\ 200,\ 400\ \text{ms}
A=(1+r)c(1+3)3=64(1+2)5=243A = (1 + r)^{c} \qquad (1 + 3)^{3} = 64 \qquad (1 + 2)^{5} = 243

Las piezas

PiezaQué haceEjemplo
Cliente reutilizadoUno por proceso: se crea en el lifespan y se cierra al apagar. Uno por request abre una conexión nueva cada vez; si además no lo cierras, aiohttp avisa Unclosed client session.app.state.triton en C01
Pool de conexiones y keep-aliveLas conexiones abiertas que el cliente reutiliza. Si están todas ocupadas, la llamada espera una libre, y esa espera cuenta en el timeout.conn_limit=64 → aiohttp.TCPConnector(limit=64); aiohttp cierra una ociosa a los 15 s
HTTP o gRPC hacia TritonC01 usa HTTP con un pool: el Service de Kubernetes (la dirección fija delante de los pods de Triton) reparte conexiones, no requests, y el pool abre varias. gRPC (el otro protocolo de Triton, con mensajes binarios) lleva todo por una sola conexión HTTP/2 de larga vida: el tráfico de un gateway quedaría pegado a un solo pod de Triton.con gRPC, el timeout por llamada es client_timeout, en segundos
Timeouts por operación (httpx)connect, read, write y pool: cada uno mide una sola espera, no el request completo. 5 s por defecto.ConnectTimeout, ReadTimeout, PoolTimeout
Timeout total (aiohttp)ClientTimeout(total=…) cubre la llamada entera, incluida la espera por una conexión del pool. Default de aiohttp: 300 s.conn_timeout=1.0 → ClientTimeout(total=1.0)
Deadline y deadline propagationUna sola hora límite por request. Cada etapa usa lo que queda y, si ya no alcanza, corta sin hacer el trabajo._budget_left(deadline) → 504
asyncio.wait_for y asyncio.timeoutCortan cualquier await al vencer: cancelan la llamada y lanzan el TimeoutError normal de Python (desde 3.11; antes, asyncio.TimeoutError era otra clase).wait_for(client.infer(…), timeout=min(0.15, left))
timeout= de infer()No es del cliente: son microsegundos que Triton aplica en su propia cola, y solo si el modelo tiene dynamic batching (Triton junta requests en lotes).C01 no lo usa
Cancelación que no llegaCortar en el gateway libera tu conexión y tu lugar. La doc de Triton describe la cancelación de requests para su endpoint gRPC y su API de C; de HTTP no dice nada: cuenta con que el request sigue en su cola o en la GPU.por eso la cola de Triton rechaza a los 100 ms (REJECT)
IdempotenteRepetirla deja el mismo efecto que hacerla una vez. POST no lo es por definición, pero clasificar una foto sí, por diseño.cobrar o crear un registro no lo es, salvo con una idempotency key (un id por operación con el que el servidor detecta el repetido)
Transitorio contra permanenteTransitorio: puede salir bien después (conexión rechazada, 503, 429). Permanente: va a fallar igual (400, 413, 415, 422).solo los transitorios se reintentan
Backoff exponencial con full jitterEsperar cada vez más (el doble) y al azar dentro de ese máximo, para no volver todos en el mismo instante.al azar entre 0 y 100, 200 y 400 ms
Retry budgetUn tope a la fracción de requests que pueden ser reintentos; pasado el tope, no se reintenta.≈ 10 % (capítulo Handling Overload del libro Site Reliability Engineering de Google)
Amplificación de reintentosSi cada capa reintenta por su cuenta, los intentos se multiplican: (1 + reintentos) elevado al número de capas.3 reintentos en 3 capas = 64 intentos al fondo; AWS cita 243 con 5 capas de 3 intentos cada una (3⁵)
httpx y httpx2httpx sigue en 0.28.1 (diciembre de 2024). httpx2, de Pydantic desde mayo de 2026, es un fork (una copia que sigue por su cuenta) de esa versión: la misma API, renombrada.httpx2==2.13.1 en requirements-dev.txt de C01 (lo pide el TestClient, el cliente de pruebas de Starlette); hoy va en 2.16.0

Topes anidados, a escala (de 0 a 300 ms)

  • caller (backend de publicación) · 300 ms

    Dónde: fuera del repo

    Al vencer: la interfaz muestra el selector manual

  • deadline del gateway · 250 ms

    Dónde: REQUEST_DEADLINE_S

    Al vencer: 504 se agotó el presupuesto del request

  • gateway → Triton · 150 ms

    Dónde: INFER_TIMEOUT_S, o lo que quede si es menos

    Al vencer: 504 timeout de inferencia

  • cola de Triton · 100 ms

    Dónde: default_queue_policy con REJECT

    Al vencer: Triton rechaza → 503

Decode lento (lab de esta sección)

  • decode · 200 ms
  • Triton: le quedaban ≈ 49 ms
  • los 150 ms que hubiera tenido Triton

504 timeout de inferencia a los ≈ 250 ms: Triton tuvo ≈ 49 ms, no 150

Reintentos: backoff exponencial (base 100 ms) y full jitter

la espera máxima: 100, 200, 400 ms, se duplicasin jitter, todos vuelven a la vezcon full jitter, cada uno vuelve al azar, entre 0 y su espera máximaeje en ms, a escala

El pico de 1,000 clientes en una ventana de 10 ms

sin jitter1,000

full jitter154

Ventanas con tráfico: 3 sin jitter contra 66 con jitter: la misma carga, repartida.

Amplificación: 3 reintentos en cada una de 3 capas

  1. arriba1 request
  2. capa 1(1 + 3) = 4 intentos
  3. capa 2(1 + 3)2 = 16 intentos
  4. capa 3(1 + 3)3 = 64 intentos

un tope de tiempo o una esperaCPU del gatewayse corta: 504rechazado: 503

Arriba, los topes anidados de C01 a escala, en ms: cada uno cabe dentro del de afuera. En medio, un request del lab con decode lento: a Triton solo le quedan ≈ 49 ms. Abajo, 3 reintentos con backoff exponencial y full jitter: la espera máxima se duplica (100, 200 y 400 ms) y la real cae al azar dentro de ella; sin jitter, 1,000 clientes vuelven en el mismo instante.

Los timeouts de C01, de afuera hacia adentro

TramoTopeDónde se fijaAl vencer
Caller → gateway300 msbackend de publicación (fuera del repo)la interfaz muestra el selector manual de categorías
Deadline del gateway250 msREQUEST_DEADLINE_S en main.py504 se agotó el presupuesto del request
Gateway → Tritonmin(150 ms, lo que queda)INFER_TIMEOUT_S y asyncio.wait_for en _infer504 timeout de inferencia
Cola de Triton100 msdefault_queue_policy en config.pbtxtTriton rechaza con Request timeout expired → el gateway responde 503
Respaldo de aiohttp1.0 s en totalconn_timeout=1.0 en el lifespanno debería pasar: wait_for corta antes
/readyz → Triton0.5 sasyncio.wait_for en readyz()no listo → 503; con 2 fallos seguidos (≈ 10 s), el pod sale del tráfico

Cada tope es menor que el de afuera: así falla primero el de más adentro, rápido y con un código claro. El de 0.5 s de /readyz queda debajo del timeoutSeconds: 2 de la probe (la pregunta periódica del kubelet).

Ejemplo real · main.py: un cliente por proceso y un deadline por request

Pythonreference/c01/gateway/app/main.py (líneas 41, 51–52, 107–113, 191–196, 294–306 y 169–172; # … marca líneas omitidas)
INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.15"))  # 150 ms para Triton dentro del p99 ≤ 200 ms
# …
# Presupuesto total server-side (< 300 ms del caller). Cada etapa usa lo que queda: deadline propagation
REQUEST_DEADLINE_S = float(os.getenv("REQUEST_DEADLINE_S", "0.25"))
# …
    # UN cliente por proceso: reutiliza conexiones keep-alive (sin handshake TCP por request).
    # conn_limit = máximo de requests en vuelo hacia Triton (backpressure); conn_timeout es un
    # timeout total de aiohttp para CUALQUIER llamada: queda de respaldo (el fino va en _infer).
    # Se crea aquí y no al importar: aiohttp necesita un event loop corriendo.
    app.state.triton = triton_http.InferenceServerClient(url=TRITON_URL, conn_limit=64, conn_timeout=1.0)
    yield  # aquí la app atiende tráfico
    await app.state.triton.close()  # cierra el pool de conexiones limpiamente en el SIGTERM
# …
def _budget_left(deadline: float) -> float:
    """Segundos que quedan del presupuesto. Agotado → 504: no gastes CPU ni GPU en un request abandonado."""
    left = deadline - time.perf_counter()
    if left <= 0:
        raise HTTPException(504, "se agotó el presupuesto del request")
    return left
# …
    left = _budget_left(deadline)  # no mandes a la GPU un request que el caller ya abandonó
    t = time.perf_counter()
    try:
        logits = await _infer(  # I/O: aquí el await sí cede el loop
            request.app.state.triton, batch, record["request_id"], timeout=min(INFER_TIMEOUT_S, left)
        )
    finally:
        # También si Triton falla (503) o no contesta a tiempo (504): un timeout es la llamada MÁS lenta,
        # no una que falta. Si no entrara, el p99 de inferencia se vería sano justo cuando Triton está
        # lento, y el desglose no cuadraría con la latencia total, que sí cuenta los 5xx.
        inf_s = time.perf_counter() - t
        INFERENCE.observe(inf_s)
        record["inference_ms"] = round(inf_s * 1000, 1)
# …
        result = await asyncio.wait_for(
            client.infer(MODEL_NAME, [inp], outputs=[out], request_id=request_id),
            timeout=timeout,  # ≤ INFER_TIMEOUT_S: lo que quede del presupuesto del request
        )
  • Un cliente por proceso, creado en el lifespan con el event loop (el hilo que atiende las corrutinas por turnos) ya corriendo (aiohttp lo exige: fuera de un loop, su constructor falla) y cerrado con await …close() al apagar. conn_limit=64 pasa a aiohttp.TCPConnector(limit=64). Como el gateway acepta como mucho 16 requests a la vez, el pool nunca es el cuello.
  • conn_timeout=1.0 se vuelve aiohttp.ClientTimeout(total=1.0) para toda la sesión: un tope total por llamada, incluida la espera por una conexión libre. Queda de respaldo; el tope fino de cada request lo pone asyncio.wait_for.
  • El deadline se fija una vez (t0 + REQUEST_DEADLINE_S). Antes del decode y antes de Triton, _budget_left revisa cuánto queda; si no queda nada, 504 sin gastar CPU ni GPU. A Triton le toca min(0.15, lo que queda): si el decode tardó 200 ms, Triton tiene ≈ 50 ms, no 150 (el lab lo muestra).
  • asyncio.wait_for cancela la llamada al vencer y lanza TimeoutError → 504. Esa cancelación corta tu lado; Triton no documenta cancelación por HTTP, así que el request puede seguir en su cola. Por eso su cola rechaza lo que lleva más de 100 ms esperando.
  • La llamada se mide en un finally: también los 503 y los 504 dejan su tiempo en classifier_inference_seconds y en inference_ms. Un timeout es la llamada más lenta, no una que falta: si no entrara, el p99 de inferencia se vería sano justo cuando Triton está lento.
  • Sin reintentos: si Triton tarda o cae, el gateway responde enseguida 504 o 503 y el caller muestra el selector manual. Los callers batch (scripts que procesan lotes, sin una persona esperando) reintentan una vez, respetando Retry-After, con jitter y dentro de un presupuesto de ≈ 10 %. Versiones: C01 fija tritonclient 2.72.0 y aiohttp 3.14.3; en octubre de 2026 van en 2.73.0 y 3.14.5, con la misma firma de infer y los mismos conn_limit y conn_timeout.

Cómo recordarla

un cliente, un pool, un deadline. El cliente se crea una vez; el pool reusa las conexiones; cada salto usa lo que queda del plazo (300 → 250 → 150 → 100 ms). Reintentar es pedir prestados tiempo y carga: solo si es idempotente y transitorio, con jitter, dentro del deadline y en una sola capa.

Trampa

confiar en los defaults. requests (la librería) no tiene timeout por defecto: sin timeout, puede esperar minutos o para siempre; httpx corta tras 5 s de inactividad en cada operación (no en total); aiohttp, a los 300 s; tritonclient aio, a los 60 s. Con un caller que se va a los 300 ms, cada llamada colgada ocupa una conexión y un lugar para una respuesta que nadie lee. Y la segunda: reintentar un timeout en el camino interactivo. Un timeout suele significar «Triton está lento por carga»: el reintento le suma carga justo ahí y tiene menos tiempo que el primer intento.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Ver, con un Triton falso hecho solo con Python estándar, cómo el deadline recorta el tiempo de cada salto, cuántas llamadas quedan abiertas con y sin timeout (Little) y qué hace el jitter con una tormenta de reintentos.

Necesitas: Python 3.11 o más nuevo (los comandos usan python3.13, la versión de C01), sin paquetes extra. Ni Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    cat > deadline.py <<'EOF'
    # deadline.py: un "Triton" falso y un gateway con deadline, como en C01 (solo stdlib)
    import asyncio, time
    
    INFER_TIMEOUT_S = 0.15  # tope del salto a Triton (C01)
    DEADLINE_S = 0.25       # presupuesto total del request (C01)
    
    async def triton_falso(demora_s):
        await asyncio.sleep(demora_s)  # cola + GPU, simuladas
        return "logits"
    
    async def gateway(decode_s, triton_s):
        t0 = time.perf_counter()
        deadline = t0 + DEADLINE_S                  # se fija una vez, al entrar
        await asyncio.sleep(decode_s)               # el decode (en C01 va al threadpool)
        left = deadline - time.perf_counter()       # lo que queda del presupuesto
        if left <= 0:
            return 504, "se agotó el presupuesto", t0
        try:
            await asyncio.wait_for(triton_falso(triton_s), timeout=min(INFER_TIMEOUT_S, left))
            return 200, f"ok (Triton tenía {min(INFER_TIMEOUT_S, left) * 1000:.0f} ms)", t0
        except TimeoutError:
            return 504, f"timeout de inferencia (tenía {min(INFER_TIMEOUT_S, left) * 1000:.0f} ms)", t0
    
    async def main():
        casos = [("normal", 0.010, 0.008), ("Triton lento", 0.010, 1.0),
                 ("decode lento", 0.200, 0.080), ("decode eterno", 0.300, 0.008)]
        for nombre, dec, tri in casos:
            codigo, detalle, t0 = await gateway(dec, tri)
            dt = (time.perf_counter() - t0) * 1000
            print(f"{nombre:13} -> {codigo} {detalle:38} respondió en {dt:4.0f} ms")
    
    asyncio.run(main())
    EOF
    python3.13 deadline.py

    Qué deberías ver: Cuatro líneas. normal → 200 en ≈ 20 ms (Triton tenía 150 ms). Triton lento → 504 timeout de inferencia en ≈ 160 ms: el gateway no espera el segundo entero. decode lento → 504 en ≈ 250 ms, y Triton tenía solo ≈ 49 ms: lo que quedaba del presupuesto. decode eterno → 504 se agotó el presupuesto en ≈ 300 ms: ni se llamó a Triton, pero el decode no se corta, así que el caller (300 ms) ya se fue. Los ms exactos varían 1 o 2 entre corridas.

  2. Paso 2
    cat > envuelo.py <<'EOF'
    # envuelo.py: 100 req/s durante 3 s contra un Triton colgado 1 s; ¿cuántas llamadas abiertas a la vez?
    import asyncio
    
    async def llamada(timeout_s, estado):
        estado["abiertas"] += 1
        estado["max"] = max(estado["max"], estado["abiertas"])
        try:
            await asyncio.wait_for(asyncio.sleep(1.0), timeout=timeout_s)  # Triton no contesta en 1 s
        except TimeoutError:
            pass  # el gateway respondería 504
        finally:
            estado["abiertas"] -= 1
    
    async def carga(timeout_s, tasa=100, segundos=3):
        estado = {"abiertas": 0, "max": 0}
        loop = asyncio.get_running_loop()
        inicio, tareas = loop.time(), []
        for i in range(tasa * segundos):
            await asyncio.sleep(max(0.0, inicio + i / tasa - loop.time()))  # una llamada cada 10 ms
            tareas.append(asyncio.create_task(llamada(timeout_s, estado)))
        await asyncio.gather(*tareas)
        return estado["max"]
    
    async def main():
        for t in (None, 0.15):
            w = 1.0 if t is None else t
            print(f"timeout {str(t):4}: máximo de llamadas abiertas = {await carga(t):3}   (Little: 100 req/s × {w} s = {100 * w:.0f})")
    
    asyncio.run(main())
    EOF
    python3.13 envuelo.py

    Qué deberías ver: Tarda ≈ 7 s. timeout None: máximo de llamadas abiertas = 101 (Little: 100). timeout 0.15: máximo = 16 (Little: 15). El timeout no hace más rápido a Triton: acota cuánto se acumula en el gateway mientras Triton está mal (16 coincide con el MAX_INFLIGHT de C01).

  3. Paso 3
    cat > reintentos.py <<'EOF'
    # reintentos.py: 1,000 clientes fallan juntos en t = 0 y reintentan 3 veces; ¿cuántos golpean a la vez?
    import random
    from collections import Counter
    
    def llegadas(jitter, clientes=1000, reintentos=3, base_ms=100, tope_ms=2000):
        random.seed(7)  # misma semilla = mismos números en cualquier Python 3
        tiempos = []
        for _ in range(clientes):
            t = 0.0
            for n in range(reintentos):
                maximo = min(tope_ms, base_ms * 2 ** n)              # backoff exponencial: 100, 200, 400 ms
                t += random.uniform(0, maximo) if jitter else maximo  # full jitter: al azar entre 0 y el máximo
                tiempos.append(t)
        return tiempos
    
    for jitter in (False, True):
        ventanas = Counter(int(t // 10) for t in llegadas(jitter))  # reintentos por ventana de 10 ms
        nombre = "full jitter" if jitter else "sin jitter "
        print(f"{nombre}: pico = {max(ventanas.values()):4} reintentos en 10 ms; ventanas de 10 ms con tráfico = {len(ventanas)}")
    
    print("amplificación con 3 reintentos por capa y 3 capas:", (1 + 3) ** 3)
    print("amplificación con 2 reintentos por capa y 5 capas:", (1 + 2) ** 5)
    EOF
    python3.13 reintentos.py

    Qué deberías ver: sin jitter: pico = 1000 reintentos en 10 ms y solo 3 ventanas con tráfico (a los 100, 300 y 700 ms): los 1,000 clientes vuelven juntos, tres veces. full jitter: pico = 154, repartidos en 66 ventanas. Y las amplificaciones: 64 con 3 reintentos en 3 capas, 243 con 2 reintentos en 5 capas.

  4. Paso 4
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si ves asyncio.exceptions.TimeoutError sin atrapar, corriste un Python anterior a 3.11 (ahí wait_for lanza asyncio.TimeoutError, que todavía no era el TimeoutError normal de Python): usa python3.13. Los tiempos de deadline.py y envuelo.py pueden variar 1 o 2 ms o en una llamada; los de reintentos.py son exactos porque la semilla es fija. Para jugar: cambia INFER_TIMEOUT_S, la demora de Triton o la base del backoff.

Probado de verdad en un Mac. Ejecutado en macOS (Apple Silicon) con Python 3.13.15, 3.13.16 y 3.14.3, y repetido copiando los pasos tal cual en zsh: las salidas de arriba son las de esas corridas (en deadline.py, ±2 ms entre corridas). reintentos.py da exactamente lo mismo también con Python 3.9.6. Solo usa la biblioteca estándar (asyncio.wait_for, asyncio.sleep, random, collections).

6

Backpressure y control de admisión: límite en vuelo, 429 contra 503, load shedding

Si llegan más requests de los que tu pod puede terminar a tiempo, ¿los encolas, los rechazas o dejas que todo se caiga?

Sintaxis con variables

if request.app.state.inflight >= max_en_vuelo:
    raise HTTPException(503, "mensaje", headers={"Retry-After": "segundos"})
request.app.state.inflight += 1
try:
    resultado = await trabajo
finally:
    request.app.state.inflight -= 1

Control de admisión (admission control): decidir en la puerta si un request entra, antes de gastar CPU o GPU en él. Entre el if y el += 1 no hay await, y el event loop (un solo hilo que atiende las corrutinas por turnos) no deja que otra se meta en medio: no hace falta lock (un candado para que dos hilos no pisen el mismo dato). El finally devuelve el lugar aunque el trabajo falle.

try:
    await asyncio.wait_for(semaforo.acquire(), timeout=espera_max_s)
except TimeoutError:
    raise HTTPException(503, "mensaje", headers={"Retry-After": "segundos"}) from None
try:
    resultado = await run_in_threadpool(funcion, datos)
finally:
    semaforo.release()

Un semáforo (asyncio.Semaphore(n): un contador de n lugares; acquire toma uno o espera, release lo devuelve) acota el trabajo caro. run_in_threadpool corre la función en un hilo del threadpool (el grupo de hilos para trabajo bloqueante). La espera con tope es una cola acotada: si no hay lugar en 50 ms, ya no llegas a la meta de latencia y es mejor rechazar.

cuota = baldes[identidad_verificada]
if not cuota.tomar():
    raise HTTPException(429, "mensaje", headers={"Retry-After": "segundos"})

Cuota por caller (quien llama al gateway) con un token bucket (un balde de fichas que se recarga a ritmo fijo; cada request gasta una). 429 = tú te pasaste de tu cuota; 503 = yo estoy lleno. La identidad sale de algo verificado (el token de servicio que valida el ALB, el balanceador de AWS, o mTLS: TLS con certificado también del lado del cliente), nunca de un header que escribe el caller. C01 lo propone, pero su main.py no lo trae.

uvicorn modulo:app --limit-concurrency n --backlog cola_tcp

Tope de todo el proceso de uvicorn, no de tu ruta: con n conexiones abiertas (cuentan también las keep-alive ociosas, abiertas a la espera de otro request) o n tareas, uvicorn responde 503 con el texto Service Unavailable, sin Retry-After (el header que dice cuándo volver), también a /healthz y /readyz. --backlog (2048 por defecto) es la cola de conexiones TCP que el sistema operativo guarda antes de que uvicorn las acepte.

Variables

VariableQué esEjemplo
max_en_vuelorequests aceptados a la vez por proceso16 (MAX_INFLIGHT)
mensajetexto del detailgateway saturado: reintenta más tarde
segundosRetry-After en segundos enteros1
trabajola corrutina que hace el trabajo del request_classify(request, top_k, record, deadline=t0 + REQUEST_DEADLINE_S)
semaforoasyncio.Semaphore creado en el lifespanrequest.app.state.cpu_slots (asyncio.Semaphore(2))
espera_max_scuánto se puede esperar un lugar0.05 (SLOT_WAIT_S)
funcion, datoslo que corre en el threadpool y sus datos_decode_and_hash, data
baldes, identidad_verificadaun token bucket por caller, buscado por su identidad verificadaclient_id del token de servicio (propuesta; no está en C01)
modulo, n, cola_tcpla app de uvicorn, el tope de conexiones o tareas y la cola TCPapp.main; C01 no usa --limit-concurrency; backlog 2048 (default)
L, λ, WL,\ \lambda,\ Wrequests en vuelo, llegadas por segundo a un pod, tiempo promedio adentro20 = 100 × 0.2
ncola, μn_{\text{cola}},\ \murequests esperando, requests que el pod termina por segundo100 esperando con μ = 100 req/s → 1 s de espera
ρ\rhoutilización: llegadas ÷ capacidad250 ÷ 167 ≈ 1.5 en el lab

Fórmulas

L=λ⋅W100 req/s×0.2 s=20  →  MAX_INFLIGHT=16 (tope por RAM)L = \lambda \cdot W \qquad 100\ \text{req/s} \times 0.2\ \text{s} = 20 \;\rightarrow\; \texttt{MAX\_INFLIGHT} = 16\ \text{(tope por RAM)}
80 MB (Python)+2×320 MB (decodes)+16×10 MB (bodies)=880 MB<1 GiB80\ \text{MB (Python)} + 2 \times 320\ \text{MB (decodes)} + 16 \times 10\ \text{MB (bodies)} = 880\ \text{MB} < 1\ \text{GiB}
Wcola=ncolaμ100 requests100 req/s=1 s≫250 msW_{\text{cola}} = \frac{n_{\text{cola}}}{\mu} \qquad \frac{100\ \text{requests}}{100\ \text{req/s}} = 1\ \text{s} \gg 250\ \text{ms}
ρ=λμ=250167≈1.5  ⇒  a tiempo≈μλ≈67 % (el lab da 68.6 %)\rho = \frac{\lambda}{\mu} = \frac{250}{167} \approx 1.5 \;\Rightarrow\; \text{a tiempo} \approx \frac{\mu}{\lambda} \approx 67\ \%\ \text{(el lab da 68.6 \%)}

Las piezas

PiezaQué haceEjemplo
Backpressure (contrapresión)Avisar hacia atrás que estás lleno, para que quien manda baje el ritmo: colas con tope, y 503 o 429 con Retry-After.503 + Retry-After: 1
Control de admisiónDecidir en la puerta si un request entra, antes de gastar CPU o GPU en él.inflight >= 16 → 503, sin leer el body
Load sheddingDescartar carga a propósito: rechazar barato lo que no vas a poder terminar a tiempo.40 fotos a la vez: 14 × 200 y 26 × 503 rápidos (README de C01)
Requests en vuelo (in-flight)Los que ya entraron y todavía no terminan. Little: L = λ × W.≈ 3–5 por pod en el pico normal
asyncio.SemaphoreUn contador de lugares para un recurso caro: acquire toma uno o espera; release lo devuelve.2 decodes a la vez por proceso
Cola acotadaEsperar con tope de tiempo o de tamaño. Sin tope, la cola solo agrega espera hasta que todo vence.≤ 50 ms por un slot de CPU; ≤ 100 ms en la cola de Triton
503 Service UnavailableYo, el servidor, estoy saturado o sin una dependencia, y es temporal. Puede llevar Retry-After.gateway saturado; Triton no disponible
429 Too Many RequestsTú, el caller, pasaste tu cuota de ritmo. Puede llevar Retry-After. La RFC 6585, que lo define, no dice cómo contar.C01 lo propone por caller; no está en main.py
Retry-AfterCuánto esperar antes de volver: segundos enteros o una fecha HTTP.Retry-After: 1
Token bucketUn balde de B fichas que se recarga a r por segundo; cada request gasta una. Permite ráfagas de hasta B.r = 50/s y B = 100 (ejemplo de M15)
--limit-concurrency (uvicorn)Tope de todo el proceso de uvicorn: con n conexiones o tareas → 503 sin Retry-After, también a los health checks.C01 limita dentro de la ruta, no aquí
Bulkhead y prioridadSeparar cargas para que una no agote la capacidad de otra: un gateway aparte para los scripts y prioridades en la cola de Triton.propuesta del serving de C01
Medir la saturaciónC01 cuenta los 503 por código, pero no exporta cuántos requests hay en vuelo. Un Gauge (una métrica que sube y baja) con set_function(lambda: app.state.inflight) lo daría en cada lectura de Prometheus (scrape).mejora posible; no está en C01
Fallback del callerLo que hace quien llama cuando no hay respuesta útil.C01: el selector manual; con 300 ms de presupuesto no espera un Retry-After de 1 s

llegan requests

  1. 1

    Requests en vuelo

    16 lugares por proceso

    Dónde: classify(), antes de leer el body

    Si está llena: 503 gateway saturado: reintenta más tarde Retry-After: 1

    Ya se gastó: microsegundos

  2. 2

    Slots de CPU (decode)

    2 lugares a la vez

    espera ≤ 50 ms

    Dónde: _classify(), antes del decode

    Si está llena: 503 gateway saturado (CPU) Retry-After: 1

    Ya se gastó: leer el body

  3. 3

    Cola de Triton

    sin lugares fijos: un tope de tiempo

    espera ≤ 100 ms

    Dónde: config.pbtxt

    Si está llena: Triton: Request timeout expired → gateway: 503 Triton rechazó la inferencia Retry-After: 1

    Ya se gastó: el decode

la GPU

Lo que midió el README del C01

16 fotos a la vez (README de C01)

200 16503 0 rápidosp50 40 ms

40 fotos a la vez (README de C01)

200 14503 26 rápidosp50 7 ms

Lab: 250 req/s contra un pod que termina ≈ 167 req/s, 20 s

Cola sin tope

p99 de los que llegan a tiempo: 249.5 mspico en vuelo: 1,554CPU gastada en respuestas tardías: 97.4 %

Puertas de C01

p99 de los que llegan a tiempo: 90.2 mspico en vuelo: 16CPU gastada en respuestas tardías: 0 %

a tiemporechazado rápido (503)tarde: el caller ya se fue

Las puertas de un pod del gateway de C01: 16 lugares en vuelo, 2 slots de decode con 50 ms de espera y la cola de Triton con 100 ms. Lo que no entra recibe un 503 con Retry-After: al instante en la primera puerta, tras ≤ 50 ms en la segunda y tras ≤ 100 ms en la cola de Triton. Abajo, el lab: el mismo pod con 1.5 veces su capacidad, con cola sin tope contra con puertas.

Las tres puertas de C01 (y la cuarta que propone)

PuertaDóndeTopeSi está llenaQué ya se gastó
Requests en vueloclassify(), antes de leer el body16 por proceso503 gateway saturado: reintenta más tarde, con Retry-After: 1nada: microsegundos
Slots de CPU_classify(), después de validar y leer el body2 decodes; espera ≤ 50 ms503 gateway saturado (CPU), con Retry-After: 1leer el body (hasta 10 MB)
Cola de Tritonconfig.pbtxt (default_queue_policy)espera ≤ 100 msTriton responde Request timeout expired; el gateway devuelve 503 Triton rechazó la inferencia: …, con Retry-After: 1el decode en CPU
Cuota por caller (propuesta)el gateway (token bucket) o una regla rate-based de AWS WAF (el firewall de aplicaciones de AWS) en el ALBr y B por identidad (con WAF: N requests por ventana de 1, 2, 5 o 10 min)429 con Retry-After (el WAF bloquea con 403 si no le configuras una respuesta propia con 429)nada

La puerta más barata va primero. Todas responden rápido, con un código que el caller entiende; ninguna deja requests colgados.

Ejemplo real · main.py: las dos puertas del gateway

Pythonreference/c01/gateway/app/main.py (líneas 46–50, 102–106, 221–229 y 273–277; # … marca líneas omitidas)
# Load shedding: requests aceptados a la vez por proceso; el resto → 503 inmediato con Retry-After.
# ≈ lo que un pod completa en ≈ 200 ms (≈ 100 req/s × 0.2 s ≈ 20) y acotado por RAM: 16 bodies de
# hasta 10 MB = 160 MB, que caben en 1Gi junto a los decodes. Al pico hay ≈ 3–5 en vuelo por pod.
MAX_INFLIGHT = int(os.getenv("MAX_INFLIGHT", "16"))
SLOT_WAIT_S = 0.05  # espera máxima por un slot de CPU: si hay que esperar más, ya no cumple el SLO → 503
# …
    # Sin este límite el threadpool (40 hilos por defecto) decodificaría 40 imágenes a la vez en un
    # pod de 1 CPU: más latencia para todas y riesgo de OOMKilled. Los demás esperan como mucho
    # SLOT_WAIT_S; si no, 503.
    app.state.cpu_slots = asyncio.Semaphore(PREPROCESS_CONCURRENCY)
    app.state.inflight = 0  # requests aceptados en este proceso; sin lock: el event loop es un solo hilo
# …
    try:
        state = request.app.state
        if state.inflight >= MAX_INFLIGHT:  # fallar rápido (µs) > encolar lo que va a vencer de todos modos
            raise HTTPException(503, "gateway saturado: reintenta más tarde", headers={"Retry-After": "1"})
        state.inflight += 1
        try:
            result = await _classify(request, top_k, record, deadline=t0 + REQUEST_DEADLINE_S)
        finally:
            state.inflight -= 1  # siempre, también si _classify lanza
# …
    slots = request.app.state.cpu_slots
    try:
        await asyncio.wait_for(slots.acquire(), timeout=SLOT_WAIT_S)
    except TimeoutError:
        raise HTTPException(503, "gateway saturado (CPU)", headers={"Retry-After": "1"}) from None
  • La primera puerta está antes de leer el body: un 503 de aquí cuesta microsegundos y no toca CPU ni GPU. El test test_overload_is_503_with_retry_after lo comprueba con MAX_INFLIGHT = 0: 503, Retry-After: 1 y el Triton falso sin llamadas.
  • app.state.inflight es un entero sin lock: entre el if y el += 1 no hay await, y el event loop corre una corrutina a la vez. Con --workers 2 serían dos contadores, uno por proceso; C01 corre un proceso por contenedor.
  • 16 sale de Little y de la RAM: ≈ 100 req/s × 0.2 s ≈ 20 en vuelo si cada request tardara 200 ms (la meta del p99), recortado a 16 porque 16 bodies de hasta 10 MB son 160 MB dentro del 1 GiB del pod. En el pico normal hay ≈ 3–5: llegar a 16 ya dice que algo va lento.
  • La segunda puerta es una cola acotada: hasta 50 ms esperando uno de los 2 slots de decode (PREPROCESS_CONCURRENCY). Sin ella, solo frenaría la primera puerta: hasta 16 decodes a la vez en un pod de 1 CPU (el comentario habla de los 40 hilos del threadpool, el tope si tampoco hubiera primera puerta). Todas irían más lento, y en el peor caso 16 decodes de 40 MP (≈ 320 MB cada uno) no caben en 1 GiB: OOMKilled (el kernel mata el contenedor por pasarse de memoria).
  • Medido en el README de C01 contra un Triton falso (un servidor que habla el mismo protocolo, KServe v2): 16 fotos a la vez → 16 × 200 (p50 ≈ 40 ms); 40 a la vez → 14 × 200 y 26 × 503 rápidos (p50 ≈ 7 ms), y ninguna pasa de 300 ms. Mientras tanto, el HPA (el autoscaler de pods de Kubernetes, que aquí escala por CPU al 60 %) agrega pods: el shedding compra ese tiempo.

Cómo recordarla

el portero dice «lleno, vuelve en un momento» (503 + Retry-After) en vez de dejar entrar a todos hasta que el local colapsa. Tres puertas en C01: 16 en vuelo, 2 decodes con 50 ms de espera y 100 ms en la cola de Triton. Y dos códigos: 429 = te pasaste tú, 503 = estoy lleno yo.

Trampa

«mejor encolar que rechazar». Una cola sin tope no agrega capacidad, solo espera: con 250 req/s contra un pod que termina ≈ 167, la cola crece sin parar, y en el lab el 97 % de los requests termina después del deadline, con el 97 % de la CPU gastada en respuestas que nadie espera. Con las puertas, el 69 % sale a tiempo y el resto recibe un 503 rápido. Otras dos: devolver 503 por una cuota (va 429) dispara tu alerta de disponibilidad por culpa de un caller; y usar solo --limit-concurrency le responde 503 también a /healthz, y el kubelet (el agente de Kubernetes en cada nodo) puede reiniciar el pod justo en el pico.

Laboratorio (opcional)4 pasos

Qué vas a provocar: Comparar, con una simulación determinista hecha solo con Python estándar, un pod sin límite contra uno con las puertas de C01, a tres ritmos de llegada: cuántos salen a tiempo, cuántos reciben 503 y el p99 (el tiempo bajo el que queda el 99 % de los que salen a tiempo).

Necesitas: Python 3, sin paquetes extra (los comandos usan python3.13, la versión de C01). Ni Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    cat > admision.py <<'EOF'
    # admision.py: un pod del gateway con 2 slots de CPU, con y sin control de admisión (simulación, solo stdlib)
    import heapq, random, sys
    
    SLOTS, MAX_EN_VUELO, ESPERA_SLOT, DEADLINE, TRITON = 2, 16, 0.050, 0.250, 0.008  # números de C01
    
    def simular(tasa, con_admision, segundos=20):
        random.seed(1)  # misma semilla = mismos números en cualquier Python 3
        t, llegadas = 0.0, []
        while t < segundos:                       # llegadas al azar (Poisson): `tasa` requests por segundo
            t += random.expovariate(tasa)
            llegadas.append((t, 0.004 + random.expovariate(1 / 0.008)))  # decode: 4 ms + exponencial de 8 ms
        libres = [0.0] * SLOTS                    # cuándo queda libre cada slot de CPU
        en_vuelo = []                             # cuándo termina cada request aceptado
        a_tiempo, rechazos, tarde, cpu_total, cpu_tirada, lat, pico = 0, 0, 0, 0.0, 0.0, [], 0
        for llega, decode in llegadas:
            while en_vuelo and en_vuelo[0] <= llega:
                heapq.heappop(en_vuelo)
            if con_admision and len(en_vuelo) >= MAX_EN_VUELO:
                rechazos += 1                     # puerta 1: 503 en microsegundos
                continue
            inicio = max(llega, libres[0])        # espera un slot (FIFO)
            if con_admision and inicio - llega > ESPERA_SLOT:
                rechazos += 1                     # puerta 2: sin slot en 50 ms → 503
                heapq.heappush(en_vuelo, llega + ESPERA_SLOT)
                continue
            heapq.heapreplace(libres, inicio + decode)
            fin = inicio + decode + TRITON
            heapq.heappush(en_vuelo, fin)
            pico = max(pico, len(en_vuelo))
            cpu_total += decode
            if fin - llega <= DEADLINE:
                a_tiempo += 1
                lat.append(fin - llega)
            else:
                tarde += 1                        # el caller ya se fue: CPU tirada
                cpu_tirada += decode
        n = len(llegadas)
        lat.sort()
        p99 = lat[int(0.99 * len(lat))] * 1000 if lat else float("nan")
        print(f"{'con admisión' if con_admision else 'sin límite  '}  a tiempo {100 * a_tiempo / n:5.1f} %  "
              f"503 {100 * rechazos / n:5.1f} %  tarde {100 * tarde / n:5.1f} %  "
              f"p99 a tiempo {p99:6.1f} ms  pico en vuelo {pico:5}  CPU tirada {100 * cpu_tirada / max(cpu_total, 1e-9):5.1f} %")
    
    tasa = float(sys.argv[1]) if len(sys.argv) > 1 else 100
    print(f"{tasa:.0f} req/s contra un pod que termina ≈ 167 req/s (2 slots ÷ 12 ms de decode promedio)")
    for con_admision in (False, True):
        simular(tasa, con_admision)
    EOF
    python3.13 admision.py 100

    Qué deberías ver: Dos líneas con los mismos números: a tiempo 100.0 %, 503 0.0 %, p99 a tiempo 55.7 ms, pico en vuelo 9. Con ρ ≈ 0.6 (100 ÷ 167) las puertas no hacen nada: así debe verse el día a día.

  2. Paso 2
    python3.13 admision.py 150

    Qué deberías ver: ρ ≈ 0.9. sin límite: todo a tiempo, pero el p99 sube a 111.6 ms y hay hasta 22 en vuelo. con admisión: 97.7 % a tiempo, 2.3 % de 503 y p99 de 73.8 ms, nunca más de 16. Cerca del límite, las puertas cambian un poco de rechazo por un p99 más bajo; los números finos (16, 50 ms) se ajustan con una prueba de carga (load test).

  3. Paso 3
    python3.13 admision.py 250

    Qué deberías ver: 1.5 veces la capacidad. sin límite: a tiempo 2.8 %, tarde 97.2 %, pico en vuelo 1554 y CPU tirada 97.4 %: el pod trabaja al máximo para nadie y llega a guardar 1554 bodies a la vez: con fotos web de varios MB, eso termina en OOMKilled. con admisión: a tiempo 68.6 % (≈ 167 ÷ 250), 503 31.4 %, p99 de 90.2 ms, 16 en vuelo y nada de CPU tirada.

  4. Paso 4
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Los números son exactos con cualquier Python 3 porque la semilla es fija; si no coinciden, revisa que copiaste el script entero. Para jugar, cambia SLOTS, MAX_EN_VUELO o ESPERA_SLOT en la línea de constantes. Es un modelo: no incluye leer el body, la red ni a Triton bajo carga.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.9.6, 3.13.16 y 3.14.3, y repetido copiando los pasos tal cual en zsh: salidas idénticas, con las cifras de arriba. Es una simulación de eventos, sin esperas reales: llegadas al azar (Poisson), decode de 4 ms más una exponencial de 8 ms (≈ 12 ms en promedio, ≈ 167 req/s con 2 slots), 8 ms de Triton y las puertas de main.py (16 en vuelo, 2 slots, 50 ms, deadline de 250 ms).

7

Errores y degradación: un código por falla, la escalera de C01 y nada de detalles internos

Cuando algo falla, ¿qué código devuelves, qué texto ve el caller, qué queda en el log y qué sigue funcionando?

Sintaxis con variables

try:
    resultado = await asyncio.wait_for(llamada, timeout=tope_s)
except TimeoutError:
    raise HTTPException(504, "msg_lento") from None
except error_de_red:
    raise HTTPException(503, "msg_caido", headers={"Retry-After": "s"}) from None
except error_del_upstream:
    raise HTTPException(503, "msg_rechazo", headers={"Retry-After": "s"}) from None

Traduce en la frontera, con excepciones específicas: lento → 504; caído → 503 con Retry-After (cuántos segundos esperar); el upstream (el servicio al que llamas) respondió con un error → 503, como C01. Sirve para «cola llena» o «modelo cargando»; un error de contrato (el request no cumple lo que el upstream espera, como un shape equivocado) no se arregla reintentando. from None esconde la excepción original en el traceback (el reporte del error en el log). Lo que ve el caller (quien llama al gateway) es el detail, el texto del error: corto y estable.

@app.exception_handler(MiError)
async def manejador(request: Request, exc: MiError):
    log.warning("evento: %r", exc)
    return JSONResponse({"detail": "mensaje_publico"}, status_code=codigo)

Un solo lugar que convierte una excepción tuya en respuesta. El mensaje público es fijo; el detalle técnico (repr(exc)) va al log. Para cambiar cómo sale cualquier HTTPException, registra el handler para el de Starlette (starlette.exceptions.HTTPException), como indica la doc de FastAPI.

HTTP/1.1 codigo frase
retry-after: s
x-request-id: request_id
content-type: application/json

{"detail":"mensaje_publico"}

Así le llega un error de C01 al caller: el código, Retry-After si es temporal, el id para reportar el caso y un texto corto. Starlette manda los nombres de los headers en minúsculas; HTTP no distingue. Un bug (una excepción que nadie atrapó) sale como 500 con el texto plano Internal Server Error: Starlette no manda el traceback mientras debug sea False, que es el default.

Variables

VariableQué esEjemplo
llamada, tope_sla llamada al upstream y su topeclient.infer(…), min(0.15, lo que queda)
error_de_redexcepción de red del cliente HTTPaiohttp.ClientError (tritonclient aio); httpx.TransportError (httpx; ojo: también incluye sus timeouts, httpx.TimeoutException, que no son TimeoutError: atrápalos antes, con su propio except, para responder 504)
error_del_upstreamexcepción cuando el upstream responde con un errorInferenceServerException; httpx.HTTPStatusError después de raise_for_status()
msg_lento, msg_caido, msg_rechazotextos fijos para el callertimeout de inferencia · Triton no disponible · Triton rechazó la inferencia
ssegundos de Retry-After1
MiError, manejador, eventotu excepción, su handler y el nombre del evento en el logUpstreamRechazo, manejar_rechazo, upstream_rechazo (ilustrativo)
mensaje_publico, codigo, frasetexto para el caller, código HTTP y su frasegateway saturado: reintenta más tarde · 503 · Service Unavailable
request_idel id del requestabc123
5xx, 4xx, todos\text{5xx},\ \text{4xx},\ \text{todos}requests por segundo según su código, sumados entre podsclassifier_requests_total{status=~"5.."}
presupuesto de error\text{presupuesto de error}lo que una meta de 99.9 % deja fallar en 30 días300,000 requests a 10 M por día
LLrequests colgados esperando a un Triton caído (Little)75 sin breaker; ≈ 0.5 con el breaker abierto (cálculo de M15)

Fórmulas

tasa de 5xx=∑rate(5xx)∑rate(todos)−∑rate(4xx)alerta urgente (page) si>1 % durante 5 min\text{tasa de 5xx} = \frac{\sum \text{rate}(\text{5xx})}{\sum \text{rate}(\text{todos}) - \sum \text{rate}(\text{4xx})} \qquad \text{alerta urgente (page) si} > 1\ \%\ \text{durante 5 min}
presupuesto de error=(1−0.999)×10 M/dıˊa×30 dıˊas=300,000 requests al mes\text{presupuesto de error} = (1 - 0.999) \times 10\ \text{M/día} \times 30\ \text{días} = 300{,}000\ \text{requests al mes}
sin breaker: L=500 req/s×0.15 s=75 colgadosbreaker abierto: 500×0.001 s≈0.5\text{sin breaker: } L = 500\ \text{req/s} \times 0.15\ \text{s} = 75\ \text{colgados} \qquad \text{breaker abierto: } 500 \times 0.001\ \text{s} \approx 0.5

Las piezas

PiezaQué haceEjemplo
4xxCulpa del request: repetirlo igual falla igual, y no cuenta contra el SLO (la meta de servicio). La excepción es 429: se reintenta después del Retry-After.400, 413, 415, 422; 429 sí se reintenta
5xxCulpa nuestra o de una dependencia: cuenta contra el SLO y gasta presupuesto de error.500, 503, 504
500 Internal Server ErrorUn bug: algo que nadie atrapó. Starlette responde texto plano y uvicorn loguea el traceback (Exception in ASGI application).C01 lo cuenta como status 500 en su línea de log
502 Bad GatewaySoy un gateway y el upstream me dio una respuesta inválida: rota o cortada.el ALB (el balanceador de AWS) lo da si uvicorn cierra una conexión ociosa que el ALB iba a reusar: --timeout-keep-alive (5 s por defecto) debe ser mayor que los 60 s del ALB; C01 usa 75
503 Service UnavailableAhora no puedo, y es temporal: sobrecarga o una dependencia caída. Con Retry-After.gateway saturado; Triton caído o rechazando
504 Gateway TimeoutSoy un gateway y el upstream no respondió a tiempo.Triton lento; presupuesto agotado
HTTPExceptionLa excepción de FastAPI que se vuelve respuesta: status_code, detail (lo que ve el caller) y headers.HTTPException(503, …, headers={"Retry-After": "1"})
exception_handlerUna función que convierte una excepción tuya en respuesta, en un solo lugar.@app.exception_handler(MiError)
debug=FalseEl default: un 500 no muestra el traceback. Con debug=True, Starlette lo manda en la respuesta.nunca debug=True en producción
No filtrar detalles internosAl caller, un texto corto y estable; rutas, hosts, versiones y trazas, al log.C01: el 413 cita las dimensiones de la imagen; el 503 de Triton lleva el texto de Triton
Escalera de degradaciónLo que sacrificas primero, decidido antes del incidente: cada escalón conserva lo más valioso.6 escalones en C01
FallbackEl camino sin el modelo: más barato y sin depender de lo que falló.C01: el selector manual en la interfaz del caller
Circuit breakerDejar de llamar un rato a una dependencia que falla y responder el fallback al instante. Tres estados: closed (llama normal), open (no llama) y half-open (deja pasar unas llamadas de prueba).propuesta de M15; C01 sale del ALB con la readiness
Falla silenciosaTodo responde 200 y rápido, pero mal. Ningún código de error la ve.labels.json desalineado con el modelo
  1. Normal

    Cuándo: todo sano

    Qué hace: top-5 del modelo en producción con p99 ≤ 200 ms; todos los callers atendidos

    El usuario ve: sugerencias de categoría

    El gateway responde: 200

  2. Se pierde una GPU o una zona

    Cuándo: un nodo GPU o una zona caídos

    Qué hace: la otra zona absorbe el 100 % del tráfico al ≈ 42 % de su máximo medido; el autoscaler repone

    El usuario ve: nada

    El gateway responde: 200

  3. Saturación

    Cuándo: cola de Triton alta, CPU del gateway al tope

    Qué hace: load shedding por prioridad: primero los callers no interactivos (su propio gateway y prioridad 2 en Triton), después cuotas estrictas

    El usuario ve: la app conserva su capacidad

    El gateway responde: 503 + Retry-After a los scripts; 429 por cuota (propuesta)

  4. Latencia degradada

    Cuándo: Triton lento

    Qué hace: fallar rápido: 504 a los 150 ms, sin reintentos en el camino interactivo

    El usuario ve: el selector manual en esos requests

    El gateway responde: 504

  5. Modelo nuevo sospechoso

    Cuándo: el canary (la versión nueva con poco tráfico) empeora la confianza, la mezcla de categorías o la aceptación

    Qué hace: rollback (volver a la versión anterior): segundos si sigue viva; ≈ 5–20 min si hay que volver a desplegar su imagen

    El usuario ve: sugerencias del modelo anterior

    El gateway responde: 200 con otro model_version

  6. Servicio caído

    Cuándo: Triton caído en las dos zonas

    Qué hace: la interfaz muestra el selector manual; la publicación se crea igual y el camino batch la clasifica después

    El usuario ve: elige la categoría a mano, como antes del modelo

    El gateway responde: 503 rápido, o el ALB responde 503 sin targets

Los escalones se deciden antes del incidente, no durante.

okrechazado a propósito (4xx o 503)falla (500, 502, 504)

La escalera de degradación de C01, de arriba abajo: qué se sacrifica primero cuando algo falla. Cada escalón conserva lo más valioso con lo que queda; el último sigue siendo útil porque existe un camino sin el modelo.

Qué devuelve el gateway de C01 en cada falla (main.py)

FallaCódigodetail que ve el callerHeaders¿Reintentar?
Body vacío400body vacío: envía los bytes de la imagenX-Request-IDNo: arregla el request
Imagen corrupta o truncada, o relación de aspecto mayor a 1:8400no se pudo decodificar la imagenX-Request-IDNo
Más de 10 MB (por Content-Length o contando los bytes)413la imagen supera 10 MBX-Request-IDNo
Más de 40 MP (megapíxeles)413el texto de la excepción: dimensiones y límiteX-Request-IDNo
Content-Type que no es JPEG, PNG ni WebP415Content-Type debe ser image/jpeg, image/png o image/webpX-Request-IDNo
top_k fuera de 1–20422la lista de errores de validación de FastAPIX-Request-ID (lo agrega su exception handler: FastAPI responde antes de llamar a classify)No
El cliente corta a mitad del upload (ClientDisconnect)499el cliente cerró la conexión antes de terminar el upload (nadie lo recibe)X-Request-IDLo decide el cliente: ya se fue
16 requests en vuelo503gateway saturado: reintenta más tardeRetry-After: 1, X-Request-IDSí, después de 1 s (callers batch)
Sin slot de CPU en 50 ms503gateway saturado (CPU)Retry-After: 1, X-Request-IDSí, después de 1 s
Triton caído (aiohttp.ClientError)503Triton no disponibleRetry-After: 1, X-Request-IDSí, después de 1 s
Triton responde con error (InferenceServerException)503Triton rechazó la inferencia: … y el texto de TritonRetry-After: 1, X-Request-IDSí, después de 1 s
Triton no responde a tiempo (TimeoutError)504timeout de inferenciaX-Request-IDNo en el camino interactivo
Se agotó el presupuesto de 250 ms504se agotó el presupuesto del requestX-Request-IDNo
Triton devuelve logits con NaN o inf500Triton devolvió logits no finitos (NaN o inf)X-Request-IDNo: hay que revisar el engine
Un bug (cualquier otra excepción)500Internal Server Error, en texto plano (Starlette)ningunoNo: hay que arreglarlo

Ni 429 ni 502: C01 no limita por caller en el código, y trata cualquier error de Triton como «ahora no» (503). Los 4xx no entran en la tasa de error ni en el histograma de latencia; los 5xx sí.

Ejemplo real · main.py: cada falla con su código, y todos los errores con su X-Request-ID

Pythonreference/c01/gateway/app/main.py (líneas 173–180, 279–288 y 234–239; # … marca líneas omitidas)
    except TimeoutError:  # asyncio.TimeoutError es alias de TimeoutError desde Python 3.11
        # 504: Triton está vivo pero lento (cola llena, GPU saturada); el cliente cae a su fallback
        raise HTTPException(504, "timeout de inferencia") from None
    except aiohttp.ClientError:  # conexión rechazada/reseteada, DNS: Triton caído o reiniciando
        raise HTTPException(503, "Triton no disponible", headers={"Retry-After": "1"}) from None
    except InferenceServerException as exc:  # Triton respondió error: modelo no cargado, request rechazado
        detail = f"Triton rechazó la inferencia: {exc.message()}"
        raise HTTPException(503, detail, headers={"Retry-After": "1"}) from None
# …
    try:
        # Aquí no hay timeout a propósito: un hilo no se puede cancelar. Si cortáramos el await, el
        # slot se liberaría con el hilo todavía decodificando y el tope de RAM dejaría de valer.
        batch, digest = await run_in_threadpool(_decode_and_hash, data)
    except (ImageTooLarge, Image.DecompressionBombError) as exc:  # ImageTooLarge antes que ValueError
        raise HTTPException(413, str(exc)) from None
    except (OSError, ValueError, SyntaxError):  # lo que lanza Pillow con archivos corruptos o truncados
        raise HTTPException(400, "no se pudo decodificar la imagen") from None
    finally:
        slots.release()  # siempre: también en 400/413
# …
    except HTTPException as exc:
        status = exc.status_code
        record["error"] = exc.detail
        # Re-lanzamos con el X-Request-ID para que también los errores sean trazables
        headers = {**(exc.headers or {}), "X-Request-ID": request_id}
        raise HTTPException(exc.status_code, exc.detail, headers=headers) from None
  • Cada falla de Triton tiene su código: lento (TimeoutError) → 504; caído (aiohttp.ClientError: conexión rechazada o cortada, o un nombre que no resuelve) → 503 con Retry-After; responde con un error (InferenceServerException, como el Request timeout expired de su cola) → 503. No usa 502: Triton contestó bien, con un error, no con una respuesta rota. Ojo: C01 manda cualquier error de Triton como 503 «ahora no», aunque algunos (un modelo que no existe, un shape equivocado) no se arreglan reintentando.
  • Un engine roto (un FP16 desbordado, pesos corruptos) puede devolver NaN o inf. _infer lo revisa con np.isfinite y responde un 500 propio, con su X-Request-ID. Sin esa revisión, FastAPI fallaría al escribir el JSON después del finally: el cliente vería un 500 y la línea y la métrica dirían 200.
  • Las excepciones de la imagen van de la más específica a la más general: ImageTooLarge hereda de ValueError, así que tiene que ir antes, o caería en el 400. str(exc) en el 413 está bien: es nuestro texto (ImageTooLarge) o el de Pillow (DecompressionBombError, sobre 80 MP), y los dos solo citan los píxeles de la imagen del caller y el límite.
  • El 503 de Triton incluye exc.message(), el texto de Triton. Entre equipos internos, detrás de un ALB internal (un balanceador que solo se alcanza desde la red interna), ayuda a depurar; en una API pública, ese texto iría al log y el caller recibiría un mensaje fijo.
  • El except HTTPException de classify anota el error en la línea de log y relanza el mismo código y detail con X-Request-ID: el caller puede reportar «el request abc123 dio 504». Un bug no pasa por aquí: sale como el 500 de Starlette, sin X-Request-ID, aunque su línea de log sí queda con status 500.
  • El decode no tiene timeout, a propósito: un hilo no se puede cancelar, y cortar el await liberaría el slot con el hilo todavía decodificando. Por eso los límites van antes (10 MB, 40 MP, 1:8) y el finally devuelve el slot también en 400 y 413.

Cómo recordarla

4xx = culpa del request, no lo repitas igual (salvo 429: espera el Retry-After); 503 = ahora no, vuelve después; 504 = no llegó a tiempo; 500 = algo nuestro se rompió (un bug, o logits con NaN). Al caller, un texto corto y el X-Request-ID; el detalle, al log. Y la escalera: decide antes qué sacrificas primero, para que el último escalón siga siendo útil (en C01, elegir la categoría a mano).

Trampa

detail=str(e) dentro de un except Exception. El texto de una excepción cualquiera puede traer rutas, hosts internos, versiones o pedazos de datos, y además cambia de una versión a otra, así que nadie puede depender de él. Y su gemela: atrapar todo y devolver 200 con una respuesta vacía. Para el caller parece éxito, las métricas no ven ningún error y nadie se entera: una falla silenciosa hecha a mano.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Ver, con un gateway mínimo y un upstream falso, qué código, qué headers y qué texto recibe el caller en cada falla, y qué deja ver debug=True.

Necesitas: Python 3.13 e internet para pip (fastapi, uvicorn y sus dependencias). Dos terminales. Ni Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    [ -d .venv ] || python3.13 -m venv .venv
    . .venv/bin/activate
    pip install "fastapi==0.141.1" "uvicorn==0.54.0"
    cat > errores.py <<'EOF'
    # errores.py: un gateway mínimo con un upstream falso, para ver qué recibe el caller en cada falla
    import asyncio, os, uuid
    from fastapi import FastAPI, HTTPException, Request
    
    class Rechazo(Exception):  # hace el papel de InferenceServerException
        pass
    
    async def upstream(modo):  # un Triton falso
        if modo == "lento":
            await asyncio.sleep(1.0)
        if modo == "caido":
            raise ConnectionRefusedError("connect ECONNREFUSED 10.0.3.7:8000")
        if modo == "rechazo":
            raise Rechazo("Request timeout expired")
        if modo == "bug":
            return {}["logits"]  # un bug nuestro: KeyError
        return [0.83, 0.07, 0.04]
    
    app = FastAPI(debug=os.getenv("DEBUG") == "1")
    
    @app.post("/v1/classify")
    async def classify(request: Request, modo: str = "ok"):
        request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
        try:
            try:
                scores = await asyncio.wait_for(upstream(modo), timeout=0.15)
            except TimeoutError:
                raise HTTPException(504, "timeout de inferencia") from None
            except ConnectionError:
                raise HTTPException(503, "upstream no disponible", headers={"Retry-After": "1"}) from None
            except Rechazo:
                raise HTTPException(503, "upstream rechazó la inferencia", headers={"Retry-After": "1"}) from None
            return {"scores": scores}
        except HTTPException as exc:
            headers = {**(exc.headers or {}), "X-Request-ID": request_id}
            raise HTTPException(exc.status_code, exc.detail, headers=headers) from None
    EOF

    Qué deberías ver: Successfully installed, con fastapi-0.141.1 y uvicorn-0.54.0 en la lista (Requirement already satisfied si ya los tenías). Son las versiones de C01; FastAPI saca versiones casi a diario (0.160.0 el 22 de octubre de 2026) y lo que se ve aquí no cambia.

  2. Paso 2
    # en una segunda terminal
    cd ~/lab-gateway && . .venv/bin/activate
    uvicorn errores:app --port 8080

    Qué deberías ver: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit). Déjala abierta: ahí salen los logs del servidor.

  3. Paso 3
    for m in ok lento caido rechazo bug; do
      curl -s -o /dev/null -w "$m %{http_code} %{time_total}s\n" -X POST "localhost:8080/v1/classify?modo=$m"
    done

    Qué deberías ver: ok 200; lento 504 en ≈ 0.15 s, no en 1 s; caido 503; rechazo 503; bug 500. En la segunda terminal, cada uno deja su línea de access log (el bug, con 500 Internal Server Error) y el bug, además, ERROR: Exception in ASGI application y un traceback que termina en KeyError: 'logits'.

  4. Paso 4
    curl -i -X POST "localhost:8080/v1/classify?modo=caido" -H "X-Request-ID: abc123"

    Qué deberías ver: HTTP/1.1 503 Service Unavailable, con retry-after: 1, x-request-id: abc123 y content-type: application/json; el body es {"detail":"upstream no disponible"}. El texto original (connect ECONNREFUSED 10.0.3.7:8000, con una IP interna) no llegó al caller.

  5. Paso 5
    curl -s -X POST "localhost:8080/v1/classify?modo=bug"; echo

    Qué deberías ver: Internal Server Error, en texto plano: ni el KeyError, ni el archivo, ni la línea.

  6. Paso 6
    # segunda terminal: Ctrl+C y vuelve a arrancar con debug
    DEBUG=1 uvicorn errores:app --port 8080
    # primera terminal
    curl -s -X POST "localhost:8080/v1/classify?modo=bug" | tail -4

    Qué deberías ver: Ahora la respuesta es el traceback completo, en texto porque curl no pide HTML: rutas de tus archivos, la línea return {}["logits"] y KeyError: 'logits' al final. Eso le regala debug=True a cualquiera que llame.

  7. Paso 7
    # segunda terminal: Ctrl+C
    deactivate
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si curl dice Failed to connect, el servidor de la segunda terminal no está corriendo o activaste otro venv. Si lento da 500 en vez de 504, corriste un Python anterior a 3.11, donde except TimeoutError no atrapa el de asyncio: usa python3.13.

Verificado contra la documentación; no ejecutado aquí. Sin instalar nada: errores.py se corrió en macOS con FastAPI 0.143.0, Starlette 1.7.0 y uvicorn 0.54.0 (Python 3.13.16), en otro puerto, y dio estas salidas: lento respondió en 0.154 s; con DEBUG=1, tail -4 termina en KeyError: 'logits', y con Accept: text/html el traceback sale en HTML. Para la versión del C01 se verificó en el código: el http_exception_handler de FastAPI responde {"detail": …} con los headers del HTTPException; Starlette pasa los nombres de header a minúsculas y su ServerErrorMiddleware responde Internal Server Error en texto plano con debug=False, o el traceback con debug=True, y relanza la excepción para que uvicorn la loguee como Exception in ASGI application.

8

Health, readiness y apagado ordenado

¿Cómo sabe Kubernetes que tu gateway está vivo y listo para recibir tráfico, y cómo se apaga sin cortar requests a la mitad?

Sintaxis con variables

@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

Liveness (¿lo reinicio?): responde mientras el proceso y su event loop (el único hilo que atiende las corrutinas por turnos) respondan. Sin dependencias: si Triton cae, reiniciar el gateway no arregla nada. Si el loop se bloquea, tampoco responde a tiempo, y la probe (la pregunta periódica del kubelet, el agente de Kubernetes en cada nodo) lo nota.

@app.get("/readyz")
async def readyz(request: Request):
    try:
        listo = await asyncio.wait_for(chequeo, timeout=tope_s)
    except (errores):
        listo = False
    if not listo:
        raise HTTPException(503, "mensaje")
    return {"status": "ready"}

Readiness (¿le mando tráfico?): 200 solo si puedes atender de verdad. Si falla, el pod sale del Service (la lista de pods que reciben tráfico) y del ALB (el balanceador de AWS), sin reiniciarse. El tope va debajo del timeoutSeconds de la probe, para que siempre llegue una respuesta.

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.recurso = crear_recurso
    calentar
    yield  # aquí atiende requests
    await app.state.recurso.close()

En el lifespan (el bloque que corre al arrancar y al apagar), lo que va antes del yield corre antes de abrir el puerto: mientras tanto, las probes ven connection refused. El warmup (una pasada en vacío para que el primer request real no pague la inicialización) va ahí. Lo de después corre al apagar, cuando uvicorn ya terminó (o canceló) los requests en curso.

# Dockerfile
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--timeout-graceful-shutdown", "drenado_s"]
# Deployment
terminationGracePeriodSeconds: gracia_s
lifecycle:
  preStop:
    exec:
      command: ["sleep", "prestop_s"]

El orden del apagado: preStop (un paso antes de apagar: el pod sigue atendiendo mientras el ALB lo saca), SIGTERM (la señal de «termina») a uvicorn, drenado (terminar los requests en curso sin aceptar nuevos) de hasta --timeout-graceful-shutdown segundos, el shutdown del lifespan y la salida. Si al vencer la gracia sigue vivo, SIGKILL (muerte inmediata).

Variables

VariableQué esEjemplo
chequeola pregunta a la dependenciarequest.app.state.triton.is_model_ready(MODEL_NAME)
tope_stope de esa pregunta, menor que el timeoutSeconds de la probe0.5 (la probe espera 2 s)
erroresfallas que cuentan como no listoTimeoutError, aiohttp.ClientError, InferenceServerException
mensajedetail del 503el modelo no está listo en Triton
recurso, crear_recursolo que compartes entre requests y cómo se creatriton · triton_http.InferenceServerClient(url=TRITON_URL, conn_limit=64, conn_timeout=1.0)
calentaruna pasada en vacío antes de aceptar tráfico (opcional)C01 no calienta nada en el gateway: el warmup del modelo lo hace Triton (model_warmup)
drenado_scuánto espera uvicorn los requests en curso20
gracia_splazo total del apagado, preStop incluido45
prestop_ssegundos que el preStop retrasa el SIGTERM15
tgracia, tpreStop, tdrenado, margent_{\text{gracia}},\ t_{\text{preStop}},\ t_{\text{drenado}},\ \text{margen}terminationGracePeriodSeconds, el preStop, el drenado máximo de uvicorn y el colchón45, 15, 20 y 10 s
tappt_{\text{app}}lo que le queda a la app después del preStop30 s
f, p, df,\ p,\ dfailureThreshold, periodSeconds e initialDelaySeconds de una probereadiness: 2 y 5 s; liveness: 3, 10 s y 5 s
tdetectart_{\text{detectar}}cuánto tarda en notarse una falla≈ 10 s para salir del tráfico; ≈ 30 s para un reinicio
tarranquet_{\text{arranque}}lo que tarda el lifespan en abrir el puertoen C01, solo leer labels.json y crear el cliente

Fórmulas

tgracia≥tpreStop+tdrenado+margen45=15+20+10t_{\text{gracia}} \ge t_{\text{preStop}} + t_{\text{drenado}} + \text{margen} \qquad 45 = 15 + 20 + 10
tapp=tgracia−tpreStop=45−15=30 s  ≥  20 s de drenadot_{\text{app}} = t_{\text{gracia}} - t_{\text{preStop}} = 45 - 15 = 30\ \text{s} \;\ge\; 20\ \text{s de drenado}
tdetectar≈f×preadiness: 2×5=10 sliveness: 3×10=30 st_{\text{detectar}} \approx f \times p \qquad \text{readiness: } 2 \times 5 = 10\ \text{s} \qquad \text{liveness: } 3 \times 10 = 30\ \text{s}
tarranque<d+(f−1)×p=5+2×10=25 s(si no cabe: startupProbe)t_{\text{arranque}} < d + (f - 1) \times p = 5 + 2 \times 10 = 25\ \text{s} \qquad (\text{si no cabe: startupProbe})

Las piezas

PiezaQué haceEjemplo
Liveness: /healthz¿El proceso responde? Si falla 3 veces seguidas, el kubelet (el agente de Kubernetes en cada nodo) reinicia el contenedor. Corre en el event loop: si el loop está bloqueado, también falla.cada 10 s, timeout 2 s, 3 fallos
Readiness: /readyz¿Le mando tráfico? Si falla, el pod sale del Service y del ALB, sin reiniciarse.cada 5 s, timeout 2 s, 2 fallos
Health check del ALBEl ALB pregunta la misma ruta y solo le manda tráfico a los pods que dan 200. Si todos fallan y siguen registrados, AWS se lo manda igual a todos (fail open); en C01 no pasa, porque los pods NotReady salen de su lista de destinos (target group).healthcheck-path: /readyz
Dependencias en la readiness, nunca en la livenessSi Triton cae, todos los gateways quedan NotReady (fuera del tráfico) y el ALB responde 503, pero nadie se reinicia en cascada.test_healthz_does_not_depend_on_triton
Lifespan al arrancarLo que va antes del yield corre antes de abrir el puerto: las probes ven connection refused hasta que termina.C01: leer labels.json, el semáforo y el cliente de Triton
WarmupUna pasada en vacío para que el primer request real no pague la inicialización. En C01 la hace Triton (model_warmup) antes de marcar el modelo como listo.is_model_ready() da True después del warmup
startupProbeUna probe que frena a las otras dos hasta que el arranque termina. Hace falta si el arranque tarda más de lo que la liveness tolera.el gateway de C01 no la tiene: su lifespan no espera a nadie
preStopUn paso antes del SIGTERM: el gateway sigue atendiendo mientras el ALB lo saca de sus destinos (targets).sleep 15
SIGTERM a uvicornDeja de aceptar conexiones, cierra las ociosas y espera las que tienen un request en curso.Shutting down
--timeout-graceful-shutdownCuánto espera uvicorn a que terminen los requests en curso. Al vencer, los cancela (cada uno recibe un 500) y corre igual el shutdown del lifespan. Sin el flag, espera sin límite.20 s en el Dockerfile de C01
terminationGracePeriodSecondsPlazo total del apagado, preStop incluido. Al vencer, SIGKILL (exit 137).45 s en k8s/gateway.yaml
deregistration_delay del ALBCuánto espera el ALB a que terminen los requests de un target que está sacando.30 s (el default de AWS es 300 s)
Lifespan al apagarLo que va después del yield: cerrar clientes y pools. Corre después de drenar.await app.state.triton.close()
los plazospreStop: sigue atendiendodrenado máximo de uvicornmargenplazo: terminationGracePeriodSeconds = 45 skubelet149ALB y endpoints2uvicorn3568lifespan701020304050 splazospreStopdrenadoplazo de gracia: 45 skubelet149ALB yendpoints2uvicorn3568lifespan701020304050 s
  1. 10 skubelet · pod borrado: Terminating; corre el preStop (sleep 15)
  2. 20 sALB y endpoints · el endpoint pasa a no listo; el controller saca al pod del ALB, que espera hasta 30 s a lo que va en curso
  3. 30 suvicorn · sigue atendiendo: todavía no recibió nada
  4. 415 skubelet · SIGTERM al PID 1 (uvicorn)
  5. 515 suvicorn · Shutting down: cierra el puerto y las conexiones ociosas; las que están en curso terminan
  6. 615.25 suvicorn · en C01 el drenado suele durar < 1 s: casi todo request termina antes de su deadline de 250 ms
  7. 715.3 slifespan · cierra el cliente de Triton; Application shutdown complete; sale con 0
  8. 835 suvicorn · peor caso: vence --timeout-graceful-shutdown 20 y cancela lo que quede (cada uno, un 500)
  9. 945 skubelet · vence la gracia: SIGKILL (exit 137)

preStop: espera a que el ALB lo saquedrenado: termina lo que está en cursomargenSIGKILL

El apagado de un pod del gateway de C01, con t = 0 al borrar el pod. Arriba, los plazos a escala: 15 s de preStop, hasta 20 s de drenado y 10 s de margen dentro de los 45 s de gracia. Abajo, qué hace cada actor en cada momento.

El apagado de un pod del gateway de C01, segundo a segundo

tQué pasaQuién
0 sBorras el pod (o llega un rollout, el cambio a una versión nueva, o un scale-down, cuando sobran pods): queda Terminating, su endpoint pasa a no listo y empieza el preStop (sleep 15).kubelet y control plane (los componentes que manejan el cluster)
0–15 sEl AWS Load Balancer Controller saca la IP del pod del target group (la lista de destinos del ALB): el ALB deja de mandarle requests nuevos y espera los que van en curso (deregistration_delay de 30 s). uvicorn sigue atendiendo: todavía no recibió nada.controller, ALB y uvicorn
15 sSIGTERM a uvicorn, que es el PID 1 (el primer proceso del contenedor): Shutting down. Cierra el puerto y las conexiones ociosas; las que tienen un request en curso se cierran al responder (Waiting for connections to close).kubelet → uvicorn
15 s + < 1 s (casi siempre)Los requests en curso terminan: casi todos duran menos que el deadline de 250 ms, así que el drenado real suele durar menos de un segundo. Un upload lento o un decode enorme pueden tardar más (el deadline no los corta): para eso están los 20 s.uvicorn
enseguidaWaiting for application shutdown → el lifespan cierra el cliente de Triton → Application shutdown complete → Finished server process → sale con código 0.uvicorn y lifespan
35 sPeor caso: vence --timeout-graceful-shutdown 20. uvicorn cancela lo que quede (Cancel N running task(s), timeout graceful shutdown exceeded): cada request cancelado recibe un 500. Después corre igual el lifespan.uvicorn
45 sVence terminationGracePeriodSeconds: SIGKILL a lo que quede (exit 137).kubelet

Igual que en las hojas de Docker y de Kubernetes: 15 s de preStop + 20 s de drenado + 10 s de margen = 45 s. Después del preStop le quedan 30 s a la app.

Ejemplo real · main.py y gateway.yaml: las dos rutas de salud y las probes que las llaman

Pythonreference/c01/gateway/app/main.py (líneas 342–363) y reference/c01/k8s/gateway.yaml (líneas 29–30 y 81–102, sangría reducida; # … marca líneas omitidas)
@app.get("/healthz")
async def healthz():
    """Liveness: ¿el proceso responde? SIN dependencias: si Triton cae, reiniciar el gateway no ayuda."""
    return {"status": "ok"}


@app.get("/readyz")
async def readyz(request: Request):
    """Readiness: ¿puedo recibir tráfico? Sí solo si el modelo está listo en Triton.

    Si Triton cae, TODOS los gateways quedan NotReady, el ALB se queda sin targets y responde 503: aceptable,
    porque sin Triton el gateway no sirve de nada. Lo que nunca debes hacer es poner esta dependencia
    en liveness: el kubelet reiniciaría en cascada pods sanos.
    """
    try:
        # 0.5 s máximo: el probe del kubelet tiene su propio timeout y no queremos colgarlo
        ready = await asyncio.wait_for(request.app.state.triton.is_model_ready(MODEL_NAME), timeout=0.5)
    except (TimeoutError, aiohttp.ClientError, InferenceServerException):
        ready = False  # cualquier falla hablando con Triton = no listo
    if not ready:
        raise HTTPException(503, "el modelo no está listo en Triton")
    return {"status": "ready", "model": MODEL_NAME, "model_version": MODEL_VERSION}
# … reference/c01/k8s/gateway.yaml (líneas 29–30 y 81–102, sangría reducida):
# preStop (15 s) + --timeout-graceful-shutdown de uvicorn (20 s) + margen
terminationGracePeriodSeconds: 45
# …
# readiness: ¿mandarle tráfico? /readyz = modelo listo en Triton (ver main.py)
readinessProbe:
  httpGet:
    path: /readyz
    port: http
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 2
# liveness: ¿reiniciar? /healthz NO depende de Triton: si Triton cae, reiniciar gateways no ayuda
livenessProbe:
  httpGet:
    path: /healthz
    port: http
  initialDelaySeconds: 5
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3
lifecycle:
  preStop:
    exec:
      # El ALB tarda en desregistrar el target: seguimos atendiendo 15 s antes del SIGTERM
      command: ["sleep", "15"]
  • /healthz no toca a Triton: responde mientras el proceso y su event loop respondan. Si Triton cae, ningún gateway se reinicia (lo prueba test_healthz_does_not_depend_on_triton). Si el loop se bloquea, /healthz tampoco responde a tiempo y la liveness lo nota en ≈ 3 × 10 s.
  • /readyz pregunta is_model_ready(), que da True solo si /v2/models/classifier/ready responde 200; Triton marca el modelo listo después de su model_warmup. Cualquier falla (timeout, red, error de Triton) cuenta como no listo → 503: el pod sale del Service y del ALB, que usa la misma ruta como health check.
  • El tope de 0.5 s queda debajo del timeoutSeconds: 2 de la probe: siempre llega una respuesta, aunque sea un 503, y no se cuenta como timeout.
  • Las probes de abajo: readiness cada 5 s con 2 fallos (sale del tráfico en ≈ 10 s); liveness cada 10 s, con 3 fallos y 5 s de espera inicial. No hay startupProbe: el lifespan solo lee labels.json y crea el cliente, sin hablar con Triton, así que el puerto abre enseguida aunque Triton esté caído.
  • Al apagar: preStop sleep 15, SIGTERM, hasta 20 s de drenado (--timeout-graceful-shutdown 20 en el Dockerfile), el lifespan cierra el cliente de Triton y el proceso sale. 15 + 20 + 10 de margen = los 45 s de terminationGracePeriodSeconds. Versiones: uvicorn 0.54.0 en C01, que es también la actual.

Cómo recordarla

liveness = ¿lo reinicio?, readiness = ¿le mando tráfico? Las dependencias van en la readiness, nunca en la liveness. Al apagar: preStop (15 s) → SIGTERM → drenado (≤ 20 s) → el lifespan cierra → sale, todo dentro de los 45 s de gracia. Como cerrar un restaurante: apagas el letrero y sigues atendiendo mientras deja de llegar gente (preStop), atiendes a quien ya está sentado (drenado) y al final apagas la cocina (lifespan).

Trampa

poner a Triton en /healthz. Si Triton cae, el kubelet reinicia todos los gateways en cascada y ninguno arregla nada: la dependencia va en /readyz. Otras dos: un lifespan que tarda más de lo que la liveness tolera (≈ 25 s en C01) muere en bucle antes de abrir el puerto, y eso se arregla con una startupProbe, no con un initialDelaySeconds enorme; y un drenado más largo que lo que queda de gracia (--timeout-graceful-shutdown mayor que gracia − preStop) termina en SIGKILL con requests a la mitad.

Laboratorio (opcional)6 pasos

Qué vas a provocar: Ver con uvicorn de verdad que el lifespan corre antes de abrir el puerto, que liveness y readiness responden cosas distintas, y cómo drena (o corta) un request en curso al recibir SIGTERM.

Necesitas: Python 3.13 e internet para pip (fastapi y uvicorn). Dos terminales con zsh; curl y pkill vienen con macOS. Ni Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    [ -d .venv ] || python3.13 -m venv .venv
    . .venv/bin/activate
    pip install "fastapi==0.141.1" "uvicorn==0.54.0"
    cat > salud.py <<'EOF'
    # salud.py: lifespan con warmup, liveness, readiness y un request largo para ver el apagado
    import asyncio, os
    from contextlib import asynccontextmanager
    from fastapi import FastAPI, HTTPException
    
    DEP = os.path.expanduser("~/lab-gateway/dependencia_lista")  # hace de «el modelo está listo en Triton»
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        print("lifespan: creo los clientes y caliento", flush=True)
        await asyncio.sleep(float(os.getenv("WARMUP_S", "10")))  # carga y warmup simulados
        print("lifespan: listo", flush=True)
        yield
        print("lifespan: cierro el cliente del upstream", flush=True)
    
    app = FastAPI(lifespan=lifespan)
    
    @app.get("/healthz")
    async def healthz():
        return {"status": "ok"}  # liveness: sin dependencias
    
    @app.get("/readyz")
    async def readyz():
        if not os.path.exists(DEP):  # readiness: depende de la dependencia
            raise HTTPException(503, "la dependencia no está lista")
        return {"status": "ready"}
    
    @app.get("/lento")
    async def lento(s: float = 2.0):
        await asyncio.sleep(s)  # un request que tarda s segundos
        return {"durmio_s": s}
    EOF

    Qué deberías ver: Successfully installed, con fastapi-0.141.1 y uvicorn-0.54.0 en la lista (o Requirement already satisfied si ya estaban).

  2. Paso 2
    # segunda terminal: el servidor
    cd ~/lab-gateway && . .venv/bin/activate
    uvicorn salud:app --port 8080 --timeout-graceful-shutdown 5
    # primera terminal, enseguida
    for i in $(seq 1 14); do curl -s -o /dev/null -w "%{http_code} " localhost:8080/healthz; sleep 1; done; echo

    Qué deberías ver: Primero varios 000 (connection refused: todavía nadie escucha en el puerto) y después 200. En la segunda terminal: Waiting for application startup., lifespan: creo los clientes y caliento, 10 s después lifespan: listo, Application startup complete. y recién entonces Uvicorn running on http://127.0.0.1:8080. Es lo que ve una probe durante el arranque.

  3. Paso 3
    curl -s -w " %{http_code}\n" localhost:8080/readyz
    curl -s -w " %{http_code}\n" localhost:8080/healthz
    touch ~/lab-gateway/dependencia_lista
    curl -s -w " %{http_code}\n" localhost:8080/readyz

    Qué deberías ver: {"detail":"la dependencia no está lista"} 503, después {"status":"ok"} 200 y, con el archivo creado, {"status":"ready"} 200. Sin la dependencia, el proceso sigue vivo pero no recibiría tráfico: es lo que pasa con todos los gateways de C01 cuando Triton cae.

  4. Paso 4
    curl -s -w " %{http_code}\n" "localhost:8080/lento?s=2" & sleep 0.5; pkill -TERM -f "uvicorn salud:app"; wait
    # en la segunda terminal, cuando uvicorn termine
    echo $?

    Qué deberías ver: ≈ 2 s después llega {"durmio_s":2.0} 200: el request en curso terminó aunque el SIGTERM llegó en medio. En la segunda terminal: Shutting down, Waiting for connections to close. (CTRL+C to force quit), la línea del GET /lento?s=2 con 200 OK, Waiting for application shutdown., lifespan: cierro el cliente del upstream, Application shutdown complete. y Finished server process [PID]. Luego zsh: terminated y echo $? da 143: uvicorn relanza el SIGTERM al terminar. Como PID 1 de un contenedor, ese relanzamiento se ignora y sale con 0 (hoja de Docker).

  5. Paso 5
    # segunda terminal: sin warmup y con solo 3 s de drenado
    WARMUP_S=0 uvicorn salud:app --port 8080 --timeout-graceful-shutdown 3
    # primera terminal
    curl -s -w " %{http_code}\n" "localhost:8080/lento?s=10" & sleep 0.5; pkill -TERM -f "uvicorn salud:app"; wait

    Qué deberías ver: ≈ 3 s después del SIGTERM llega Internal Server Error 500. En la segunda terminal: ERROR: Cancel 1 running task(s), timeout graceful shutdown exceeded y un ERROR: Exception in ASGI application con un CancelledError, y la línea del GET /lento?s=10 con 500: uvicorn cancela el request, le manda un 500 y sigue con el apagado. En Kubernetes, lo que siga vivo al vencer la gracia muere con SIGKILL, sin siquiera ese 500.

  6. Paso 6
    deactivate
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Si /healthz da 000 todo el tiempo, el servidor no arrancó: mira la segunda terminal. Si pkill no hace nada, revisa que el comando de uvicorn diga salud:app. Si los 000 no alcanzan a verse, corre el bucle de curl antes de arrancar uvicorn.

Verificado contra la documentación; no ejecutado aquí. Sin instalar nada: salud.py se corrió en macOS con FastAPI 0.143.0, Starlette 1.7.0 y uvicorn 0.54.0 (Python 3.13.16), en otro puerto y con dependencia_lista en otra carpeta, y dio estas salidas: 11 veces 000 y después 200; 503, 200 y 200; {"durmio_s":2.0} 200 con el SIGTERM en medio, los mensajes en el orden de arriba y salida 143; con --timeout-graceful-shutdown 3, Internal Server Error 500 ≈ 3.2 s después del SIGTERM. En el código de uvicorn 0.54.0: el lifespan corre antes de abrir el socket; una tarea cancelada antes de empezar su respuesta recibe un 500 (send_500_response); y al salir relanza la señal capturada con signal.raise_signal.

9

Observabilidad: una línea JSON por request, request ID, métricas y trazas

Cuando un request sale mal o lento, ¿cómo lo encuentras, cómo sabes en qué tramo se fue el tiempo y qué no debes guardar nunca?

Sintaxis con variables

record = {"event": "evento", "request_id": request_id, "model_version": version}
status = 500
try:
    resultado = await trabajo
    status = 200
except HTTPException as exc:
    status = exc.status_code
    record["error"] = exc.detail
    raise
finally:
    record.update(status=status, latency_ms=round((time.perf_counter() - t0) * 1000, 1))
    log.info(json.dumps(record, ensure_ascii=False))

Una línea JSON por request, escrita en el finally: sale también en los errores. Campos con valores, nunca la imagen ni datos personales. A stdout: en un contenedor, el log es un stream (un flujo de líneas) que guarda el runtime del contenedor (el programa que lo corre: containerd en EKS).

request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
response.headers["X-Request-ID"] = request_id
await client.infer(modelo, [inp], outputs=[out], request_id=request_id)

Respeta el id que trae el caller (quien llama al gateway); si no trae, crea uno. Devuélvelo en la respuesta (también en los errores) y pásalo al siguiente salto: el mismo id queda en la línea de log, en la respuesta y en el request a Triton.

REQUESTS = Counter("prefijo_requests_total", "ayuda", ["status", "source", "model_version"])
LATENCY = Histogram("prefijo_request_latency_seconds", "ayuda", ["source"], buckets=bordes)
# en el finally de cada request:
REQUESTS.labels(status=str(status), source=source, model_version=MODEL_VERSION).inc()
LATENCY.labels(source=source).observe(elapsed)

Counter: un número que solo sube (cuántos requests, con qué código). Histogram: cuenta cuántas observaciones caen bajo cada borde (bucket: le="0.2" cuenta todas las de hasta 0.2 s) y guarda la suma y la cantidad. Pocos labels (etiquetas que parten la métrica) y con valores de una lista cerrada: nunca un id. Bordes de buckets alrededor del SLO (la meta de latencia).

histogram_quantile(0.99, sum by (le) (rate(prefijo_request_latency_seconds_bucket[5m])))
sum(rate(prefijo_requests_total{status=~"5.."}[5m])) / sum(rate(prefijo_requests_total{status!~"4.."}[5m]))

El p99 (el tiempo bajo el que queda el 99 % de los requests) se calcula en Prometheus: primero se suman los buckets de todos los pods (sum by (le)) y después se saca el cuantil. La tasa de 5xx se mide sobre los requests válidos: los 4xx no entran ni arriba ni abajo.

pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
OTEL_SERVICE_NAME=servicio \
OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=fraccion \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=collector \
OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="rutas" \
opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port puerto

Trazas (el recorrido de un request por los servicios) sin tocar el código: opentelemetry-bootstrap instala las instrumentaciones de lo que tienes (FastAPI, aiohttp) y opentelemetry-instrument las activa. Sale un span (un tramo con inicio, fin y atributos) por request, con otros chicos por cada envío y lectura (http send, http receive), y otro por la llamada a Triton; el contexto viaja en el header traceparent. Hace exec de uvicorn (se reemplaza por él), que sigue siendo el PID 1: el proceso que recibe el SIGTERM. C01 no lo trae: el curso propone muestrear ≈ 1 % (guardar 1 de cada 100 trazas nuevas). Desde FastAPI 0.142 (C01 fija 0.141.1), FastAPI ya crea sus spans y métricas; para mandarlos al Collector (el servicio que recibe las trazas) pide fastapi[standard] y FASTAPI_OTEL_AUTO_CONFIGURE=true.

Variables

VariableQué esEjemplo
evento, request_id, versioncampos fijos de la líneaclassify · el x-request-id o uuid4().hex · MODEL_VERSION
trabajola corrutina que hace el request_classify(request, top_k, record, deadline=…)
modelonombre del modelo en TritonMODEL_NAME = "classifier"
prefijo, ayudaprefijo de las métricas y su texto de ayudaclassifier · Latencia total de /v1/classify
bordesbordes de los buckets, varios cerca del SLO(0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5)
servicioservice.name de las trazasclassifier-gateway
fraccionfracción de trazas nuevas que se guardan0.01
collectorURL base del OpenTelemetry Collector (el servicio que recibe y reenvía las trazas)http://otel-collector:4318
rutasrutas que no se trazan (expresiones regulares separadas por comas)healthz,readyz,metrics
puertopuerto del gateway8080
q^, r, N\hat{q},\ r,\ Ncuantil estimado, posición buscada y total de observacioneslab: 212.8 ms con los buckets de C01
ℓ, u, cℓ, cu\ell,\ u,\ c_{\ell},\ c_{u}bordes inferior y superior del bucket donde cae r, y los conteos acumulados en esos bordes0.2 y 0.3 s si el p99 está entre 200 y 300 ms
b, vb,\ vbuckets finitos del histograma y combinaciones de labels; el + 4 son el bucket +Inf, _sum, _count y _created11 y 4 (los valores de source)
λ\lambdarequests por segundo500 en el pico de diseño

Fórmulas

q^=ℓ+(u−ℓ) r−cℓcu−cℓr=0.99×N\hat{q} = \ell + (u - \ell)\,\frac{r - c_{\ell}}{c_{u} - c_{\ell}} \qquad r = 0.99 \times N
series=(b+4)×v=(11+4)×4=60 por pod\text{series} = (b + 4) \times v = (11 + 4) \times 4 = 60\ \text{por pod}
10 M requests/dıˊa×≈300 B≈3 GB/dıˊa10\ \text{M requests/día} \times \approx 300\ \text{B} \approx 3\ \text{GB/día}
trazas nuevas/s=λ×0.01=500×0.01=5\text{trazas nuevas/s} = \lambda \times 0.01 = 500 \times 0.01 = 5

Las piezas

PiezaQué haceEjemplo
Línea JSON por requestUn evento por request con lo necesario para investigar. Una línea = un JSON: cualquier herramienta la lee sin un parser propio.≈ 300–350 bytes: request_id, preprocess_ms, inference_ms, status
stdoutEn un contenedor el log es un stream: el runtime lo guarda con la hora de cada línea y un agente (Fluent Bit) lo lleva a S3, el almacenamiento de AWS.PYTHONUNBUFFERED=1 en el Dockerfile: cada línea sale al instante
request_idEl id que une la línea de log, la respuesta y el request a Triton. Respeta el del caller; si no trae, uuid4.X-Request-ID. El ALB (el balanceador de AWS) no lo manda: agrega su propio X-Amzn-Trace-Id
CounterUn número que solo sube y vuelve a 0 si el proceso reinicia. Se consulta con rate(), que da cuánto sube por segundo.classifier_requests_total{status, source, model_version}
HistogramCuenta observaciones por bucket, más _sum y _count. El p99 se calcula en Prometheus sumando los buckets de todos los pods.classifier_request_latency_seconds
Buckets alrededor del SLOLos bordes deciden la precisión: con 0.15, 0.2 y 0.3 s, un p99 cerca de 200 ms sale bien; con los default, que saltan de 0.1 a 0.25 s, no.lab de esta sección
Labels acotados (cardinalidad)Cada combinación de valores de labels es una serie que Prometheus guarda en memoria (cuántas hay es la cardinalidad): solo valores de una lista cerrada.source: mobile, web, internal u other; nunca request_id
Desglose por tramoUn histograma por etapa dice dónde se fue el tiempo: CPU (preproceso) o Triton (red, cola y GPU).classifier_preprocess_seconds y classifier_inference_seconds
/metricsLa ruta que Prometheus lee cada 15 s. Con 1 proceso basta el registro por defecto; con --workers N hace falta el modo multiproceso (PROMETHEUS_MULTIPROC_DIR).generate_latest() con CONTENT_TYPE_LATEST
Traza y spanUna traza es el viaje de un request entre servicios; cada span es un tramo con inicio, fin y atributos.span del gateway → span de la llamada → spans de Triton
traceparent y muestreoEl header estándar traceparent (especificación W3C Trace Context) lleva el id de la traza al siguiente servicio. parentbased_traceidratio guarda una fracción de las trazas nuevas y respeta la decisión de quien llama.OTEL_TRACES_SAMPLER_ARG=0.01
ExemplarUn trace_id pegado a una observación del histograma: del p99 saltas a un request concreto. Solo sale en el formato OpenMetrics (la variante del formato de texto que sí los lleva; C01 expone el otro), y Prometheus lo guarda con --enable-feature=exemplar-storage.LATENCY.labels(source=source).observe(t, {"trace_id": …})
Qué no loguearNi la imagen, ni el EXIF (los metadatos de la foto, que pueden traer la ubicación), ni la IP, ni ids de usuario, ni tokens o el header Authorization.C01: solo los primeros 16 caracteres del sha256 de la imagen

POST/v1/classify?top_k=520017.9 msrequest_id 6f1d0c2b9a8e4f7d8c3b2a1908f7e6d5

1Una línea de log355 bytes

{"event": "classify", "request_id": "6f1d0c2b9a8e4f7d8c3b2a1908f7e6d5", "model_version": "2026-09-01-resnet50-fp16", "source": "mobile", "image_bytes": 183204, "preprocess_ms": 8.4, "image_sha256": "9c1e5a0b7d3f2e84", "inference_ms": 6.1, "top_k": [[311, 0.8312], [54, 0.0721], [57, 0.0394], [62, 0.0188], [60, 0.0093]], "status": 200, "latency_ms": 17.9}

2Métricas: 6 series suben

  • classifier_requests_total

    {model_version="2026-09-01-resnet50-fp16",source="mobile",status="200"}

    +1

  • classifier_request_latency_seconds_bucket

    {le="0.025",source="mobile"}

    +1 en este bucket y en todos los mayores (17.9 ms)

  • classifier_preprocess_seconds_bucket

    {le="0.01"}

    +1 (8.4 ms)

  • classifier_inference_seconds_bucket

    {le="0.01"}

    +1 (6.1 ms)

  • classifier_top1_score_bucket

    {le="0.9",model_version="2026-09-01-resnet50-fp16"}

    +1 (0.8312)

  • classifier_image_bytes_bucket

    {le="250000.0"}

    +1 (183,204 bytes)

3Trazas: un árbol de spans, a escala (ms)

  1. POST /v1/classifyserver17.9 ms

    instrumentación de FastAPI

  2. decode + preprocesointernal8.4 ms

    span manual (opcional)

  3. POSTclient6.1 ms

    instrumentación de aiohttp (la URL /v2/models/classifier/infer va como atributo); lleva traceparent

  4. cola + cómputo en Tritonserver4.9 ms

    Triton, si exporta trazas OpenTelemetry

Lo que une los tres: el mismo request_id en la línea de log, el header X-Request-ID y el request a Triton

el request enteroCPU del gatewayesperando a Tritondentro de Tritonidel mismo request_id

Un request de 17.9 ms y lo que deja: una línea de log, seis métricas que suben en 1 (un contador y cinco histogramas) y, si activas las trazas, un árbol de spans. Valores ilustrativos con la forma real de main.py; las trazas son la propuesta del curso.

Logs, métricas y trazas: qué pregunta responde cada una

SeñalRespondeCuestaEn C01
Línea de log por request¿Qué le pasó a este request?bytes por request: ≈ 3 GB por día a 10 M requestsel 100 % de los requests, sin datos personales, va a S3
Métricas¿Cuántos, qué tan rápido y cuántos errores, en total y por tramo?series de tiempo: no crecen con el tráfico, sí con los labelsPrometheus lee /metrics cada 15 s
Trazas¿En qué servicio y en qué tramo se fue el tiempo de este request?spans por request: por eso se muestreanpropuesta del curso: ≈ 1 %, con el mismo request_id; el código no la trae

Se unen por ids que viajan entre ellas, pero hay que ponerlos: el trace_id en la línea de log y en el exemplar del histograma, o el request_id como atributo del span. C01 hoy solo tiene el request_id, que une el log, la respuesta y el request a Triton. Las alertas salen de las métricas; la investigación, de los logs y las trazas.

Ejemplo real · main.py: las métricas, el request_id y la línea que se escribe siempre

Pythonreference/c01/gateway/app/main.py (líneas 63–72, 210–220 y 240–249; # … marca líneas omitidas)
# Nombres = contrato con monitoring/alerts.yml: si cambias uno aquí, rompes una alerta en silencio.
# Labels acotados: status (≈ 10 códigos) × source (4) × model_version (1–2 durante un canary)
REQUESTS = Counter(  # el cliente de Python agrega el sufijo _total; el nombre expuesto es este mismo
    "classifier_requests_total", "Requests a /v1/classify por código HTTP, caller y modelo",
    ["status", "source", "model_version"],  # status: "200", "503"… · source: mobile/web/internal/other
)
LATENCY = Histogram(  # latencia server-side; los buckets rodean al SLO (0.2 s) para que el p99 sea preciso
    "classifier_request_latency_seconds", "Latencia total de /v1/classify", ["source"],
    buckets=(0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5),
)
# …
    # Si el cliente (o un proxy) ya trae un id lo respetamos: así se traza el request de punta a punta
    request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
    record = {  # la línea de log que se escribe SIEMPRE, también en errores
        "event": "classify",
        "request_id": request_id,
        "model_version": MODEL_VERSION,
        # Autodeclarado: solo sirve para partir el monitoreo (mobile/web/interno), NO es identidad.
        # Cuotas y prioridad se basan en la identidad verificada en el borde (JWT de servicio o mTLS).
        "source": request.headers.get("x-client-source", "unknown"),
    }
    status = 500  # si algo inesperado explota, se cuenta como 500
# …
    finally:
        elapsed = time.perf_counter() - t0
        source = record["source"] if record["source"] in SOURCES else "other"  # cardinalidad acotada
        # Alimenta la alerta de 5xx y los paneles por caller y por modelo (canary vs estable)
        REQUESTS.labels(status=str(status), source=source, model_version=MODEL_VERSION).inc()
        if not 400 <= status < 500:  # los 4xx son rechazos rápidos: incluirlos "mejoraría" el p99 falsamente
            LATENCY.labels(source=source).observe(elapsed)
        record.update(status=status, latency_ms=round(elapsed * 1000, 1))
        # Sin imagen cruda ni PII (ni IP ni user id): solo lo necesario para monitorear y etiquetar
        log.info(json.dumps(record, ensure_ascii=False))
  • Los nombres de las métricas son un contrato con monitoring/alerts.yml (lo dice el comentario de la línea 63 de main.py): si cambias uno, una alerta se queda sin datos sin avisar. prometheus_client expone el contador como classifier_requests_total y agrega una serie _created (la hora en que nació cada serie) por combinación de labels (se apaga con PROMETHEUS_DISABLE_CREATED_SERIES=True).
  • El record se arma al entrar y se escribe en el finally: sale una línea por request, también en 4xx, 503 y 504. status = 500 por defecto: si explota algo inesperado, la línea y el contador dicen 500 aunque la excepción siga su camino.
  • source viene de un header que escribe el caller: sirve para partir los paneles, no como identidad. Antes de usarse como label se mapea a una lista cerrada (SOURCES; si no, other): un texto libre crearía una serie nueva por cada valor distinto.
  • La latencia no observa los 4xx: un rechazo de 1 ms bajaría el p99 sin que nadie esté mejor; los 503 y 504 sí entran. Con top_k = 5 la línea pesa unos 350 bytes (355 en el ejemplo de la figura; el curso redondea a ≈ 300) y no lleva la hora: la agrega el runtime del contenedor al guardarla (kubectl logs --timestamps la muestra).
  • El 422 de top_k no entra a esta función (FastAPI valida antes de llamar a classify): lo anota un exception handler, con su línea, su conteo y su X-Request-ID. Lo que no pasa por ninguna función no deja línea ni métrica: con --no-access-log, solo lo ve el ALB, en CloudWatch (las métricas de AWS): HTTPCode_Target_4XX_Count si lo respondió el gateway (un 405, por usar GET) y HTTPCode_ELB_4XX_Count si lo respondió el propio ALB, como el 404 de una ruta que el Ingress no enruta. Versiones: prometheus-client 0.26.0 en C01, que es también la actual; OpenTelemetry Python va en 1.45.1 (la librería base) y 0.66b1 (las instrumentaciones).

Cómo recordarla

logs para un request, métricas para todos, trazas para ver dónde se fue el tiempo, unidos por un id que pones en los tres. Una línea JSON por request, escrita en el finally; histogramas con buckets alrededor del SLO y labels de lista cerrada; el p99 se calcula sumando buckets, nunca promediando p99.

Trampa

promediar el p99 de cada pod. En el lab de esta sección, tres pods con p99 de 117, 114 y 263 ms promedian 164 ms («cumplimos 200 ms»), pero el p99 real del servicio es 206 ms: no cumple. Prometheus lo calcula bien sumando buckets (sum by (le)) y después el cuantil. Otras dos: poner request_id (o cualquier valor sin límite) como label, que crea una serie por request hasta que Prometheus se queda sin memoria; y loguear la imagen, el header Authorization o la IP «por si acaso».

Laboratorio (opcional)5 pasos

Qué vas a provocar: Ver el patrón de una línea JSON por request (también en los errores) y comprobar con números por qué el p99 se calcula sumando buckets y por qué importan sus bordes.

Necesitas: Python 3, sin paquetes extra (los comandos usan python3.13). Ni Docker ni GPU.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway
    cat > logs.py <<'EOF'
    # logs.py: una línea JSON por request con el patrón try/except/finally de C01 (simulación, solo stdlib)
    import json, time
    
    class HTTPError(Exception):  # hace el papel de HTTPException
        def __init__(self, status, detail):
            self.status, self.detail = status, detail
    
    def trabajo(modo):
        if modo == "saturado":
            raise HTTPError(503, "gateway saturado: reintenta más tarde")
        if modo == "lento":
            raise HTTPError(504, "timeout de inferencia")
        if modo == "bug":
            return {}["logits"]  # un bug: KeyError
        return {"predictions": []}
    
    def classify(modo, request_id):
        t0 = time.perf_counter()
        record = {"event": "classify", "request_id": request_id, "model_version": "2026-09-01-resnet50-fp16"}
        status = 500  # si algo inesperado explota, se cuenta como 500
        try:
            resultado = trabajo(modo)
            status = 200
            return resultado
        except HTTPError as exc:
            status = exc.status
            record["error"] = exc.detail
            raise
        finally:  # corre SIEMPRE: con éxito, con HTTPError y con el bug
            record.update(status=status, latency_ms=round((time.perf_counter() - t0) * 1000, 1))
            print(json.dumps(record, ensure_ascii=False), flush=True)
    
    for i, modo in enumerate(["ok", "ok", "saturado", "lento", "bug"]):
        try:
            classify(modo, f"req-{i}")
        except Exception as exc:
            pass  # en el gateway real, el cliente recibe el error (y el bug, un 500)
    EOF
    python3.13 logs.py

    Qué deberías ver: Cinco líneas JSON: req-0 y req-1 con status 200; req-2 con error gateway saturado: reintenta más tarde y status 503; req-3 con timeout de inferencia y 504; req-4 con status 500 y sin campo error. El finally escribió las cinco, también la del bug: ese 500 sale del valor por defecto status = 500.

  2. Paso 2
    python3.13 logs.py | python3.13 -c "import json, sys; [print(l, end='') for l in sys.stdin if json.loads(l)['status'] >= 500]"

    Qué deberías ver: Solo req-2, req-3 y req-4. Una línea = un JSON: filtrar por cualquier campo no necesita un parser propio; en producción lo hace tu herramienta de logs.

  3. Paso 3
    cat > p99.py <<'EOF'
    # p99.py: el p99 de 3 pods como lo calcula Prometheus (sumando buckets) y como NO se calcula (promediando p99)
    import math, random, sys
    
    C01 = [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.5, 1.0, 2.5]                  # buckets de main.py
    DEFAULT = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0]  # default de prometheus_client
    
    def pod(n, mediana_s, sigma):  # latencias al azar con cola larga (lognormal)
        return [random.lognormvariate(math.log(mediana_s), sigma) for _ in range(n)]
    
    def p99_exacto(xs):
        xs = sorted(xs)
        return xs[math.ceil(0.99 * len(xs)) - 1]
    
    def acumulado(xs, bordes):  # lo que exporta /metrics: _bucket{le="…"} cuenta las observaciones <= le
        return [sum(1 for x in xs if x <= b) for b in bordes] + [len(xs)]  # el último es le="+Inf"
    
    def histogram_quantile(q, bordes, acum):  # la regla de Prometheus: interpolación lineal dentro del bucket
        rango = q * acum[-1]
        for i, c in enumerate(acum):
            if c >= rango:
                if i == len(bordes):
                    return bordes[-1]  # cayó en +Inf: devuelve el último borde finito
                ini, antes = (bordes[i - 1], acum[i - 1]) if i else (0.0, 0)
                return ini + (bordes[i] - ini) * (rango - antes) / (c - antes)
    
    lento_ms = float(sys.argv[1]) if len(sys.argv) > 1 else 75
    random.seed(3)
    pods = [pod(10000, 0.040, 0.45), pod(10000, 0.040, 0.45), pod(10000, lento_ms / 1000, 0.55)]
    ms = lambda s: f"{s * 1000:6.1f} ms"
    print("p99 de cada pod:          ", ", ".join(ms(p99_exacto(p)).strip() for p in pods))
    print("promedio de esos p99:     ", ms(sum(p99_exacto(p) for p in pods) / 3), "  <- no significa nada")
    print("p99 exacto (30,000 reqs): ", ms(p99_exacto(sum(pods, []))))
    for nombre, bordes in (("de C01", C01), ("default", DEFAULT)):
        suma = [sum(col) for col in zip(*(acumulado(p, bordes) for p in pods))]  # sum by (le)
        print(f"histogram_quantile {nombre:8}", ms(histogram_quantile(0.99, bordes, suma)))
    EOF
    python3.13 p99.py 70

    Qué deberías ver: p99 de cada pod: 116.6, 113.5 y 245.7 ms; su promedio, 158.6 ms. El p99 exacto de los 30,000 requests es 192.3 ms. histogram_quantile con los buckets de C01: 196.8 ms, cerca y del lado correcto de 200. Con los default de prometheus_client: 238.9 ms, una falsa alarma.

  4. Paso 4
    python3.13 p99.py 75

    Qué deberías ver: Ahora el p99 exacto es 205.5 ms: el servicio no cumple 200 ms. Los buckets de C01 dicen 212.8 ms y lo detectan; el promedio de los p99 dice 164.4 ms y lo esconde; los default dicen 242.2 ms, casi lo mismo que antes: con un bucket que va de 100 a 250 ms no se distingue 192 de 206.

  5. Paso 5
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Los números son exactos con cualquier Python 3 (semilla fija). Prueba con 50, 60, 80 o 90: los buckets de C01 siguen al p99 exacto con 4 a 25 ms de error, mientras los default dan entre 219 y 249 ms aunque el exacto vaya de 144 a 246 ms.

Probado de verdad en un Mac. Ejecutado en macOS con Python 3.9.6, 3.13.15, 3.13.16 y 3.14.3, y repetido copiando los pasos tal cual en zsh: salidas idénticas. histogram_quantile sigue la regla documentada de Prometheus para histogramas clásicos (interpolación lineal dentro del bucket, 0 como límite inferior del primero, el último borde finito si cae en +Inf); los buckets default son los de prometheus_client (Histogram.DEFAULT_BUCKETS).

10

Tests: pytest, TestClient, el fake de Triton y las pruebas de carga

¿Cómo pruebas el gateway sin GPU ni Triton, incluidos los timeouts, los 503 y la carga, antes de que llegue a producción?

Sintaxis con variables

@pytest.fixture
def fixture(monkeypatch):
    monkeypatch.setattr(modulo, "nombre", doble)
    with TestClient(app) as client:
        yield client

Una fixture prepara lo que el test necesita; lo que va después del yield es la limpieza. monkeypatch cambia un atributo solo durante ese test y lo restaura al final. with TestClient(…) corre el lifespan (el arranque y el cierre de la app); sin with, no corre.

@pytest.mark.parametrize("param,esperado", [("valor", codigo), …])
def test_caso(fixture, param, esperado):
    doble.mode = param
    assert fixture.get("ruta").status_code == esperado

TestClient llama a la app dentro del mismo proceso, sin red ni servidor: el test es un def normal, sin await. parametrize corre el mismo test con varias entradas, y cada una cuenta como un test aparte.

@pytest.mark.anyio
async def test_caso():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        r = await ac.get("ruta")
    assert r.status_code == codigo

Un test async def, para cuando el test también tiene que esperar con await otras llamadas async. ASGITransport conecta el cliente directo con la app, sin red (ASGI es la interfaz entre uvicorn y tu app). El marcador anyio viene con AnyIO, la librería async que FastAPI ya instala. Ojo: AsyncClient no corre el lifespan (para eso existe LifespanManager, del paquete asgi-lifespan). Se importa de httpx, como en la doc de FastAPI, o de httpx2, la continuación de httpx que mantiene Pydantic, con la misma API.

pytest
pytest -k "patron" -x
pytest --durations=n
pytest --lf

-k elige los tests cuyo nombre contiene el patrón; -x para en la primera falla; --durations=N lista los N más lentos; --lf repite solo los que fallaron la última vez.

locust -f locustfile --headless -u usuarios -r rampa --run-time duracion --host url

Una prueba de carga (load test) manda muchos requests a la vez para medir el p99 (la latencia bajo la cual queda el 99 % de los requests) y encontrar dónde se satura el servicio. Locust simula usuarios escritos en Python; con wait_time = constant_throughput(1), cada usuario intenta 1 request por segundo, como máximo: si sus respuestas tardan más de 1 s, manda menos.

Variables

VariableQué esEjemplo
fixturenombre de la fixture; cada test la recibe como argumentoapi
modulodónde vive lo que reemplazas, tal como lo usa la appmain.triton_http
nombreel atributo que cambias solo durante el testInferenceServerClient
doblela clase falsa que toma su lugarFakeTriton
appla app ASGI real, la misma que carga uvicornmain.app
param, esperadolos argumentos que cambian en cada vueltamode, expected
valor, codigocada combinación de entrada y resultado("down", 503)
caso, rutalo que prueba (sale en el reporte) y el endpointreadyz_reflects_triton_model_readiness, /readyz
patronparte del nombre de los tests que quieres corrertriton
ncuántos tests lentos listar3
locustfile, usuarios, rampa, duracion, urlel archivo de Locust, usuarios simulados, usuarios nuevos por segundo, duración y destinolocustfile.py, 500, 20, 20m, https://classifier.internal (M03)
λ\lambdarequests por segundo que genera la prueba500, el pico de diseño de C01
uuusuarios simulados500
rrveces por segundo que cada usuario intenta su tarea (constant_throughput)1
WWlo que tarda cada respuesta; si pasa de 1/r, la tasa real cae≤ 0.3 s (el timeout del locustfile de M03)
ttestt_{\text{test}}lo que dura el test de Triton lento≈ 0.15 s (medido en el laboratorio)
tfaket_{\text{fake}}lo que duerme el fake en modo slow1 s
TtimeoutT_{\text{timeout}}el timeout del gateway hacia Triton0.15 s (INFER_TIMEOUT_S)

Fórmulas

λ=u⋅r500 reqs=500 usuarios×1 reqssi W<1r\lambda = u \cdot r \qquad 500\ \tfrac{\text{req}}{\text{s}} = 500\ \text{usuarios} \times 1\ \tfrac{\text{req}}{\text{s}} \quad \text{si } W < \tfrac{1}{r}
ttest≈min⁡(tfake, Ttimeout)=min⁡(1 s, 0.15 s)=0.15 st_{\text{test}} \approx \min\left(t_{\text{fake}},\ T_{\text{timeout}}\right) = \min\left(1\ \text{s},\ 0.15\ \text{s}\right) = 0.15\ \text{s}

Las piezas

PiezaQué haceEjemplo
pytestBusca funciones test_* en archivos test_*.py (o *_test.py) y las corre; si un assert falla, muestra los dos valores comparados.pytest==9.1.1 en requirements-dev.txt
pytest.iniLa configuración de pytest: dónde buscar tests y qué opciones usar siempre.pythonpath = . · testpaths = tests · addopts = -q
@pytest.fixturePrepara lo que el test necesita; lo que va después del yield es la limpieza.api: la app con el fake inyectado
monkeypatch.setattrCambia un atributo solo durante un test y lo restaura al terminar, aunque el test falle.MAX_INFLIGHT = 0 simula un pod saturado
@pytest.mark.parametrizeEl mismo test con varias entradas; cada combinación cuenta como un test.21 funciones, 26 tests en C01
TestClientLlama a la app dentro del proceso, sin red ni servidor. Con with corre el lifespan; sin with, no lo corre, y cada request usa un event loop nuevo (el bucle que atiende el código async).with TestClient(main.app) as client
httpx2El cliente HTTP que TestClient usa por debajo desde Starlette 1.2: la continuación de httpx que mantiene Pydantic, con la misma API. Con el httpx de siempre funciona, pero avisa que está deprecado; sin ninguno de los dos, falla.httpx2==2.13.1 en C01
AsyncClient + ASGITransportCliente async contra la app, sin red, para tests async def. No corre el lifespan.@pytest.mark.anyio
ASGI directo (scope, receive, send)Llamar a la app como lo hace uvicorn, con los mensajes escritos a mano. Sirve para lo que TestClient no puede simular: un cliente que corta a mitad del body (el mensaje http.disconnect).post_and_disconnect: el test del 499
un handler de logging propioJunta las líneas que escribe tu logger para revisarlas en el test. caplog no sirve si el logger no le pasa sus líneas al logger raíz (propagate = False), como el del C01.LogLines y la fixture log_lines
REGISTRY.get_sample_valueLee el valor de una serie de prometheus_client sin parsear /metrics. El registro es de todo el proceso: compara antes y después del request.classifier_inference_seconds_count sube 1 con el 504
fake (doble de prueba)Una clase falsa con los mismos métodos y las mismas excepciones que la real. Detecta errores de contrato que un mock genérico deja pasar.FakeTriton: ok, down, slow, reject y nan
MagicMock · AsyncMockObjetos falsos (mocks) de unittest.mock que aceptan cualquier llamada. Lo que devuelve un MagicMock no se puede esperar con await; lo que devuelven los métodos de un AsyncMock, sí.infer = AsyncMock(return_value=…)
app.dependency_overridesLa forma de FastAPI de cambiar una dependencia (Depends) durante los tests. C01 no usa Depends: cambia el cliente con monkeypatch.app.dependency_overrides[get_client] = fake
@pytest.mark.filterwarningsSilencia un warning esperado en ese test, para que no tape los demás.ignore::PIL.Image.DecompressionBombWarning
LocustPrueba de carga en Python: usuarios simulados contra el gateway real; reporta el p99 por endpoint.-u 500 -r 20 --run-time 20m
carga abierta o cerradaAbierta: los requests llegan a tasa fija, como los usuarios reales. Cerrada: cada usuario espera su respuesta; si el servidor se frena, la prueba también, y el p99 sale mejor que la realidad (coordinated omission).abierta: k6 con constant-arrival-rate; Locust es cerrada (con un timeout corto, casi abierta)
Qué simulaCómoCódigoTest (×N = casos de parametrize)
Camino feliz y contrato del tensorFakeTriton.mode = "ok"200test_classify_ok_respects_top_k_and_contract
PNG y WebP, top_k por defectoparametrize por formato200test_other_formats_and_default_top_k ×2
Request ID propagadoheader X-Request-ID: abc123200test_request_id_is_propagated
Tipo no permitidoimage/gif y application/octet-stream415test_unsupported_content_type_is_415
Más de 10 MBpor Content-Length y en 11 trozos de 1 MB413test_body_over_10mb_is_413_by_content_length · …_without_content_length
Decompression bombPNG de 1 bit de 48 MP y de 81 MP413test_decompression_bomb_is_413 ×2
Imagen rota, rara o vacíabasura, truncada, 1×4,000 px, body vacío400test_undecodable_image_is_400 ×2 · test_extreme_aspect_ratio_is_400 · test_empty_body_is_400
top_k fuera de rangotop_k=0 y top_k=500; revisa la línea y el conteo422test_top_k_out_of_range_is_422
Upload cortadoASGI directo: un trozo del body y http.disconnect499 (no cuenta como 5xx)test_client_disconnect_mid_upload_is_499_not_500
Triton caídomode = "down"503 + Retry-After: 1test_triton_down_is_503_with_retry_after
Triton rechazamode = "reject"503test_triton_error_is_503
Triton lentomode = "slow" (duerme 1 s); el timeout entra al histograma504test_slow_triton_is_504
Logits con NaNmode = "nan"500 (también en la línea y la métrica)test_non_finite_logits_are_500_in_the_log_and_the_metric_too
Pod saturadomonkeypatch MAX_INFLIGHT = 0503 + Retry-After: 1test_overload_is_503_with_retry_after
Presupuesto agotadomonkeypatch REQUEST_DEADLINE_S = 0.001504test_request_deadline_is_504
Liveness sin Tritonmode = "down"200test_healthz_does_not_depend_on_triton
Readinessok, down y reject200 · 503 · 503test_readyz_reflects_triton_model_readiness ×3
MétricasGET /metrics sin seguir redirects200test_metrics_exposes_contract_names
Los 26 tests del C01 y lo que simula cada uno. El fake y monkeypatch cubren cada código de error sin GPU; la carga se prueba aparte.

Ejemplo real · C01: el fake de Triton, la fixture y el test del 504

Pythonreference/c01/gateway/tests/test_api.py (líneas 38–65, 79–85 y 245–253; # … marca líneas omitidas)
class FakeTriton:
    """Doble de tritonclient.http.aio.InferenceServerClient con cinco modos de comportamiento."""

    mode = "ok"  # ok | down (sin red) | slow (no responde a tiempo) | reject (Triton devuelve error) | nan

    def __init__(self, url, conn_limit=100, conn_timeout=60.0, **kwargs):  # misma firma que el real
        self.url = url
        self.calls = []  # guardamos lo que el gateway envía para verificar el contrato del tensor

    async def infer(self, model_name, inputs, outputs=None, request_id="", **kwargs):
        self.calls.append({"model": model_name, "inputs": inputs, "outputs": outputs})
        if self.mode == "down":
            raise aiohttp.ClientConnectionError("connection refused")  # lo que se ve si el pod de Triton murió
        if self.mode == "slow":
            await asyncio.sleep(1.0)  # más que INFER_TIMEOUT_S (0.15 s): el gateway debe cortar antes
        if self.mode == "reject":
            raise InferenceServerException("Request for unknown model", status="400")
        logits = np.linspace(-2, 2, 500, dtype=np.float32)[np.newaxis]  # [1,500], el shape real
        logits[0, TOP_CLASS] = np.nan if self.mode == "nan" else 12.0  # top-1 claro, o un engine roto
        return FakeResult(logits)

    async def is_model_ready(self, model_name, model_version=""):
        if self.mode == "down":
            raise aiohttp.ClientConnectionError("connection refused")
        return self.mode != "reject"  # "reject" simula un modelo que no cargó

    async def close(self):
        pass  # el real cierra el pool de aiohttp

# …
@pytest.fixture
def api(monkeypatch):
    """App con el fake inyectado. `with TestClient` ejecuta el lifespan (crea y cierra el cliente)."""
    FakeTriton.mode = "ok"
    monkeypatch.setattr(main.triton_http, "InferenceServerClient", FakeTriton)
    with TestClient(main.app) as client:
        yield client

# …
def test_slow_triton_is_504(api, log_lines):
    FakeTriton.mode = "slow"
    observed = REGISTRY.get_sample_value("classifier_inference_seconds_count")
    r = post(api, image_bytes())
    assert r.status_code == 504  # cortamos a los 0.15 s: el cliente cae a su fallback manual
    assert "X-Request-ID" in r.headers  # los errores también son trazables
    # El timeout entra al histograma de inferencia y a la línea: es la llamada más lenta, no una que falta
    assert REGISTRY.get_sample_value("classifier_inference_seconds_count") == observed + 1
    assert log_lines[-1]["inference_ms"] >= 100  # ≈ 150 ms: lo que se esperó antes de cortar
  • monkeypatch reemplaza la clase InferenceServerClient, no un objeto: el lifespan del C01 sigue llamando triton_http.InferenceServerClient(url=TRITON_URL, conn_limit=64, conn_timeout=1.0) y recibe un FakeTriton. Por eso el fake copia la firma del real (conn_limit=100, conn_timeout=60.0 son los defaults de tritonclient.http.aio).
  • Cada modo imita una falla real con la misma excepción que lanzaría el cliente: down → aiohttp.ClientConnectionError (503), slow → no responde a tiempo (504), reject → InferenceServerException (503), y nan → logits con NaN, como un engine roto (500). Así se prueba el mapeo de errores a códigos sin GPU.
  • slow duerme con await asyncio.sleep(1.0), que cede el event loop (el único hilo que atiende todas las requests async por turnos): el asyncio.wait_for del gateway puede cortar a los 0.15 s. Con time.sleep(1.0) no podría: el request respondería 200 al segundo y el test fallaría (lo provocas en el laboratorio).
  • El test del 504 revisa también lo que queda escrito: REGISTRY.get_sample_value lee el conteo de classifier_inference_seconds (el timeout entra al histograma), y log_lines, una fixture con un handler de logging propio, junta las líneas JSON: el logger del C01 no propaga al raíz, así que caplog no las ve.
  • mode es un atributo de la clase, compartido por todos los tests: la fixture lo vuelve a "ok" antes de cada uno. Sin esa línea, el resultado dependería del orden de los tests.
  • self.calls guarda lo que el gateway mandó: el primer test revisa el nombre, la shape y el dtype del tensor (input, [1, 3, 224, 224], FP32), el contrato de config.pbtxt. Un MagicMock aceptaría cualquier cosa y no lo notaría.
  • El 499 no se prueba con TestClient, que siempre entrega el body completo. post_and_disconnect llama a la app como uvicorn (ASGI directo: scope, receive, send): manda un trozo del body y después http.disconnect, el mismo mensaje que llega cuando el cliente cierra la conexión.
  • Lo que estos tests no ven: la red, uvicorn, el ALB y la carga. Para eso están un smoke test (una prueba corta de punta a punta) en staging, un entorno igual a producción, y una prueba de carga con fotos reales (M03).

Cómo recordarla

un fake por dependencia, un test por código. El fake imita al cliente real (mismos métodos, mismas excepciones), monkeypatch lo mete, with TestClient arranca la app con su lifespan, y cada código de error (400, 413, 415, 422, 499, 500, 503, 504) tiene su test.

Trampa

TestClient(app) sin with. El lifespan no corre, app.state queda vacío y el primer request falla con AttributeError: 'State' object has no attribute 'inflight'. Además, cada request corre en un event loop nuevo: un cliente o un semáforo creado al importar el módulo puede terminar en Event loop is closed o en … is bound to a different event loop. La otra trampa: un fake que duerme con time.sleep. Bloquea el loop, el timeout no corta y el test del 504 falla, o pasa por el motivo equivocado.

Laboratorio (opcional)8 pasos

Qué vas a provocar: Escribir un fake de Triton y probar 200, 503 y 504 sin red; medir con --durations que el timeout corta a los 0.15 s, y romperlo a propósito con un time.sleep.

Necesitas: Python 3.11 o más nuevo (el de Homebrew) y conexión para el pip install de pytest; no necesitas FastAPI, Docker ni Triton.

  1. Paso 1
    mkdir -p ~/lab-gateway/tests && cd ~/lab-gateway && python3 -m venv .venv && source .venv/bin/activate && pip install -q pytest==9.1.1

    Qué deberías ver: Sin salida, o solo avisos de pip: un venv con pytest 9.1.1, el del C01. Necesitas Python 3.11 o más nuevo (desde 3.11, el timeout de asyncio lanza el TimeoutError que atrapa gw.py); si python3 es el 3.9 de las herramientas de Xcode, el pip falla: usa el de Homebrew. Los pasos siguientes van en ~/lab-gateway con el venv activo.

  2. Paso 2
    cat > pytest.ini <<'EOF'
    [pytest]
    pythonpath = .
    testpaths = tests
    addopts = -q
    EOF
    cat > gw.py <<'EOF'
    import asyncio
    
    INFER_TIMEOUT_S = 0.15  # 150 ms, como el C01
    
    
    async def classify(client):
        """Mini gateway: llama al "Triton" y traduce sus fallas a un código HTTP."""
        try:
            await asyncio.wait_for(client.infer(), timeout=INFER_TIMEOUT_S)  # se lee al llamar
        except TimeoutError:  # Triton vivo pero lento
            return 504
        except ConnectionError:  # Triton caído
            return 503
        return 200
    EOF

    Qué deberías ver: Sin salida. pytest.ini es el mismo del C01. gw.py es un gateway en miniatura que hace lo que _infer del C01: TimeoutError → 504, falla de conexión → 503.

  3. Paso 3
    cat > tests/test_gw.py <<'EOF'
    import asyncio
    import time
    
    import pytest
    
    import gw
    
    
    class FakeTriton:
        """Doble del cliente async: mismos métodos y mismas excepciones que el real."""
    
        def __init__(self, mode="ok", delay=1.0):
            self.mode, self.delay = mode, delay
    
        async def infer(self):
            if self.mode == "down":
                raise ConnectionRefusedError("connection refused")
            if self.mode == "slow":
                await asyncio.sleep(self.delay)
            return [0.1, 0.9]
    
    
    @pytest.mark.parametrize("mode,code", [("ok", 200), ("down", 503), ("slow", 504)])
    def test_codigo_por_modo(mode, code):
        assert asyncio.run(gw.classify(FakeTriton(mode))) == code
    
    
    def test_lento_corta_a_tiempo():
        t0 = time.perf_counter()
        assert asyncio.run(gw.classify(FakeTriton("slow"))) == 504
        assert time.perf_counter() - t0 < 0.3  # cortó a ≈ 0.15 s; no esperó 1 s
    
    
    def test_timeout_con_monkeypatch(monkeypatch):
        monkeypatch.setattr(gw, "INFER_TIMEOUT_S", 0.001)  # solo dura este test
        assert asyncio.run(gw.classify(FakeTriton("slow", delay=0.01))) == 504
    EOF

    Qué deberías ver: Sin salida. El fake tiene tres modos, como el del C01 (ok, down y slow); parametrize los convierte en 3 tests, y hay 2 más: uno mide el tiempo y otro baja el timeout con monkeypatch.

  4. Paso 4
    pytest

    Qué deberías ver: ..... [100%] y 5 passed in 0.32s (los tiempos cambian un poco). Ningún test usó red ni Triton.

  5. Paso 5
    pytest --durations=3

    Qué deberías ver: La sección slowest 3 durations muestra 0.15s call tests/test_gw.py::test_lento_corta_a_tiempo y 0.15s call tests/test_gw.py::test_codigo_por_modo[slow-504], más el aviso (1 durations < 0.005s hidden. Use -vv to show these durations.). El fake duerme 1 s, pero el timeout corta a los 0.15 s.

  6. Paso 6
    sed -i '' 's/await asyncio\.sleep(self\.delay)/time.sleep(self.delay)/' tests/test_gw.py && pytest --durations=3

    Qué deberías ver: ..FFF, tres AssertionError: assert 200 == 504, los dos lentos en 1.01s y al final 3 failed, 2 passed in 2.07s. Un time.sleep bloquea el event loop: el timeout no puede cortar y el “gateway” responde 200 un segundo después.

  7. Paso 7
    sed -i '' 's/time\.sleep(self\.delay)/await asyncio.sleep(self.delay)/' tests/test_gw.py && pytest --lf

    Qué deberías ver: ... [100%] y 3 passed in 0.32s: --lf repitió solo los tres que habían fallado, y ya pasan.

  8. Paso 8
    deactivate; cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida: se borra la carpeta, con el venv adentro.

Cómo arreglarlo y confirmarlo

Si el test del 504 tarda 1 s o recibe 200, busca código bloqueante (time.sleep, requests, un decode) dentro de un async def, en el fake o en la app. En C01: await para el I/O, run_in_threadpool para la CPU. Confírmalo con pytest --durations=3: el test lento debe tardar ≈ 0.15 s.

Probado de verdad en un Mac. Ejecutado con Python 3.14.3 y pytest 9.1.1 (el del venv del repo, en lugar del pip install del paso 1) en una carpeta equivalente a ~/lab-gateway; las salidas son las de esas corridas.

11

Rendimiento: workers de uvicorn, CPU para el decode, keep-alive, perfilar y medir el p99

¿Dónde se va el tiempo de tu gateway y qué perilla mueves: hilos, procesos, pods o keep-alive?

Sintaxis con variables

uvicorn modulo:app --host 0.0.0.0 --port puerto --workers n \
  --timeout-keep-alive seg_ka --no-access-log --timeout-graceful-shutdown seg_apagado

Así arranca el C01. --workers son procesos completos, cada uno con su event loop (el hilo que atiende los requests async por turnos), su memoria y sus métricas; en Kubernetes va 1 por pod y escalas con pods. --host 0.0.0.0 escucha en todas las interfaces de red: con el default, 127.0.0.1, nadie de afuera del contenedor llega. --timeout-keep-alive dice cuántos segundos queda abierta una conexión sin requests, lista para reusarse.

await asyncio.wait_for(slots.acquire(), timeout=espera)
try:
    resultado = await run_in_threadpool(funcion_cpu, datos)
finally:
    slots.release()

El trabajo de CPU (decode, resize) va a un threadpool (hilos aparte del event loop). El semáforo (un contador de turnos, creado en el lifespan) deja pasar N a la vez: acota CPU y RAM. Si no hay turno en espera, C01 responde 503.

py-spy top --pid pid
py-spy dump --pid pid
py-spy record -o archivo.svg --pid pid --duration seg

py-spy es un profiler por muestreo: mira la pila de llamadas (stack) del proceso muchas veces por segundo, sin tocar tu código. top muestra las funciones que más tiempo usan, en vivo; dump, dónde está cada hilo ahora mismo; record, un flame graph (un gráfico de dónde se fue el tiempo). En macOS pide sudo; en un contenedor, el permiso (capability) SYS_PTRACE, que deja leer la memoria de otro proceso.

PYTHONASYNCIODEBUG=1 uvicorn modulo:app --port puerto --loop asyncio

Modo debug de asyncio: avisa cada vez que algo ocupa el event loop más de 100 ms, con la corrutina y la línea donde quedó pausada después (Executing <Task …> took 0.182 seconds): lo que bloqueó está justo antes de esa línea. --loop asyncio usa el loop estándar de Python en vez de uvloop. Solo en local o en staging: el modo debug hace todo más lento.

curl -s -o /dev/null -w '%{http_code} %{time_connect} %{time_starttransfer} %{time_total}\n' \
  -H "Content-Type: image/jpeg" --data-binary @{foto} "url/v1/classify"

Mide un request desde afuera, en segundos: la conexión, el primer byte de la respuesta (incluye la subida del body y lo que tardó el servidor) y el total. Un request no da un p99: para eso, el histograma o una prueba de carga.

histogram_quantile(0.99, sum by (le) (rate(histograma_bucket[ventana])))

El p99 (la latencia bajo la cual queda el 99 % de los requests) de todo el servicio, en PromQL, el lenguaje de consultas de Prometheus: primero se suman los buckets (los conteos por rango de latencia) de todos los pods y después se calcula el cuantil. Nunca promedies el p99 de cada pod.

Variables

VariableQué esEjemplo
modulo:appel módulo y la variable de la appapp.main:app
puertopuerto donde escucha8080
nprocesos de uvicorn1 (más capacidad = más pods)
seg_kasegundos que una conexión ociosa queda abierta75 (default 5)
seg_apagadosegundos de drenado tras el SIGTERM20
slots, esperael semáforo del lifespan y cuánto esperar un turnoapp.state.cpu_slots (2 turnos), SLOT_WAIT_S = 0.05
funcion_cpu, datosla función bloqueante y su entrada_decode_and_hash, data
pidel proceso a mirar, por su número (PID)el PID de uvicorn (1 dentro del contenedor)
archivo, segdónde guardar el flame graph y cuánto muestrearperfil, 30
foto, urluna imagen de prueba y el gatewayfoto.jpg, http://localhost:8080
histograma, ventanael histograma de latencia y la ventana del rateclassifier_request_latency_seconds, 5m
λ\lambdarequests por segundo500 en el pico de diseño (≈ 100 por pod con 5 pods)
tCPUt_{\text{CPU}}CPU por request: decode, resize, normalizar≈ 6 ms en promedio
crequestc_{\text{request}}CPU que pide cada pod (requests.cpu)1
uHPAu_{\text{HPA}}utilización objetivo del HPA (el autoscaler de pods por CPU)0.6
ρloop\rho_{\text{loop}}fracción del tiempo en que el event loop está ocupado con trabajo bloqueante≈ 0.8 en el laboratorio, con el decode adentro
tˉbloqueo\bar{t}_{\text{bloqueo}}trabajo bloqueante promedio por request dentro del loop≈ 20 ms en el laboratorio (0.95 × 12 + 0.05 × 175)
tkeep-alive, tidlet_{\text{keep-alive}},\ t_{\text{idle}}cuánto espera cada lado (uvicorn y el ALB, el balanceador de AWS) antes de cerrar una conexión ociosa75 s en uvicorn, 60 s en el ALB

Fórmulas

cores=λ⋅tCPU=500 reqs×6 ms=3\text{cores} = \lambda \cdot t_{\text{CPU}} = 500\ \tfrac{\text{req}}{\text{s}} \times 6\ \text{ms} = 3
pods=⌈λ⋅tCPUcrequest⋅uHPA⌉=⌈31×0.6⌉=5\text{pods} = \left\lceil \dfrac{\lambda \cdot t_{\text{CPU}}}{c_{\text{request}} \cdot u_{\text{HPA}}} \right\rceil = \left\lceil \dfrac{3}{1 \times 0.6} \right\rceil = 5
ρloop=λ⋅tˉbloqueo=40 reqs×20 ms≈0.8\rho_{\text{loop}} = \lambda \cdot \bar{t}_{\text{bloqueo}} = 40\ \tfrac{\text{req}}{\text{s}} \times 20\ \text{ms} \approx 0.8
tkeep-aliveuvicorn=75 s  >  tidleALB=60 st_{\text{keep-alive}}^{\text{uvicorn}} = 75\ \text{s} \; > \; t_{\text{idle}}^{\text{ALB}} = 60\ \text{s}

Las piezas

PiezaQué haceEjemplo
--workers NN procesos con todo duplicado: memoria, conexiones, métricas. En Kubernetes, 1 por pod y más pods con el HPA.--workers 1 en C01
WEB_CONCURRENCYVariable de entorno que uvicorn usa como --workers si no pasas el flag.si una imagen base la define, tienes workers sin saberlo
--timeout-keep-aliveSegundos que una conexión ociosa queda abierta (default 5). Debe ser mayor que el idle timeout del balanceador.75 > 60 del ALB
--limit-concurrencyTope por proceso de conexiones abiertas o tareas; al pasarlo, 503 inmediato en texto plano y sin Retry-After. Cuenta también las conexiones keep-alive ociosas del ALB.C01 usa su propio MAX_INFLIGHT
--backlogConexiones que esperan a que el proceso las acepte (default 2048).el default
uvloop · httptoolsVersiones más rápidas del event loop y del parser HTTP, escritas en C y Cython; uvicorn las usa solo si están instaladas (--loop auto, --http auto).uvloop==0.22.1, httptools==0.8.0
threadpool de anyioLos hilos que comparten run_in_threadpool, los endpoints def y las dependencias def: 40 por proceso por defecto.C01 deja pasar 2 decodes con un semáforo
asyncio.to_threadCorre una función en otro pool: el executor por defecto de asyncio, de min(32, CPUs + 4) hilos.el laboratorio de abajo
GILEl candado de CPython: un solo hilo ejecuta bytecode de Python a la vez. Pillow y NumPy lo sueltan en el trabajo pesado; un bucle en Python puro, no.medido: decode 3.4–3.7× con 4 hilos; Python puro 1.0×
asyncio.SemaphoreContador de turnos: limita cuántos decodes corren a la vez, y con ellos la RAM (≈ 320 MB por PNG de 40 MP).PREPROCESS_CONCURRENCY=2
py-spyProfiler por muestreo: ve el stack del proceso sin modificarlo. top, dump y record.py-spy dump --pid 1
PYTHONASYNCIODEBUG=1Modo debug de asyncio: avisa de los pasos (callbacks o tareas) que ocupan el loop más de 100 ms.Executing <Task …> took 0.182 seconds
histograma de latenciaConteos por bucket que se suman entre pods; de ahí sale el p99. Buckets densos alrededor del SLO (la meta de latencia: p99 ≤ 200 ms en C01).0.15, 0.2 y 0.3 s en C01
CPU throttlingCon limits.cpu, el kernel frena al contenedor que gastó su cuota, en ventanas de 100 ms: picos de p99 con CPU promedio baja.C01 no pone límite de CPU

pod del gatewayCPU request 1memoria 1Gi

uvicorn (PID 1)--workers 1

event loop: 1 hilo

  • leer el body
  • await Triton
  • /healthz y /readyz
  • softmax y top-k
  • armar la respuesta

threadpool de anyio

asyncio.Semaphore · 2 slots · espera ≤ 50 ms

Si no hay slot a tiempo: 503 gateway saturado (CPU)

40 hilos, pero solo 2 decodes a la vez: los deja pasar el semáforo

  • decode
  • resize
  • normalizar
  • sha256

Más capacidad

más pods: HPA de 3 a 12, al 60 % de CPU

--workers N en el mismo pod

Laboratorio: 40 req/s, 1 de cada 20 fotos de 12 MP

Decode en el event loop

p5080 ms

p99216 ms

/healthz167 ms

Decode en un hilo

p5024 ms

p99189 ms

/healthz1 ms

A escala, de 0 a 216 ms.

lo hace el event loop: esperas de I/O y pasos cortosCPU pesada: va a un hilosin slot a tiempo: 503

Un pod del C01: un proceso uvicorn con un event loop y un threadpool. El loop atiende el I/O; el decode va a los hilos, de a 2. /metrics es un def: también corre en un hilo, sin semáforo. Para más capacidad, más pods, no más workers. Abajo, lo medido en el laboratorio.

Ejemplo real · C01: cómo arranca uvicorn en el contenedor, y por qué cada flag

Dockerfilereference/c01/gateway/Dockerfile (líneas 44–52)
# 1 proceso por contenedor (--workers 1): en k8s escalas con réplicas (HPA), cada pod con su
# request de CPU, su métrica de /metrics y su probe. Varios workers en un pod esconden fallas,
# duplican memoria y rompen prometheus_client sin modo multiproceso.
#   --timeout-keep-alive 75   mayor que el idle timeout del ALB (60 s): si el backend cierra primero,
#                             el ALB reusa una conexión muerta y el cliente ve 502 esporádicos
#   --no-access-log           el gateway ya escribe una línea JSON por request; el access log duplicaría
#   --timeout-graceful-shutdown 20  en SIGTERM termina los requests en curso (< terminationGracePeriodSeconds)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", \
     "--timeout-keep-alive", "75", "--no-access-log", "--timeout-graceful-shutdown", "20"]
  • --workers 1: un proceso por pod. El HPA (el autoscaler que agrega o quita pods según la CPU) ve la CPU real de ese proceso, /metrics describe a ese proceso y la probe habla con él. Con 4 workers en un pod, cada scrape de Prometheus cae en un worker distinto y los contadores suben y bajan (sin el modo multiproceso de prometheus_client).
  • ¿Y si un proceso no alcanza? Primero, que el event loop no haga CPU: el decode va al threadpool y Pillow suelta el GIL, así que un proceso usa más de un core. Después, más pods: a 500 req/s son ≈ 3 cores de decode, ≈ 5 pods de 1 CPU al 60 % (fórmula de arriba).
  • --timeout-keep-alive 75: una conexión keep-alive queda abierta entre requests para reusarla, y uvicorn la cierra si pasa 5 s ociosa (su default). Si el gateway la cierra justo cuando el ALB la reusa, el ALB responde 502. Con 75 > 60, siempre cierra primero el ALB (su idle_timeout.timeout_seconds=60 está en k8s/gateway.yaml).
  • --no-access-log: menos líneas y menos CPU; el registro de cada request es la línea JSON del gateway. El costo: lo que FastAPI responde antes de llegar a una función no deja línea. El C01 cubre el 422 con un exception handler; un 405 o un 404 no dejan rastro en el gateway.
  • --timeout-graceful-shutdown 20: tras el SIGTERM (la señal con que Kubernetes le pide al proceso que termine), uvicorn espera hasta 20 s los requests en curso y cancela lo que quede (esos terminan en 500). Cabe en los 45 s de gracia: 15 s de preStop (la espera antes del SIGTERM) + 20 s de drenado + 10 s de margen.
  • Lista JSON (forma exec): uvicorn es el PID 1 y le llega el SIGTERM directo. Con forma shell, el PID 1 sería sh y el apagado ordenado no ocurriría (detalle en la hoja de Docker).

Cómo recordarla

el loop para esperar, los hilos para calcular, los pods para crecer. Mide antes de mover algo (histograma por tramo, py-spy) y la perilla sale sola: el decode al threadpool, el keep-alive mayor que el del ALB, y más pods antes que más workers.

Trampa

subir --workers o los hilos para bajar un p99 alto. Si el event loop está bloqueado, más hilos no lo destraban; si el código es Python puro, el GIL no deja usar más cores; y si el p99 son las fotos de 12 MP, cada una tarda lo mismo en cualquier hilo. En el laboratorio, sacar el decode del loop baja el p50 de ≈ 80 a 24 ms, pero el p99 casi no se mueve: para ese, draft mode (el decoder JPEG entrega directo una versión reducida) y fotos más chicas.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Medir el p50, el p99 y la espera de un health check en un proceso con el decode dentro del event loop y fuera de él; ver el aviso del modo debug de asyncio y comprobar que los hilos solo ayudan si el código suelta el GIL.

Necesitas: Python 3.12 o más nuevo (el de Homebrew; lo pide NumPy 2.5) y conexión para el pip install de Pillow y NumPy. Las cifras cambian con tu máquina; la diferencia entre los dos modos, no.

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway && python3 -m venv .venv && source .venv/bin/activate && pip install -q pillow==12.3.0 numpy==2.5.3

    Qué deberías ver: Sin salida, o solo avisos de pip: Pillow y NumPy con las versiones del C01. NumPy 2.5 necesita Python 3.12 o más nuevo: con 3.11 o con el 3.9 de Xcode, el pip falla.

  2. Paso 2
    cat > carga.py <<'EOF'
    import asyncio, io, statistics, sys, time
    
    import numpy as np
    from PIL import Image
    
    MODO = sys.argv[1]  # bloqueante | hilo
    TASA, N = 40, 200  # 40 req/s durante 5 s, a tasa fija (carga abierta)
    
    
    def foto(w, h):  # JPEG con ruido: el decoder trabaja de verdad
        pix = np.random.default_rng(0).integers(0, 255, (h, w, 3), dtype=np.uint8)
        buf = io.BytesIO()
        Image.fromarray(pix).save(buf, "JPEG", quality=90)
        return buf.getvalue()
    
    
    CHICA, GRANDE = foto(1024, 768), foto(4000, 3000)  # celular y web de 12 MP
    
    
    def decode(data):  # CPU pura, como el preproceso del C01
        return Image.open(io.BytesIO(data)).convert("RGB").resize((256, 256))
    
    
    async def request(i, llegada, lat):
        data = GRANDE if i % 20 == 0 else CHICA  # 1 de cada 20: foto grande
        if MODO == "bloqueante":
            decode(data)  # dentro del event loop
        else:
            await asyncio.to_thread(decode, data)  # en un hilo aparte
        await asyncio.sleep(0.010)  # "Triton": 10 ms de I/O
        lat.append(time.perf_counter() - llegada)
    
    
    async def healthz(esperas, fin):  # cada 50 ms: ¿cuánto tarda el loop en atenderla?
        while not fin.is_set():
            t = time.perf_counter()
            await asyncio.sleep(0.05)
            esperas.append(time.perf_counter() - t - 0.05)
    
    
    async def main():
        lat, esperas, fin = [], [], asyncio.Event()
        sonda = asyncio.create_task(healthz(esperas, fin))
        inicio, tareas = time.perf_counter(), []
        for i in range(N):
            llegada = inicio + i / TASA  # la hora de llegada no depende del loop
            await asyncio.sleep(max(0, llegada - time.perf_counter()))
            tareas.append(asyncio.create_task(request(i, llegada, lat)))
        await asyncio.gather(*tareas)
        fin.set()
        await sonda
        q = statistics.quantiles(lat, n=100)
        print(f"{MODO}: p50 {q[49] * 1000:.0f} ms · p99 {q[98] * 1000:.0f} ms"
              f" · peor espera de /healthz {max(esperas) * 1000:.0f} ms")
    
    
    asyncio.run(main())
    EOF

    Qué deberías ver: Sin salida. carga.py simula un proceso del gateway: 40 requests por segundo durante 5 s, a tasa fija; cada uno decodifica una foto de celular (≈ 12 ms de CPU) o, 1 de cada 20, una de 12 MP (≈ 175 ms), y espera 10 ms de “Triton”. Una sonda cada 50 ms mide cuánto tarda el loop en atenderla, como un /healthz.

  3. Paso 3
    python carga.py bloqueante; python carga.py hilo

    Qué deberías ver: bloqueante: p50 ≈ 80 ms · p99 ≈ 220 ms · peor espera de /healthz ≈ 160 ms, y hilo: p50 ≈ 24 ms · p99 ≈ 190 ms · peor espera ≈ 1–3 ms. Con el decode en el loop, cada foto espera a las de adelante, y el health check también. El p99 casi no baja: son las fotos grandes, que tardan lo mismo en cualquier hilo.

  4. Paso 4
    PYTHONASYNCIODEBUG=1 python carga.py bloqueante 2>&1 | grep -m 3 Executing

    Qué deberías ver: Tres líneas Executing <Task pending name='Task-3' coro=<request() running at …/carga.py:30> … took 0.182 seconds (y Task-23 y Task-43: las otras fotos grandes). La línea 30 es el await que sigue al decode: el bloqueo está justo antes. Las fotos chicas (≈ 12 ms) no pasan el umbral de 100 ms y no aparecen, aunque también bloquean.

  5. Paso 5
    cat > gil.py <<'EOF'
    import io, time
    from concurrent.futures import ThreadPoolExecutor
    
    import numpy as np
    from PIL import Image
    
    pix = np.random.default_rng(0).integers(0, 255, (3000, 4000, 3), dtype=np.uint8)
    buf = io.BytesIO()
    Image.fromarray(pix).save(buf, "JPEG", quality=90)
    FOTO = buf.getvalue()  # una foto de 12 MP
    
    
    def decode():  # Pillow suelta el GIL mientras decodifica y redimensiona
        Image.open(io.BytesIO(FOTO)).convert("RGB").resize((341, 256))
    
    
    def python_puro():  # bytecode de Python: necesita el GIL todo el tiempo
        sum(i * i for i in range(2_000_000))
    
    
    for nombre, f in [("pillow", decode), ("python puro", python_puro)]:
        t = time.perf_counter()
        for _ in range(8):
            f()
        uno = time.perf_counter() - t
        with ThreadPoolExecutor(4) as pool:
            t = time.perf_counter()
            list(pool.map(lambda _: f(), range(8)))
            cuatro = time.perf_counter() - t
        print(f"{nombre}: 1 hilo {uno:.2f} s · 4 hilos {cuatro:.2f} s · {uno / cuatro:.1f}x")
    EOF
    python gil.py

    Qué deberías ver: pillow: 1 hilo 1.41 s · 4 hilos 0.38 s · 3.7x y python puro: 1 hilo 0.85 s · 4 hilos 0.82 s · 1.0x (al repetirlo, Pillow da entre 3.4x y 3.7x). Pillow suelta el GIL mientras decodifica, así que 4 hilos usan 4 cores; el bucle de Python puro no lo suelta y tarda lo mismo con 1 que con 4.

  6. Paso 6
    python -c 'print(round(40 * (0.95 * 0.012 + 0.05 * 0.175), 2))'

    Qué deberías ver: 0.81: con el decode adentro, el event loop está ocupado el 81 % del tiempo (tasa × trabajo bloqueante por request). Cerca de 1, la espera crece rápido: por eso el p50 pasó de 24 a ≈ 80 ms.

  7. Paso 7
    deactivate; cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida: se borra la carpeta, con el venv adentro.

Cómo arreglarlo y confirmarlo

El decode a run_in_threadpool (o asyncio.to_thread) con un semáforo, como el C01; para el p99 de las fotos grandes, draft mode y downscale en el cliente. Confírmalo con la misma medición: la espera de /healthz vuelve a ≈ 1 ms.

Probado de verdad en un Mac. Ejecutado con Python 3.14.3, Pillow 12.3.0 y NumPy 2.5.3 (del venv del repo, en lugar del pip install del paso 1) en una Mac con chip M de 10 cores; las cifras son de esas corridas.

12

Diagnóstico: el método y la tabla de síntomas

Tu gateway está lento, devuelve 503 o se traba: ¿qué miras primero, qué después, y cómo pasas del síntoma a la causa?

Sintaxis con variables

kubectl logs deploy/deploy -n ns --all-pods=true --since=ventana | grep -o '"status": [0-9]*' | sort | uniq -c
kubectl logs deploy/deploy -n ns --all-pods=true --since=ventana | grep -o '"error": "[^"]*"' | sort | uniq -c

Código y detalle: el número solo no basta. En C01 hay cuatro 503 distintos y dos 504 distintos, y el campo error de la línea JSON dice cuál es. --all-pods=true junta los logs de todos los pods del Deployment; cada línea llega con [pod/NOMBRE/gateway] delante.

histogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[5m])))
histogram_quantile(0.99, sum by (le) (rate(classifier_inference_seconds_bucket[5m])))

Tramo (cada parte del camino del request), en PromQL, las consultas de Prometheus: ¿creció el preproceso, que es CPU del gateway, o la llamada a Triton? Si es Triton, sus métricas separan la espera en cola del cómputo. Los timeouts también entran a classifier_inference_seconds (el C01 mide la llamada en un finally): un 504 cae entre 0.15 y 0.3 s, así que el p99 de inferencia sube justo cuando Triton está lento.

histogram_quantile(0.99, sum by (le, pod) (rate(classifier_request_latency_seconds_bucket[5m])))
kubectl rollout history deploy/deploy -n ns

Alcance: ¿todos los pods o uno? Uno solo apunta a su nodo (un vecino ruidoso: otro pod que le roba CPU); todos apuntan al código, a Triton o a la carga. Y ¿coincide con un deploy? Entonces el rollback (volver a la versión anterior) va primero.

kubectl logs deploy/deploy -n ns --all-pods=true --since=ventana | grep request_id
py-spy dump --pid pid

Un caso concreto: el X-Request-ID que devolvió la respuesta lleva a su línea de log, con preprocess_ms, inference_ms y el error. Y si el proceso parece trabado, py-spy dump muestra qué hace cada hilo en este momento.

Variables

VariableQué esEjemplo
deploy, nsel Deployment y su namespacegateway, classifier
ventanacuánto hacia atrás mirar10m
request_idel X-Request-ID de la respuestaun hex de 32 caracteres
pidel proceso de uvicorn1 dentro del contenedor
5xx, 4xx, total\text{5xx},\ \text{4xx},\ \text{total}requests por familia de código en classifier_requests_total; page = la alerta despierta a quien está de guardiala alerta ClassifierHigh5xxRate de C01
LLrequests en vuelo en un pod16 = MAX_INFLIGHT: el pod empieza a rechazar
λ\lambdarequests por segundo que recibe ese pod100
WWtiempo promedio de cada request, por la ley de Little (en vuelo = tasa × tiempo) despejada160 ms: algo se frenó
tredt_{\text{red}}serializar el tensor (602 KB) y la ida y vuelta por la red hasta el Triton de la zona2–4 ms
tcola, tcoˊmputot_{\text{cola}},\ t_{\text{cómputo}}lo que mide Triton: espera en su cola y trabajo en la GPU≤ 2 ms y 3–6 ms en el p50
tespera del loopt_{\text{espera del loop}}lo que tarda el event loop del gateway en volver a atender la respuesta≈ 0 si nada lo bloquea

Fórmulas

tasa5xx=5xxtotal−4xx>1 % durante 5 min  ⇒  page\text{tasa}_{\text{5xx}} = \dfrac{\text{5xx}}{\text{total} - \text{4xx}} > 1\,\% \text{ durante 5 min} \;\Rightarrow\; \text{page}
W=Lλ=16100 req/s=160 msW = \dfrac{L}{\lambda} = \dfrac{16}{100\ \text{req/s}} = 160\ \text{ms}
tgateway→Triton≈tred+tcola+tcoˊmputo+tespera del loopt_{\text{gateway}\to\text{Triton}} \approx t_{\text{red}} + t_{\text{cola}} + t_{\text{cómputo}} + t_{\text{espera del loop}}

Las piezas

PiezaQué haceEjemplo
503 gateway saturado: reintenta más tardeEl pod ya tenía 16 requests en vuelo (MAX_INFLIGHT) y rechazó sin leer el body.kubectl get hpa (el autoscaler de pods) y el p99
503 gateway saturado (CPU)Ningún turno de decode libre en 50 ms: CPU del pod saturada.classifier_preprocess_seconds y classifier_image_bytes
503 Triton no disponibleFalla de red hacia Triton: conexión rechazada o reseteada, o DNS.pods de Triton y sus endpoints
503 Triton rechazó la inferencia: …Triton respondió un error: modelo no cargado, o su cola lo rechazó (Request timeout expired).el texto después de los dos puntos
503 el modelo no está listo en TritonLo devuelve /readyz: el pod sale del ALB hasta que Triton esté listo.kubectl events --types=Warning
504 timeout de inferenciaTriton no respondió en min(150 ms, lo que quedaba del presupuesto).cola contra cómputo en Triton
504 se agotó el presupuesto del requestLos 250 ms se gastaron antes de llegar a Triton: body lento o decode lento.image_bytes y preprocess_ms de esos requests
413 · 415 · 400Rechazos del cliente: más de 10 MB o de 40 MP; tipo no permitido; imagen rota o vacía.el campo error de la línea JSON
405 sin línea de logFastAPI lo respondió antes de cualquier función (un GET a /v1/classify): no hay log ni métrica del gateway. El 422 sí deja línea en el C01, por su exception handler.HTTPCode_Target_4XX_Count del ALB (el balanceador de AWS)
500 sin campo errorUna excepción que no es HTTPException: un bug, o un cliente que se fue a mitad del upload.Exception in ASGI application en kubectl logs
502 Bad Gateway (HTML del ALB)El ALB no pudo usar la conexión: el pod la cerró con un request pendiente, por un keep-alive más corto que el del ALB o por apagarse sin drenar.--timeout-keep-alive y el apagado
503 Service Temporarily Unavailable (HTML del ALB)El ALB no tiene a quién mandar el request: todos los gateways quedaron NotReady (no listos), casi siempre porque Triton cayó.kubectl get endpointslices -n classifier
460 en los access logs del ALBEl cliente cerró la conexión antes de la respuesta (nginx lo anota como 499): su timeout es más corto que tu latencia.latency_ms > 300 en los logs

1Síntoma con números2Código + detalle3Tramo4Alcance5Un request6El proceso por dentro

  1. Síntoma con números

    ¿Qué código o qué percentil, cuánto y desde cuándo?

    Mira:
    • classifier_requests_total por status
    • el p99 del histograma
  2. Código + detalle

    ¿Cuál de los 503 o de los 504?

    Mira:
    • el campo error de la línea JSON
  3. Tramo

    ¿Preproceso (CPU) o Triton (red, cola, cómputo)?

    Mira:
    • classifier_preprocess_seconds contra classifier_inference_seconds
  4. Alcance

    ¿Todos los pods o uno? ¿Coincide con un deploy?

    Mira:
    • by (pod)
    • kubectl rollout history
  5. Un request

    ¿Qué le pasó a este request_id?

    Mira:
    • kubectl logs … | grep ID
  6. El proceso por dentro

    ¿Qué hace el event loop ahora mismo?

    Mira:
    • py-spy dump
    • PYTHONASYNCIODEBUG=1

Mitigar primero, entender después.

El método, de izquierda a derecha. Cada paso descarta una familia de causas; mitiga apenas sepas lo suficiente (rollback, escalar, sacar un pod) y entiende después.

Ejemplo real · C01: el runbook de ClassifierHighP99Latency, paso por paso

textoPasos 1 a 4 del runbook de ClassifierHighP99Latency (M12, runbooks) como consultas con los nombres de reference/c01 (gateway, monitoring/alerts.yml), más un paso 5 de alcance que agrega esta hoja; sin salidas
# Runbook de ClassifierHighP99Latency (p99 > 200 ms durante 10 min): mitigar primero, entender después
# 1) ¿Qué tramo subió? CPU del gateway o la llamada a Triton (PromQL)
histogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[5m])))
histogram_quantile(0.99, sum by (le) (rate(classifier_inference_seconds_bucket[5m])))
# Si es Triton: ¿espera en la cola o cómputo? (ms promedio por request)
sum(rate(nv_inference_queue_duration_us{model="classifier"}[5m]))
  / sum(rate(nv_inference_request_success{model="classifier"}[5m])) / 1000

# 2) ¿Hubo deploy? Si coincide, rollback
kubectl rollout history deploy/gateway -n classifier
kubectl rollout history deploy/triton -n classifier
kubectl rollout undo deploy/gateway -n classifier

# 3) ¿KEDA llegó a su máximo (4 Triton)? Falta GPU
kubectl get hpa -n classifier

# 4) ¿Un solo caller manda de más? Su cuota, no más pods
sum by (source) (rate(classifier_requests_total[5m]))

# 5) ¿Todos los pods o uno?
histogram_quantile(0.99, sum by (le, pod) (rate(classifier_request_latency_seconds_bucket[5m])))
  • El orden sale del runbook de C01 en M12 (la receta escrita de qué hacer ante una alerta): tramo, deploy, capacidad y caller, sus pasos 1 a 4. El paso 5 (¿todos los pods o uno?) lo agrega esta hoja. Cada paso descarta una familia de causas con un vistazo.
  • Los dos histogramas del gateway parten el problema en dos: classifier_preprocess_seconds es CPU del gateway; classifier_inference_seconds es red + cola + cómputo de Triton, más la espera del event loop si está bloqueado.
  • Si el p99 subió justo después de un deploy, rollout undo va antes que entender: es reversible y corta el impacto. La causa la buscas después, con calma.
  • sum by (source) separa a los callers (mobile, web, internal y other): si creció uno solo, la respuesta es su cuota, no más pods.
  • by (le, pod): si un solo pod tiene el p99 alto, sospecha de su nodo (un vecino ruidoso) antes que del código.
  • Lo que este runbook no ve: un 405 (FastAPI lo responde sin pasar por ninguna función) y lo que responde el ALB por su cuenta. Los 504 sí están: entran al histograma de inferencia con lo que esperaron. Para lo demás está la tabla de síntomas de arriba.

Cómo recordarla

código → detalle → tramo → alcance → request → proceso. El código dice qué familia; el campo error, cuál; los histogramas por tramo, dónde; by (pod) y el historial de deploys, a quién; el request_id, un caso concreto; py-spy, qué hace el proceso ahora.

Trampa

diagnosticar con un solo número, o con el promedio. “503” en C01 son cuatro causas distintas, y “504” son dos: Triton no contestó a tiempo (la línea trae inference_ms ≈ 150) o el presupuesto se agotó antes de llamarlo (sin inference_ms). Y no escales a ciegas: más pods no arreglan un event loop bloqueado ni un Triton lento.

Laboratorio (opcional)8 pasos

Qué vas a provocar: Encontrar dos fallas escondidas en 3,000 líneas de log con el formato del C01 (datos simulados): qué es cada una, de qué pod viene y en qué tramo está.

Necesitas: Una terminal de macOS con python3 (sirve hasta el 3.9 del sistema) y jq (en macOS reciente ya viene en /usr/bin/jq; si no, brew install jq).

  1. Paso 1
    mkdir -p ~/lab-gateway && cd ~/lab-gateway && cat > logs.py <<'EOF'
    """Logs SIMULADOS con el formato del gateway del C01: una línea JSON por request, en 3 pods."""
    import json
    import random
    
    random.seed(7)
    PODS = {"gateway-x2k4p": 1.0, "gateway-q8n7d": 3.0, "gateway-7tq2m": 1.0}  # el 3.0: CPU lenta
    
    
    def linea(lento):
        r = {"event": "classify", "request_id": f"{random.getrandbits(128):032x}",
             "model_version": "2026-09-01-resnet50-fp16",
             "source": random.choice(["mobile"] * 8 + ["web", "internal"])}
        if r["source"] == "web" and random.random() < 0.05:  # un cliente web manda multipart
            r.update(error="Content-Type debe ser image/jpeg, image/png o image/webp",
                     status=415, latency_ms=0.3)
            return r
        r["image_bytes"] = random.randint(150_000, 260_000)
        if lento > 1 and random.random() < 0.06:  # sin slot de CPU en 50 ms
            r.update(error="gateway saturado (CPU)", status=503, latency_ms=51.2)
            return r
        pre = round(random.lognormvariate(1.9, 0.35) * lento, 1)  # ≈ 7 ms; × 3 en el pod lento
        r.update(preprocess_ms=pre, image_sha256=f"{random.getrandbits(64):016x}")
        if random.random() < 0.03:  # Triton lento: vence el timeout de 150 ms (C01 anota igual lo que esperó)
            r.update(inference_ms=150.4, error="timeout de inferencia", status=504, latency_ms=round(pre + 152, 1))
            return r
        inf = round(random.lognormvariate(2.1, 0.3), 1)  # ≈ 8 ms
        r.update(inference_ms=inf, top_k=[[412, 0.83], [87, 0.07], [3, 0.04], [250, 0.02], [9, 0.01]],
                 status=200, latency_ms=round(pre + inf + random.uniform(2, 6), 1))
        return r
    
    
    for pod, lento in PODS.items():
        with open(f"{pod}.log", "w", encoding="utf-8") as f:
            for _ in range(1000):
                f.write(json.dumps(linea(lento), ensure_ascii=False) + "\n")
    print("listo: 3 archivos, 1,000 líneas cada uno (datos simulados)")
    EOF

    Qué deberías ver: Sin salida. logs.py escribe logs SIMULADOS con el formato de main.py: una línea JSON por request, en 3 pods, con dos problemas escondidos.

  2. Paso 2
    python3 logs.py && head -n 1 gateway-x2k4p.log

    Qué deberías ver: listo: 3 archivos, 1,000 líneas cada uno (datos simulados) y una línea como {"event": "classify", "request_id": "6513270e…", …, "preprocess_ms": 8.3, …, "inference_ms": 7.0, …, "status": 200, "latency_ms": 19.0}. No trae hora: en el cluster la agrega el runtime del contenedor.

  3. Paso 3
    cat gateway-*.log | grep -o '"status": [0-9]*' | sort | uniq -c

    Qué deberías ver: 2826 "status": 200, 15 "status": 415, 68 "status": 503 y 91 "status": 504. Paso 1 del método: qué códigos y cuántos.

  4. Paso 4
    cat gateway-*.log | jq -r 'select(.status >= 500) | "\(.status) \(.error)"' | sort | uniq -c

    Qué deberías ver: 68 503 gateway saturado (CPU) y 91 504 timeout de inferencia: dos problemas distintos, no uno.

  5. Paso 5
    grep -c '"status": 503' gateway-*.log; grep -c '"status": 504' gateway-*.log

    Qué deberías ver: Los 503 vienen todos de gateway-q8n7d.log (68; los otros, 0). Los 504 están repartidos: 31, 35 y 25. Un pod con problemas propios, y algo aguas abajo que afecta a todos.

  6. Paso 6
    for f in gateway-*.log; do echo "$f $(jq -s '[.[].preprocess_ms | numbers] | sort | .[(length * 0.99 | floor)]' $f) $(jq -s '[.[].inference_ms | numbers] | sort | .[(length * 0.99 | floor)]' $f)"; done

    Qué deberías ver: El p99 de preprocess_ms y de inference_ms por pod: gateway-7tq2m.log 16.0 150.4, gateway-q8n7d.log 43.5 150.4 y gateway-x2k4p.log 14.3 150.4. El pod de los 503 tarda ≈ 3× más en CPU. En la inferencia, los tres dan 150.4: los 504 entran con lo que esperaron y, como son más del 1 % en cada pod, se quedan con el p99. Si filtras solo los 200 (select(.status == 200)), vuelven a 15.9, 16.9 y 16.9: cuando Triton contesta, contesta rápido.

  7. Paso 7
    grep -h 0c1f76327efa51a2e18be6462acb4931 gateway-*.log | jq -c '{status, error, image_bytes, preprocess_ms, latency_ms}'

    Qué deberías ver: {"status":503,"error":"gateway saturado (CPU)","image_bytes":233074,"preprocess_ms":null,"latency_ms":51.2}: un request concreto, encontrado por su ID. Esperó un turno de CPU 50 ms y se rindió; nunca llegó a decodificar.

  8. Paso 8
    cd ~ && rm -rf ~/lab-gateway

    Qué deberías ver: Sin salida.

Cómo arreglarlo y confirmarlo

Los 503 de un solo pod con el preproceso lento apuntan a su nodo: compara kubectl top pod, mira el nodo con kubectl describe node y, si es un vecino ruidoso, márcalo con kubectl cordon (no recibe pods nuevos) y borra ese pod: el Deployment lo recrea en otro nodo. Los 504 repartidos apuntan a Triton: cola contra cómputo. Y los 415 vienen todos de source web: un cliente que manda multipart.

Probado de verdad en un Mac. Ejecutado con Python 3.14.3, 3.13.16 y 3.9.6 (los tres generan los mismos archivos, byte a byte) y jq 1.7.1 de macOS; los logs son simulados con el formato de main.py.

13

Errores que debes reconocer rápido

Lees el mensaje: ¿quién lo dijo (tu app, uvicorn, el cliente HTTP, asyncio, Kubernetes o el ALB), qué significa y qué corres primero?

Sintaxis con variables

{"detail":[{"type":"tipo","loc":["lugar","campo"],"msg":"mensaje","input":valor,"ctx":{"limite":n}}]}

Un 422 de FastAPI: la validación rechazó el request y tu función ni corrió. loc dice dónde (query, path, header o body) y qué campo; input, lo que llegó; ctx, el límite que no se cumplió.

{"detail":"mensaje"}

Un HTTPException: lo decidió tu código (o FastAPI, como Not Found). En C01, el mensaje dice cuál de los 503 o de los 504 es.

N validation error for Modelo
campo
  mensaje [type=tipo, input_value=valor, input_type=tipo_py]

Un ValidationError de Pydantic dentro de tu código, no en el request: el cliente recibe un 500, no un 422.

NIVEL:    mensaje

Una línea de uvicorn (INFO, WARNING o ERROR): arranque, apagado, el puerto, Exception in ASGI application.

modulo.Excepcion: mensaje

La última línea de un traceback (la lista de llamadas que llevó al error): la excepción y su texto. Así hablan aiohttp, tritonclient, httpx y asyncio; las líneas de arriba dicen en qué línea de tu código pasó.

Variables

VariableQué esEjemplo
tipoel código del error de Pydanticgreater_than_equal, missing, int_parsing
lugardónde venía el datoquery, path, header, body
campoel campo con problematop_k
mensajeel texto del errorInput should be greater than or equal to 1
valorlo que llegó; en query y headers, siempre texto"0"
limite, nla regla que no se cumplióge, 1
N, Modelocuántos errores (desde 2 dice errors) y en qué modelo de Pydantic1, Prediccion
tipo_pyel tipo de Python de lo que llegóstr
NIVELla gravedad del log de uvicornINFO, WARNING, ERROR
modulo.Excepcionquién la lanzótritonclient.utils.InferenceServerException, httpx.ReadTimeout

Las piezas

PiezaQué haceEjemplo
422 {"detail":[…]}FastAPI validó el request y lo rechazó: tu función no corrió.top_k=0
{"detail":"…"}Un HTTPException: tu código eligió el código y el texto.gateway saturado (CPU)
Internal Server Error (texto plano)Una excepción que nadie atrapó; el detalle está en el log, no en la respuesta.Exception in ASGI application
… validation error for …Pydantic rechazó datos al crear un modelo dentro de tu código: es un 500, no un 422. Si falla el response_model, FastAPI lo dice con otro texto: ResponseValidationError, también 500.[type=float_parsing, …]
ERROR: · WARNING: · INFO:uvicorn hablando: arranque, apagado, el puerto, requests inválidos.Error loading ASGI app.
aiohttp.ClientErrorLa familia de errores de red de aiohttp, que usa tritonclient.http.aio; C01 la traduce a 503.ClientConnectorError
InferenceServerException: [código] mensajeTriton respondió un error; entre corchetes, el código HTTP que mandó Triton: 400 es tu request, 404 un modelo que no conoce, 503 capacidad o estado.[404] Request for unknown model…
httpx.TimeoutExceptionSi tu gateway usa httpx (C01 no): venció uno de sus cuatro timeouts (connect, read, write, pool), 5 s cada uno por defecto.ReadTimeout, PoolTimeout
httpx.NetworkError · ProtocolErrorSi usas httpx: no hubo conexión (ConnectError) o el servidor cortó o violó el protocolo (RemoteProtocolError).Server disconnected without sending a response.
RuntimeWarning · RuntimeError de asyncioUso incorrecto del event loop: una corrutina sin await, un loop cerrado o ajeno.Event loop is closed
Readiness probe failedEl kubelet (el agente de Kubernetes en cada nodo) no pudo confirmar que el pod está listo (readiness): el pod sale del ALB, el balanceador de AWS.HTTP probe failed with statuscode: 503
502 · 503 · 504 en HTMLLo respondió el ALB, no tu app: no aparece en tus logs.502 Bad Gateway
FAILED … AssertionErrorpytest: un assert no se cumplió; abajo muestra los valores comparados.assert 200 == 504

Quiz · un mensaje a la vez

Mensaje 1 de 26

Error loading ASGI app. Could not import module "app.main".
Quiz: el mensaje tal como lo ves; responde quién lo dijo, qué significa, qué corres primero y en qué sección está.

Los 26 mensajes

26 de 26

  1. Error loading ASGI app. Could not import module "app.main".

    uvicorn encontró el paquete app, pero no el módulo app.main: un typo (app.mian:app), falta app/main.py, o Python encontró otra carpeta app/ antes que la tuya. Si arrancas desde una carpeta sin app/, el error es otro: ModuleNotFoundError: No module named 'app'. Y si el módulo existe pero le falta una dependencia, ves el ModuleNotFoundError de esa dependencia (No module named 'numpy'). C01 se arranca desde gateway/, donde está el paquete app/.

    Primero: python -c "import app; print(app.__path__)" (en la carpeta donde corres uvicorn: ¿qué app/ encontró y tiene main.py?)

    → Sección 2 · FastAPI básico: app, rutas, modelos, response_model, códigos, dependencias y lifespan

  2. AttributeError: 'State' object has no attribute 'inflight'

    El lifespan no corrió, así que app.state no tiene lo que se crea al arrancar (inflight, cpu_slots, triton). Pasa si creas FastAPI() sin lifespan=lifespan, o en un test con TestClient(app) sin with. El request termina en 500.

    Primero: grep -n "FastAPI(" app/main.py

    → Sección 2 · FastAPI básico: app, rutas, modelos, response_model, códigos, dependencias y lifespan

  3. RuntimeWarning: coroutine 'infer' was never awaited

    Llamaste a una función async sin await: se creó la corrutina, pero nunca corrió. No es un error: el request sigue sin esa parte (sin inferencia, sin log, sin métrica). Python avisa cuando la corrutina se descarta; con PYTHONASYNCIODEBUG=1 dice además dónde se creó.

    Primero: grep -n "infer(" app/*.py

    → Sección 3 · async contra hilos: event loop, async def contra def, trabajo bloqueante, threadpool, workers y GIL

  4. RuntimeError: Event loop is closed

    Algo quiso usar un event loop que ya terminó: un cliente async (aiohttp, httpx) u otro objeto de asyncio creado en un loop y usado en otro. Pasa al crearlos al importar el módulo, o entre tests (TestClient sin with usa un loop por request). Por eso C01 crea el cliente de Triton en el lifespan.

    Primero: grep -nE "ClientSession|AsyncClient|InferenceServerClient" app/*.py

    → Sección 3 · async contra hilos: event loop, async def contra def, trabajo bloqueante, threadpool, workers y GIL

  5. {"detail":[{"type":"greater_than_equal","loc":["query","top_k"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}

    Un 422 de FastAPI: el request no cumplió el contrato y tu función ni corrió. type es el código del error; loc, dónde (query) y qué campo; input, lo que llegó (en la query siempre es texto); ctx, el límite. FastAPI quita el campo url que agrega Pydantic.

    Primero: curl -s -X POST "localhost:8080/v1/classify?top_k=0" -H "Content-Type: image/jpeg" --data-binary @foto.jpg

    → Sección 4 · Validación y contrato: Pydantic v2, uploads con límite, 400/413/415/422, versionado y OpenAPI

  6. ValidationError: 1 validation error for Prediccion · score · Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='alto', input_type=str]

    Pydantic rechazó datos dentro de tu código (al armar un modelo con la respuesta de otro servicio, o con la config), no en el request: el cliente recibe un 500, no un 422. Debajo viene un enlace a errors.pydantic.dev con la versión (2.13 en C01). En Pydantic v1 el mismo error decía value is not a valid float.

    Primero: kubectl logs deploy/gateway -n classifier --all-pods=true --since=10m | grep -B 5 "validation error for"

    → Sección 4 · Validación y contrato: Pydantic v2, uploads con límite, 400/413/415/422, versionado y OpenAPI

  7. Cannot connect to host triton.classifier.svc.cluster.local:8000 · httpx.ConnectError: All connection attempts failed

    No hubo conexión con el servidor de abajo: nada escucha en ese host y puerto (conexión rechazada) o el DNS no resuelve el nombre. Lo primero es el ClientConnectorError de aiohttp, que usa tritonclient.http.aio: C01 lo traduce a 503 Triton no disponible con Retry-After: 1. Lo segundo es lo que verías si tu gateway usara httpx async (con el cliente síncrono: [Errno 111] Connection refused). Si una NetworkPolicy descarta los paquetes, no ves ninguno de los dos: la conexión se cuelga y, en C01, vence el timeout (504).

    Primero: kubectl get endpointslices -n classifier -l kubernetes.io/service-name=triton

    → Sección 5 · Llamar al servidor de modelos: cliente, pool, timeouts, reintentos y deadline

  8. httpx.RemoteProtocolError: Server disconnected without sending a response.

    Si usas httpx (C01 no): el servidor cerró la conexión sin responder. Lo típico: reusaste del pool una conexión keep-alive que el servidor acababa de cerrar por inactividad (uvicorn cierra a los 5 s por defecto, y httpx guarda las ociosas 5 s: empatan). La regla es la del ALB: el cliente suelta la conexión antes que el servidor (keepalive_expiry menor). También pasa si el servidor se reinició a mitad del request.

    Primero: grep -nE "AsyncClient|Limits|keepalive_expiry" app/*.py (¿keepalive_expiry menor que el keep-alive del servidor de abajo?)

    → Sección 5 · Llamar al servidor de modelos: cliente, pool, timeouts, reintentos y deadline

  9. tritonclient.utils.InferenceServerException: [404] Request for unknown model: 'classifier' is not found

    Triton no conoce un modelo con ese nombre: un MODEL_NAME mal escrito, o un modelo que nunca se cargó. Entre corchetes va el código HTTP que mandó Triton: 404 en la 24.08 del C01 (hasta la 23.12, 400); .message() da el texto sin él. En C01 le llega al cliente como 503 Triton rechazó la inferencia: … con Retry-After: 1.

    Primero: curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/v2/models/classifier/ready (con kubectl port-forward -n classifier svc/triton 8000:8000)

    → Sección 5 · Llamar al servidor de modelos: cliente, pool, timeouts, reintentos y deadline

  10. httpx.ReadTimeout

    Si usas httpx: esperó datos del servidor más que el timeout de lectura (5 s por defecto). Con el cliente async el texto suele venir vacío; con el síncrono, timed out. Los timeouts de httpx son por operación (connect, read, write, pool), no un total: para un presupuesto por request, envuelve la llamada con asyncio.timeout o asyncio.wait_for, como C01 con tritonclient.

    Primero: grep -nE "Timeout|timeout=" app/*.py

    → Sección 5 · Llamar al servidor de modelos: cliente, pool, timeouts, reintentos y deadline

  11. httpx.PoolTimeout

    Si usas httpx: ninguna conexión del pool se liberó antes del timeout de pool (5 s por defecto). Tienes más requests en vuelo hacia ese servidor que max_connections (100 por defecto), y cada uno tarda. Casi nunca se arregla agrandando el pool: el servidor de abajo está lento, le mandas de más, o hay respuestas en streaming sin cerrar que retienen conexiones.

    Primero: histogram_quantile(0.99, sum by (le) (rate(classifier_inference_seconds_bucket[5m]))) para ver la latencia hacia el servidor de abajo, en C01 Triton

    → Sección 5 · Llamar al servidor de modelos: cliente, pool, timeouts, reintentos y deadline

  12. 503 {"detail":"gateway saturado: reintenta más tarde"} · Retry-After: 1

    Load shedding del C01: el pod ya tenía 16 requests en vuelo (MAX_INFLIGHT) y rechazó este en microsegundos, sin leer el body. Retry-After dice en cuántos segundos reintentar. Si se sostiene, algo se frenó (16 en vuelo a 100 req/s son 160 ms por request) o falta capacidad.

    Primero: kubectl get hpa gateway -n classifier

    → Sección 6 · Backpressure y control de admisión: límite en vuelo, 429 contra 503, load shedding

  13. Triton rechazó la inferencia: Request timeout expired

    La cola de Triton rechazó el request porque esperó más de 100 ms (default_queue_policy con REJECT): la GPU no da abasto. tritonclient lo muestra como [503] Request timeout expired, y C01 lo devuelve como 503 con Retry-After: 1.

    Primero: sum(rate(nv_inference_queue_duration_us{model="classifier"}[5m])) / sum(rate(nv_inference_request_success{model="classifier"}[5m])) / 1000

    → Sección 6 · Backpressure y control de admisión: límite en vuelo, 429 contra 503, load shedding

  14. ValueError: Out of range float values are not JSON compliant: nan

    Quisiste devolver NaN o infinito en un JSON. En una ruta sin response_model, como classify, FastAPI usa el JSONResponse de Starlette, con allow_nan=False: el cliente recibe un 500, y si tu finally ya anotó el request, la línea de log y la métrica dicen 200 (el finally corre antes de serializar). Por eso el C01 revisa los logits con np.isfinite apenas llegan de Triton y responde un 500 propio, que sí se cuenta: en sus logs no vas a ver este mensaje. Con un response_model, Pydantic lo escribe como null, sin error. En un clasificador, el NaN suele venir de logits rotos (un engine mal construido, un FP16 desbordado). Desde Python 3.12 el mensaje termina con el valor (: nan); en 3.11 o antes, sin él.

    Primero: kubectl logs deploy/gateway -n classifier --all-pods=true --since=10m | grep -B 20 "not JSON compliant" · en el C01: kubectl logs deploy/gateway -n classifier --all-pods=true --since=10m | grep -c 'logits no finitos'

    → Sección 7 · Errores y degradación: un código por falla, la escalera de C01 y nada de detalles internos

  15. Internal Server Error · Exception in ASGI application

    Una excepción que nadie atrapó. El cliente recibe un 500 en texto plano, sin detalle (bien: no filtras nada); el detalle está en el log, donde uvicorn imprime Exception in ASGI application y el traceback. En C01, la línea JSON de ese request queda con status 500 y sin campo error.

    Primero: kubectl logs deploy/gateway -n classifier --all-pods=true --since=10m | grep -A 30 "Exception in ASGI application"

    → Sección 7 · Errores y degradación: un código por falla, la escalera de C01 y nada de detalles internos

  16. Readiness probe failed: HTTP probe failed with statuscode: 503

    El /readyz del gateway respondió 503: el modelo no está listo en Triton, o Triton no contestó en 0.5 s. El pod sale del ALB sin reiniciarse. Si pasa en todos los gateways a la vez, el problema es Triton, no el gateway.

    Primero: kubectl events -n classifier --types=Warning

    → Sección 8 · Health, readiness y apagado ordenado

  17. Shutting down · Waiting for connections to close. (CTRL+C to force quit)

    uvicorn recibió SIGTERM (o Ctrl+C): cerró el puerto y espera los requests en curso, hasta --timeout-graceful-shutdown (20 s en C01; sin el flag, espera sin límite). Si se acaba el plazo, cancela lo que queda (Cancel N running task(s), timeout graceful shutdown exceeded) y esos requests terminan en 500. Después: Application shutdown complete y Finished server process.

    Primero: kubectl logs -f POD -n classifier

    → Sección 8 · Health, readiness y apagado ordenado

  18. TypeError: Object of type float32 is not JSON serializable

    json.dumps no sabe escribir tipos de NumPy: float32 e int64 fallan (float64 pasa, porque es una subclase de float). En C01 la línea de log se escribe en un finally: si el record trae un np.float32, el error reemplaza la respuesta y el request termina en 500. Por eso el código convierte con float() e int(). Si lo devuelves desde una ruta sin response_model (como classify), FastAPI falla con otro texto: un ValueError que incluye 'numpy.float32' object is not iterable.

    Primero: grep -nE "float\(|int\(" app/main.py

    → Sección 9 · Observabilidad: una línea JSON por request, request ID, métricas y trazas

  19. prometheus_client.registry.DuplicateTimeseries: Duplicated timeseries in CollectorRegistry: {'classifier_requests_total', …}

    Registraste dos veces una métrica con el mismo nombre en el registro global de prometheus_client: el módulo se importó dos veces con nombres distintos, un test lo recargó, o en un notebook re-ejecutaste la celda que crea el Counter. Desde prometheus-client 0.26.0 (la del C01) la excepción es DuplicateTimeseries, una subclase de ValueError; antes salía ValueError: con el mismo texto. Las métricas se crean una vez, al nivel del módulo.

    Primero: grep -rnE "Counter\(|Histogram\(" app/

    → Sección 9 · Observabilidad: una línea JSON por request, request ID, métricas y trazas

  20. TypeError: object MagicMock can't be used in 'await' expression

    Reemplazaste un cliente async con MagicMock: lo que devuelve su método es otro mock, y no se puede esperar con await. Usa AsyncMock o, mejor, un fake con los mismos métodos async y las mismas excepciones, como el FakeTriton del C01. En Python 3.14 el texto cambió a 'MagicMock' object can't be awaited.

    Primero: grep -n "MagicMock" tests/*.py

    → Sección 10 · Tests: pytest, TestClient, el fake de Triton y las pruebas de carga

  21. async def functions are not natively supported.

    pytest no sabe correr solo un test async def: desde pytest 8.4 el test falla (antes se saltaba con un aviso). Márcalo con @pytest.mark.anyio (el plugin viene con anyio, que ya instala FastAPI), usa pytest-asyncio, o escribe el test con def y TestClient, como C01.

    Primero: grep -n "async def test_" tests/*.py

    → Sección 10 · Tests: pytest, TestClient, el fake de Triton y las pruebas de carga

  22. RuntimeError: The starlette.testclient module requires the httpx2 package to be installed.

    TestClient necesita un cliente HTTP por debajo. Desde Starlette 1.2 busca httpx2 y, si solo encuentra httpx, funciona pero avisa que está deprecado; sin ninguno de los dos, falla con este error. C01 fija httpx2==2.13.1 en requirements-dev.txt.

    Primero: pip install -r requirements-dev.txt

    → Sección 10 · Tests: pytest, TestClient, el fake de Triton y las pruebas de carga

  23. Executing <Task pending name='Task-3' coro=<request() running at carga.py:30> … took 0.182 seconds

    El modo debug de asyncio (PYTHONASYNCIODEBUG=1) avisa que una tarea ocupó el event loop 182 ms seguidos: en ese tiempo no avanzó ningún otro request. El umbral es 100 ms (loop.slow_callback_duration). Dice qué corrutina fue y la línea donde quedó pausada después, el siguiente await: el código bloqueante (CPU o I/O sin await) está justo antes de esa línea.

    Primero: PYTHONASYNCIODEBUG=1 uvicorn app.main:app --port 8080 --loop asyncio

    → Sección 11 · Rendimiento: workers de uvicorn, CPU para el decode, keep-alive, perfilar y medir el p99

  24. 502 Bad Gateway

    Una página HTML del ALB: el pod cerró la conexión (TCP RST o FIN) con un request pendiente. Si son esporádicos, casi siempre es keep-alive: el backend cierra la conexión ociosa antes que el ALB (uvicorn, a los 5 s por defecto; el ALB, a los 60). En los deploys, un pod que se apagó sin drenar. No aparece en tus logs.

    Primero: grep -n "timeout-keep-alive" Dockerfile

    → Sección 11 · Rendimiento: workers de uvicorn, CPU para el decode, keep-alive, perfilar y medir el p99

  25. [Errno 48] error while attempting to bind on address ('127.0.0.1', 8080): [errno 48] address already in use

    Otro proceso ya usa ese puerto: otro uvicorn que quedó corriendo, o docker compose. uvicorn lo imprime como ERROR al arrancar y sale. Es el texto del loop de asyncio en Python 3.13 y 3.14; en 3.11 o antes, y con uvloop (instalado en C01), no repite el [errno …] del medio. Con --workers 2 o más, o con --reload, es más corto: [Errno 48] Address already in use. En Linux el número es 98.

    Primero: lsof -nP -iTCP:8080 -sTCP:LISTEN

    → Sección 12 · Diagnóstico: el método y la tabla de síntomas

  26. starlette.requests.ClientDisconnect

    El cliente cerró la conexión mientras el gateway leía el body con request.stream(). La excepción no trae texto. Si nadie la atrapa, el request cuenta como 500 sin campo error y uvicorn imprime Exception in ASGI application, aunque el servidor esté sano. El C01 la atrapa al leer el body y la anota como 499, sin traceback: en sus logs vas a ver la línea JSON, no este mensaje. En el ALB, un cliente que corta aparece como 460 (o 400, si cortó antes de mandar todo el body); en nginx, como 499.

    Primero: kubectl logs deploy/gateway -n classifier --all-pods=true --since=1h | grep -c 'requests.ClientDisconnect$' · en el C01: kubectl logs deploy/gateway -n classifier --all-pods=true --since=1h | grep -c '"status": 499'

    → Sección 12 · Diagnóstico: el método y la tabla de síntomas

Ejemplo real · Tres errores provocados con Python 3.13, la versión del contenedor del C01

textoSalidas reales con Python 3.13.15 en macOS; … marca líneas omitidas del traceback
$ python3.13 -c 'import asyncio
async def infer(): return [0.1, 0.9]
async def main(): infer()
asyncio.run(main())'
<string>:3: RuntimeWarning: coroutine 'infer' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

$ python3.13 -c 'import json; json.dumps({"score": float("nan")}, allow_nan=False)'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  …
ValueError: Out of range float values are not JSON compliant: nan

$ python3.13 -c 'import asyncio
from unittest.mock import MagicMock
async def classify(client): return await client.infer()
asyncio.run(classify(MagicMock()))'
Traceback (most recent call last):
  …
  File "<string>", line 3, in classify
    async def classify(client): return await client.infer()
                                       ^^^^^^^^^^^^^^^^^^^^
TypeError: object MagicMock can't be used in 'await' expression
  • Lee de abajo hacia arriba: la última línea es la excepción y su texto; la primera línea con File que apunta a tu código dice dónde. Las de en medio suelen ser de librerías.
  • Un RuntimeWarning no detiene nada: la corrutina sin await simplemente no corre y el programa termina bien. Por eso es peligrosa: no hay 500, solo falta trabajo (una inferencia, un log, una métrica).
  • : nan al final aparece desde Python 3.12 (también en 3.13 y 3.14); en 3.11 o antes, el mensaje termina en compliant. Y el de MagicMock cambió en 3.14 a 'MagicMock' object can't be awaited: busca por la parte estable del mensaje.
  • Quién habla: {"detail": …} en JSON es tu app (FastAPI); Internal Server Error en texto plano es un 500 que nadie atrapó; ERROR: o INFO: es uvicorn; Readiness probe failed es el kubelet; una página HTML de 502 o 503 es el ALB. En C01 traen X-Request-ID las respuestas que arma classify y los 422, que pasan por su exception handler; un 500 sin atrapar, no.

Cómo recordarla

quién habla dice dónde mirar. JSON con detail es tu app; ERROR: o INFO: es uvicorn; aiohttp, InferenceServerException o httpx. es la llamada al servidor de modelos; RuntimeWarning y RuntimeError de asyncio son el event loop; Warning … probe failed es el kubelet; HTML es el ALB.

Trampa

buscar el texto exacto sin mirar la versión. Python 3.13 cambió los nombres de los códigos (la línea de estado dice 413 Content Too Large y 422 Unprocessable Content; antes, Request Entity Too Large y Unprocessable Entity), 3.14 cambió el texto de MagicMock, Pydantic v2 cambió todos sus mensajes (value is not a valid integer pasó a Input should be a valid integer…) y pytest 8.4 convirtió el aviso de los tests async en una falla. Busca por la parte estable: el tipo de excepción y las palabras clave.

Laboratorio (opcional)7 pasos

Qué vas a provocar: Reconocer los 26 mensajes en 5 minutos y provocar cinco de verdad con Python: una corrutina sin await, un loop cerrado, NaN en JSON, un MagicMock con await y un puerto ocupado.

Necesitas: Una terminal de macOS con python3 3.13 o 3.14 (el de Homebrew): con versiones anteriores cambian algunos textos. Nada que instalar.

  1. Paso 1
    ronda() { printf '%s\n' 'Error loading ASGI app. Could not import module "app.main".' "AttributeError: 'State' object has no attribute 'inflight'" "RuntimeWarning: coroutine 'infer' was never awaited" 'RuntimeError: Event loop is closed' '{"detail":[{"type":"greater_than_equal","loc":["query","top_k"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}' "ValidationError: 1 validation error for Prediccion · score · Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='alto', input_type=str]" 'Cannot connect to host triton.classifier.svc.cluster.local:8000 · httpx.ConnectError: All connection attempts failed' 'httpx.RemoteProtocolError: Server disconnected without sending a response.' "tritonclient.utils.InferenceServerException: [404] Request for unknown model: 'classifier' is not found" 'httpx.ReadTimeout' 'httpx.PoolTimeout' '503 {"detail":"gateway saturado: reintenta más tarde"} · Retry-After: 1' 'Triton rechazó la inferencia: Request timeout expired' 'ValueError: Out of range float values are not JSON compliant: nan' 'Internal Server Error · Exception in ASGI application' 'Readiness probe failed: HTTP probe failed with statuscode: 503' 'Shutting down · Waiting for connections to close. (CTRL+C to force quit)' 'TypeError: Object of type float32 is not JSON serializable' "prometheus_client.registry.DuplicateTimeseries: Duplicated timeseries in CollectorRegistry: {'classifier_requests_total', …}" "TypeError: object MagicMock can't be used in 'await' expression" 'async def functions are not natively supported.' 'RuntimeError: The starlette.testclient module requires the httpx2 package to be installed.' "Executing <Task pending name='Task-3' coro=<request() running at carga.py:30> … took 0.182 seconds" '502 Bad Gateway' "[Errno 48] error while attempting to bind on address ('127.0.0.1', 8080): [errno 48] address already in use" 'starlette.requests.ClientDisconnect' | sort -R | head -5; }; ronda

    Qué deberías ver: Cinco mensajes al azar. Para cada uno, en menos de 30 s y en voz alta: quién lo dijo (tu app, uvicorn, el cliente, asyncio, el kubelet o el ALB), la causa probable y el primer comando. Después compáralo con la lista.

  2. Paso 2
    ronda

    Qué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.

  3. Paso 3
    python3 -c 'import asyncio
    async def infer(): return [0.1, 0.9]
    async def main(): infer()
    asyncio.run(main())'

    Qué deberías ver: <string>:3: RuntimeWarning: coroutine 'infer' was never awaited y RuntimeWarning: Enable tracemalloc to get the object allocation traceback. El programa termina bien: el aviso no frena nada, y la inferencia nunca corrió.

  4. Paso 4
    python3 -c 'import asyncio
    async def crear(): return asyncio.get_running_loop()
    loop = asyncio.run(crear())
    loop.call_soon(print)'

    Qué deberías ver: Un traceback que termina en RuntimeError: Event loop is closed. asyncio.run cerró su loop al terminar, y algo lo quiso usar después: lo mismo le pasa a un cliente async creado en un loop y usado en otro.

  5. Paso 5
    python3 -c 'import json; json.dumps({"score": float("nan")}, allow_nan=False)'

    Qué deberías ver: Un traceback que termina en ValueError: Out of range float values are not JSON compliant: nan (Python 3.14 agrega una línea más: when serializing dict item 'score'). JSONResponse de Starlette usa allow_nan=False: en una ruta sin response_model, como classify, un NaN en la respuesta es un 500. Por eso el C01 revisa los logits antes y responde su propio 500, que sí queda en la línea y en la métrica.

  6. Paso 6
    python3 -c 'import asyncio
    from unittest.mock import MagicMock
    async def classify(client): return await client.infer()
    asyncio.run(classify(MagicMock()))'

    Qué deberías ver: Con Python 3.13: TypeError: object MagicMock can't be used in 'await' expression. Con 3.14: TypeError: 'MagicMock' object can't be awaited. Mismo error, otro texto.

  7. Paso 7
    python3 -c 'import asyncio
    async def main():
        await asyncio.start_server(lambda r, w: None, "127.0.0.1", 18080)
        await asyncio.start_server(lambda r, w: None, "127.0.0.1", 18080)
    asyncio.run(main())'

    Qué deberías ver: OSError: [Errno 48] error while attempting to bind on address ('127.0.0.1', 18080): [errno 48] address already in use. Es lo que uvicorn imprime como ERROR al arrancar con el puerto ocupado y el loop de asyncio; con uvloop sale sin el [errno 48] del medio. En Linux, Errno 98.

Cómo arreglarlo y confirmarlo

Los que fallaste: abre la sección de su tema (el enlace de la lista) y haz su laboratorio. Repite la ronda otro día: la meta es 5 de 5 en menos de 2 minutos.

Probado de verdad en un Mac. Los pasos 3 a 7 se ejecutaron con Python 3.13.15 y 3.14.3 en macOS; donde el texto cambia entre versiones, el paso lo dice.

⚙

Herramientas

Del síntoma al primer comando, y una revisión rápida del código de tu gateway antes de abrir el pull request.

Diagnosticador

Elige lo que ves. Te dice qué correr primero y las causas típicas, cada una con cómo comprobarla y cómo arreglarla.

Elige un síntoma para ver las causas.

Revisor de código del gateway

Pega un archivo de Python de tu gateway (FastAPI). Revisa 18 reglas típicas en tu navegador: lo que bloquea el event loop dentro de un async def, clientes HTTP sin timeout o creados en cada request, bodies leídos sin tope, errores que filtran detalles internos o que responden 500 a un timeout, except que se tragan el error, el modo de desarrollo y CORS abierto con credenciales. Cada hallazgo dice la línea.

Es heurístico: lee el código por líneas (sin comentarios ni docstrings) y no lo ejecuta, así que puede pasar algo por alto o marcar algo que está bien.

Corre en tu navegador, sin bajar nada: funciona sin conexión.