Módulo 7: Reliability Patterns & Production Checklist

8. Resumen y Troubleshooting del Módulo 7

Descripción

En este módulo construiste la capa de reliability que separa una app AI que "funciona en mi laptop" de una que "funciona en producción con 500 usuarios concurrentes." Los cinco patterns — error classification, retry, circuit breaker, rate limiting y fallback — no son opcionales para producción real. Esta cápsula cierra el módulo con el diagnóstico de los problemas más comunes que vas a encontrar, el production checklist de AI específico, y la conexión hacia el Módulo 8 donde vas a integrar todo.


Los 5 errores más comunes y cómo diagnosticarlos


Error 1: Retry en todo sin discriminar

Síntoma: Tu app hace retry de errores 401 y 400, generando llamadas inútiles (y a veces incrementando el costo).

Diagnóstico:

# En tus logs, busca patrones como:
# llm_retry_attempt where error_type=AuthenticationError
# llm_retry_attempt where error_type=BadRequestError

# Con jq en tus JSONL logs:
jq 'select(.event == "llm_retry_attempt") | {error_type, attempt}' logs/app.json

Causa raíz: retry_if_exception_type((Exception,)) — catching demasiado amplio.

Solución:

# ❌ Muy amplio:
retry=retry_if_exception_type(Exception)

# ✅ Solo errores transitorios:
def is_retryable(e):
    from openai import APITimeoutError, RateLimitError, InternalServerError
    from src.infrastructure.llm_provider import LLMProviderError
    if isinstance(e, (APITimeoutError, RateLimitError, InternalServerError)):
        return True
    if isinstance(e, LLMProviderError):
        return e.should_retry
    return False  # Desconocido → no retry por defecto

retry=retry_if_exception(is_retryable)

Error 2: Circuit breaker que no se activa cuando debería

Síntoma: OpenAI lleva 5 minutos caído, tu app sigue enviando requests (cada uno con 3 retries), el servidor está saturado, y el circuit nunca abre.

Diagnóstico:

# El circuit nunca se activa si:
# 1. El threshold es muy alto para el volumen de tráfico
# 2. Los failures no coinciden con expected_exceptions
# 3. El circuit es per-request (nuevo en cada request, nunca acumula)

# Verificar que el circuit es un SINGLETON:
# Incorrecto: crear CircuitBreaker() dentro del endpoint handler
# Correcto: crear CircuitBreaker() una vez en startup

# Verificar métricas:
circuit_breaker.get_metrics()
# → {"failure_count": 2, "failure_threshold": 5, "state": "closed"}
# Si failure_count nunca sube, los errores no están siendo capturados

Causa raíz más común: El CircuitBreaker se recrea en cada request (por ejemplo, dentro de una función que se llama por request).

Solución:

# ❌ CircuitBreaker per-request (no acumula estado):
@app.post("/analyze")
async def analyze(body: AnalyzeRequest):
    cb = CircuitBreaker(failure_threshold=5)  # ← NUEVO en cada request
    with_cb = CircuitBreakerProvider(provider, cb)
    ...

# ✅ CircuitBreaker singleton:
# En dependencies.py, fuera de cualquier función de endpoint:
_circuit_breaker = CircuitBreaker(name="openai", failure_threshold=5)

def get_llm_provider():
    return CircuitBreakerProvider(base_provider, _circuit_breaker)  # mismo cb

Error 3: Fallback que degrada silenciosamente y sin logging

Síntoma: Ves en producción que la calidad de las respuestas ha bajado. Nadie sabe cuándo empezó. No hay logs de fallback. Los usuarios no ven ningún indicador de que el servicio está degradado.

Diagnóstico:

# Si no hay logs de fallback, ¿cómo sabes si se está usando?
# La respuesta es: no lo sabes.

# Busca en tus logs:
jq 'select(.event == "fallback_provider_used") | .fallback_used' logs/app.json | sort | uniq -c
# Si no hay resultados, tienes un problema de visibilidad

# En los responses de la API, ¿hay un campo "degraded"?
# Si todas tus responses tienen "degraded": false pero el LLM está fallando,
# tienes un bug en la detección

Solución:

# 1. Loguear SIEMPRE cuando se usa fallback:
log.warning(
    "fallback_activated",
    primary_failed=self._names[0],
    fallback_used=name,
    request_id=get_request_id()  # Del M5 — request tracing
)

# 2. Incluir degraded en el response:
return {
    **result,
    "degraded": provider.is_degraded,
    "degraded_reason": "Usando modelo de respaldo" if provider.is_degraded else None
}

# 3. Métricas de fallback en el health check:
# /health/deps → mostrar fallback_activation_count

Error 4: Health check cosmético

Síntoma: Kubernetes muestra todos los pods como "ready", pero el endpoint /analyze falla con 500 porque OpenAI está caído.

Diagnóstico:

# Si tu health check es:
# GET /health/ready → {"status": "ok"}  # Siempre 200

# Y tu endpoint real es:
# POST /analyze → 500 (porque OpenAI falla)

# Kubernetes cree que el pod está listo y sigue enviando tráfico
# El resultado: todos los requests fallan hasta que K8s detecta el problema

Solución:

# /health/ready debe verificar dependencias:
@router.get("/ready")
async def readiness():
    checks = {}
    
    # Verificar circuit breakers
    cb_metrics = get_circuit_breaker_metrics()
    any_open = any(m["state"] == "open" for m in cb_metrics.values())
    
    if any_open:
        return JSONResponse(
            {"status": "not_ready", "reason": "circuit_open"},
            status_code=503  # K8s no enviará tráfico
        )
    
    return {"status": "ready"}

# Con esto:
# - Si circuit está open → 503 → K8s deja de enviar tráfico al pod
# - El tráfico va a otros pods (o se activa el fallback del load balancer)

Error 5: Rate limiting que bloquea en vez de throttlear

Síntoma: Bajo carga, tu app se "congela" — los requests tardan 30 segundos en responder (el max_wait_seconds del rate limiter), y muchos de ellos dan timeout antes de ser procesados.

Diagnóstico:

# Si max_wait_seconds=30 y rate=1 req/s,
# y llegan 60 requests simultáneos:
# - Los primeros pasan inmediatamente (burst)
# - El resto espera en cola: hasta 60 segundos
# - Tu API timeout puede ser 30s → los últimos 30 requests dan timeout

# Verificar utilización del bucket:
bucket.get_metrics()
# → {"utilization_percent": 100, "total_rejected": 0, "total_waited_seconds": 450}
# Si waited_seconds es alto, los requests están esperando demasiado

Solución:

# Opción 1: Reducir max_wait y rechazar rápido (para APIs interactivas)
rate_limited = RateLimitedProvider(
    inner=provider,
    requests_per_minute=60,
    max_wait_seconds=5.0  # ← Rechazar si no puede procesar en 5s
)

# Opción 2: Añadir el header Retry-After en la respuesta rechazada
# Para que el cliente sepa cuándo reintentar

# Opción 3: Hacer la cola visible al usuario
# "Tu solicitud está en cola. Posición: 15/30."

Production Checklist de AI: lo que diferencia dev de prod

Checklist mínimo (antes de cualquier lanzamiento)

RELIABILITY
├── [ ] Retry implementado con tenacity
│   ├── [ ] stop_after_attempt(3-5) — no retry infinito
│   ├── [ ] wait_random_exponential(min=1, max=30) — backoff + jitter
│   ├── [ ] retry solo errores transitorios (Timeout, RateLimit, 5xx)
│   └── [ ] before_sleep callback con logging de cada retry
│
├── [ ] Circuit breaker implementado
│   ├── [ ] failure_threshold configurado (5-10 típico)
│   ├── [ ] recovery_timeout configurado (30-60s)
│   ├── [ ] El CircuitBreaker es un singleton (no per-request)
│   └── [ ] Loguea cuando abre y cierra
│
├── [ ] Rate limiting client-side
│   ├── [ ] Debajo del 80% del límite de la API
│   ├── [ ] max_wait_seconds razonable para el caso de uso
│   └── [ ] Por modelo si se usan múltiples modelos
│
├── [ ] Fallback chain
│   ├── [ ] Al menos primary + secondary provider
│   ├── [ ] Static fallback como último recurso
│   ├── [ ] Loguea cada fallback activation
│   └── [ ] Comunica degradación al usuario (campo degraded en response)
│
OBSERVABILIDAD (del M5)
├── [ ] Structured logging (JSON) configurado
├── [ ] request_id en todos los logs
├── [ ] Costo logueado por request
├── [ ] Alerts de costo configurados
│
GUARDRAILS (del M4)
├── [ ] Input validation en todos los endpoints
├── [ ] Output validation con Pydantic
├── [ ] Content policy check en endpoints con user input
│
CLEAN ARCHITECTURE (del M6)
├── [ ] Domain no depende de providers concretos
├── [ ] Config en pydantic-settings con validación
├── [ ] Tests de domain con MockProvider (sin llamadas reales)
│
HEALTH CHECKS
├── [ ] /health/live — retorna 200 siempre que el proceso viva
├── [ ] /health/ready — retorna 503 si circuit está open o deps caídos
└── [ ] /health/deps — verifica OpenAI, rate limits, budget

Checklist de operaciones en producción (ongoing)

MONITORING
├── [ ] Dashboard con: latency p50/p95, error rate, cost/day, fallback rate
├── [ ] Alert si error rate > 5% por 5 minutos
├── [ ] Alert si cost/day > threshold (ej. $50/día)
├── [ ] Alert si circuit breaker se abre
│
INCIDENT RESPONSE
├── [ ] Runbook documentado: ¿qué hacer si OpenAI está caído?
├── [ ] Runbook: ¿qué hacer si budget se excede?
├── [ ] Canal de alertas configurado (Slack, PagerDuty, etc.)
│
MANTENIMIENTO
├── [ ] Rate limits revisados mensualmente (OpenAI los cambia)
├── [ ] Circuit breaker thresholds ajustados según tráfico real
└── [ ] Costos revisados semanalmente

Árbol de diagnóstico: ¿qué falla?

Síntoma: Mi app está devolviendo muchos errores 500
│
├── ¿Los logs muestran llm_retry_attempt?
│   ├── SÍ → El retry está activado. ¿Se agotan los retries?
│   │         ├── SÍ → ¿Es un outage? → ¿Está el circuit abierto?
│   │         │         ├── SÍ (circuit open) → Normal. Esperar recovery.
│   │         │         └── NO → Aumentar failure_threshold o investigate
│   │         └── NO → El retry funciona. ¿El problema es otro?
│   │
│   └── NO → El retry no está activo. ¿Está configurado?
│             └── Verificar que RetryProvider envuelve al OpenAIProvider
│
├── ¿Los logs muestran circuit_breaker_opened?
│   ├── SÍ → El circuit se abrió. ¿Hay fallback?
│   │         ├── SÍ → ¿El fallback funciona? Ver fallback_provider_used en logs
│   │         └── NO → Implementar fallback (cápsula 06)
│   └── NO → El circuit no se activa. ¿Es un singleton?
│             └── Verificar que CircuitBreaker se crea una vez, no por request
│
└── ¿Los logs muestran client_rate_limit_rejected?
    ├── SÍ → El rate limiter está rechazando. ¿El max_wait es muy corto?
    │         └── Aumentar max_wait o subir el rate limit
    └── NO → El problema no es rate limiting

Resumen del módulo

#CápsulaLo que aprendiste
01IntroducciónLos 5 patterns, composición, cómo la DI del M6 facilita todo
02Error HandlingTaxonomía: TRANSIENT, INPUT_ERROR, AUTH_ERROR, OUTPUT_ERROR
03Retry + Backofftenacity, exponential + jitter, solo transitorios, logging de retries
04Circuit BreakerEstados CLOSED→OPEN→HALF_OPEN, singletons, composición con retry
05Rate LimitingToken bucket, safety margin 80%, queue vs reject, budget control
06Fallbacks + HealthJerarquía de fallbacks, comunicar degradación, liveness vs readiness
07ProyectoEnsamblaje completo en dependencies.py, tests de composición
08TroubleshootingLos 5 errores más comunes, production checklist AI

Lo que viene: Módulo 8 (Proyecto Integrador)

El Módulo 8 es la culminación de toda la guía. En él vas a integrar:

  • Tests (M2-M3): unit tests, integration tests, el MockProvider como base
  • Guardrails (M4): validación de input/output en todos los endpoints
  • Logging + Observabilidad (M5): structured logging, request tracing, cost tracking
  • Clean Architecture (M6): separación en capas, DI, config con pydantic-settings
  • Reliability (M7): la capa completa de retry/circuit/rate limit/fallback

El resultado es un sistema AI production-ready completo que puedes usar como plantilla para proyectos reales o como pieza de portfolio que demuestra que entiendes todos los aspectos de llevar AI a producción.


Ejercicios

Ejercicio 1: Diagnóstico rápido

Estás en producción y ves estos logs. ¿Cuál es el diagnóstico y la acción inmediata?

{"event": "llm_retry_attempt", "attempt_number": 3, "exception_type": "RateLimitError"}
{"event": "llm_retry_attempt", "attempt_number": 3, "exception_type": "RateLimitError"}
{"event": "circuit_breaker_opened", "circuit": "primary", "failure_count": 5}
Ver solución

Diagnóstico: El primary provider está siendo rate-limited persistentemente. Los retries no resuelven el problema porque el rate limit no se libera entre intentos. Después de 5 failures, el circuit se abre.

Acción inmediata:

  1. Verificar si hay un spike de tráfico (¿por qué tantos requests?)
  2. Verificar el dashboard de OpenAI — ¿estás cerca del límite?
  3. Si el fallback está activo, el servicio sigue funcionando con degradación
  4. Reducir el rate limit client-side temporalmente o aumentar el tier en OpenAI

Ejercicio 2: Checklist de producción

Antes de hacer deploy de tu reliability layer, verifica cada ítem. Marca los que tienes:

- [ ] Retry: max_attempts ≤ 5 (más = latencia inaceptable)
- [ ] Retry: min_wait ≥ 1s (menos = thundering herd)
- [ ] Circuit breaker: failure_threshold ≥ 5 (menos = falsos positivos)
- [ ] Circuit breaker: es singleton (no se recrea por request)
- [ ] Rate limiter: usa 80% del límite real de la API
- [ ] Fallback: tiene al menos un nivel después del primary
- [ ] Health: /health/live NO verifica dependencias externas
- [ ] Health: /health/ready SÍ verifica dependencias externas
- [ ] Logs: cada retry/circuit/rate event se logea con request_id
- [ ] Tests: la cadena completa está testeada con mocks rápidos
Ver guía de verificación

Si falta alguno:

  • Retry sin min_wait: agrega min_wait_seconds=1.0 en RetryProvider
  • Circuit no singleton: mover a variable global o module-level en dependencies.py
  • Rate limiter al 100%: cambiar a requests_per_minute = int(api_limit * 0.8)
  • Liveness checa OpenAI: simplificar /health/live a solo {"status": "alive"}

Recursos adicionales

  1. tenacity Documentation — Retry library completa
  2. Circuit Breaker (Martin Fowler) — El artículo canónico
  3. Release It! (Michael Nygard) — El libro de reliability en sistemas distribuidos
  4. OpenAI Rate Limits Guide — Límites actuales y estrategias de manejo
  5. Kubernetes Probes — Liveness y readiness en K8s
  6. AWS: Exponential Backoff and Jitter — El artículo canónico sobre jitter