Módulo 7: Reliability Patterns & Production Checklist
1. Introducción: Reliability Patterns
Descripción
"Funciona en mi máquina" no significa "funciona en producción." En producción las cosas fallan — no es una posibilidad, es una certeza. La pregunta no es si OpenAI va a devolverte un error 429, sino cuándo. Los reliability patterns no previenen esos fallos: te permiten manejarlos de forma que tu servicio siga funcionando o falle de manera controlada. En este módulo vas a construir esa capa de resiliencia sobre la clean architecture que implementaste en el Módulo 6.
Al terminar este módulo, vas a tener:
- Un sistema que reintenta errores transitorios sin saturar la API
- Un circuit breaker que detecta outages y deja de enviar requests inútiles
- Rate limiting del lado del cliente para controlar tu gasto y tráfico
- Una cadena de fallbacks que mantiene el servicio vivo incluso cuando el provider primario cae
- Health checks reales que Kubernetes puede usar para decidir si tu pod está listo
Los failure modes reales de LLM APIs
Antes de escribir código, necesitas entender los escenarios que vas a enfrentar. Estos no son hipotéticos — son situaciones que ocurren regularmente en producción:
Escenario 1: Rate limit bajo presión
- Tu app tiene 100 usuarios activos simultáneos
- Todos generan requests al mismo tiempo (lunes 9am)
- OpenAI te devuelve 429 (Too Many Requests)
- Sin retry: todos ven error 500
- Con retry + backoff: los requests se distribuyen, la mayoría completa
Escenario 2: Timeout en prompt largo
- Usuario envía un documento de 50 páginas
- El modelo tarda 45s en procesarlo
- Tu client tiene timeout de 30s
- Sin timeout handling: excepción no capturada, error 500
- Con timeout retry: reintenta con prompt truncado
Escenario 3: Outage de 2 horas
- OpenAI tiene un incidente de infraestructura
- Sin circuit breaker: tu app envía 5000 requests fallidos en 2 horas
(cada request hace 3 retries = 15000 llamadas inútiles a OpenAI)
(costo: tu servidor procesando requests que nunca van a funcionar)
- Con circuit breaker: después de 5 failures consecutivos, el circuit abre
Las siguientes llamadas fallan inmediatamente (sin llamar a OpenAI)
Cuando pasan 60s, el circuit prueba una llamada → si funciona, cierra
Escenario 4: Spike de tráfico
- Tu blog post se vuelve viral, 1000 usuarios en 10 minutos
- Sin rate limiting: tus 1000 requests simultáneos todos fallan con 429
- Con rate limiter: requests se throttlean, se procesan 8/segundo
Los usuarios esperan un poco, pero obtienen respuesta
Escenario 5: Respuesta malformada
- El LLM devuelve texto en vez de JSON (por algún motivo)
- Sin fallback: excepción de parse, error 500
- Con fallback: intenta parsear, si falla devuelve respuesta por defecto
Y logea para investigar
Los 5 patterns del módulo
┌──────────────────────────────────────────────────────────────┐
│ RELIABILITY LAYER │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ RETRY │ │ CIRCUIT │ │ RATE LIMITING │ │
│ │ + BACKOFF │ │ BREAKER │ │ (Token Bucket) │ │
│ │ │ │ │ │ │ │
│ │ Transitorio │ │ Persistente │ │ Throttling │ │
│ │ → reintenta │ │ → falla │ │ → controla │ │
│ │ con espera│ │ rápido │ │ velocidad │ │
│ └─────────────┘ └──────────────┘ └────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ FALLBACK CHAIN │ │
│ │ │ │
│ │ Primary → Secondary Model → Cached Response → Static │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HEALTH CHECKS │ │
│ │ Readiness + Liveness + Dependency checks reales │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Por qué la clean architecture del M6 hace esto fácil
# ANTES del M6 (sin clean architecture):
# Si quisieras añadir retry a tu app, tendrías que modificar:
# - La god function en main.py (donde está el LLM call mezclado con todo)
# - Cada lugar donde se llama al LLM
# - Los tests (que dependen de la implementación interna)
# DESPUÉS del M6 (con clean architecture y DI):
# Para añadir reliability, solo creas wrappers del LLMProvider:
from src.infrastructure.llm_provider import LLMProvider
class RetryProvider:
"""Wrappea cualquier LLMProvider con retry logic."""
def __init__(self, inner: LLMProvider, max_attempts: int = 3):
self._inner = inner
self._max = max_attempts
def complete(self, messages: list[dict], **kwargs) -> str:
# Añade retry sin modificar domain ni processing
...
class CircuitBreakerProvider:
"""Wrappea cualquier LLMProvider con circuit breaker."""
def __init__(self, inner: LLMProvider, failure_threshold: int = 5):
self._inner = inner
...
def complete(self, messages: list[dict], **kwargs) -> str:
...
# En dependencies.py (el único lugar que cambia):
def get_llm_provider() -> LLMProvider:
base = OpenAIProvider.from_settings(settings)
with_retry = RetryProvider(base, max_attempts=3)
with_circuit_breaker = CircuitBreakerProvider(with_retry, failure_threshold=5)
return with_circuit_breaker
# El domain (sentiment_service.py) no cambia.
# Los tests del domain no cambian.
# Solo dependencies.py cambia.
Cuándo usar cada pattern
¿El error es transitorio (dura segundos)?
└─ SÍ → RETRY con backoff
Ejemplos: timeout momentáneo, rate limit que se libera pronto
¿El servicio ha fallado muchas veces seguidas?
└─ SÍ → CIRCUIT BREAKER
Ejemplos: outage, servidor caído, rate limit prolongado
¿El tráfico puede superar los límites de la API?
└─ SÍ → RATE LIMITING
Ejemplos: spikes de tráfico, carga sostenida alta
¿El primary provider falla y necesitas seguir sirviendo?
└─ SÍ → FALLBACK
Ejemplos: outage de OpenAI, modelo no disponible
¿Necesitas saber si el sistema puede recibir tráfico?
└─ SÍ → HEALTH CHECKS
Ejemplos: Kubernetes readiness, monitoring, alertas
Composición: los patterns trabajan juntos
# La secuencia correcta de composición:
# 1. Rate Limiter: ¿tengo capacidad para este request?
# Si NO → rechazar con 503, no gastar recursos
# Si SÍ → continuar
# 2. Circuit Breaker: ¿el servicio está disponible?
# Si OPEN → ir al fallback inmediatamente
# Si CLOSED/HALF-OPEN → continuar
# 3. Retry: ¿el request falló por error transitorio?
# Si SÍ → reintenta con backoff
# Si NO (error permanente) → propagar error
# 4. Fallback: ¿todos los retries fallaron?
# PRIMARY → SECONDARY → CACHED → STATIC DEFAULT
# En código:
def get_llm_provider() -> LLMProvider:
base = OpenAIProvider.from_settings(settings) # La llamada real
# Aplicar en el orden correcto (de afuera hacia adentro):
with_retry = RetryProvider(base, max_attempts=3) # Innermost
with_cb = CircuitBreakerProvider(with_retry, threshold=5) # Wraps retry
with_fallback = FallbackProvider(with_cb, fallback_model=secondary) # Outermost
return with_fallback
# Flujo de una request:
# FallbackProvider.complete()
# → CircuitBreakerProvider.complete()
# → [Circuit CLOSED: pasa]
# → RetryProvider.complete()
# → Intento 1: TimeoutError → esperar 1s
# → Intento 2: TimeoutError → esperar 2s
# → Intento 3: success → retornar
# → [Después de 5 failures del circuit, abre]
# → FallbackProvider detecta CircuitOpenError
# → Intenta secondary_provider
# → Secondary funciona → retornar con flag degraded=True
Prerequisitos del módulo
# Instalar las dependencias de reliability
pip install tenacity # Retry con backoff
pip install pybreaker # Circuit breaker (opcional, también hay implementación custom)
Roadmap del módulo
| # | Cápsula | Pattern | Qué hace |
|---|---|---|---|
| 01 | Introducción | — | Los 5 patterns y composición |
| 02 | Error Handling LLM APIs | Base | Clasificar y manejar cada tipo de error |
| 03 | Retry y backoff | Retry | tenacity con exponential backoff + jitter |
| 04 | Circuit Breakers | Circuit | Estados, thresholds, auto-recovery |
| 05 | Rate Limiting | Rate | Token bucket, throttling, burst control |
| 06 | Fallbacks y Health Checks | Fallback | Fallback chain + health endpoints reales |
| 07 | Proyecto Reliability Layer | Todos | Integración completa con clean architecture |
| 08 | Resumen y Troubleshooting | — | Production checklist |
Ejercicios
Ejercicio 1: Clasifica los failure modes
Para cada escenario, indica el pattern correcto:
- OpenAI devuelve 429 después de un spike de tráfico
- La app lleva 10 minutos recibiendo errors 500 de OpenAI
- 200 usuarios hacen requests simultáneos a un endpoint con límite de 50/min
- El LLM devuelve JSON malformado en el 2% de los requests
Ver solución
- 429 después de spike → Retry con exponential backoff (error transitorio que se resuelve solo)
- 10 minutos de errors 500 → Circuit Breaker (error persistente, dejar de intentar, abrir el circuit)
- 200 usuarios, límite 50/min → Rate Limiting (throttling para no superar el límite)
- JSON malformado → Fallback (error de parsing → respuesta por defecto + log para investigar)
Ejercicio 2: Diseñar la composición
Para una app con estas características:
- Límite de OpenAI: 100 RPM
- Se espera que OpenAI tenga ~99.5% uptime (outage ~4h/mes)
- El LLM tiene ~1% de respuestas malformadas
- Tienes un modelo de backup más barato
Diseña la cadena de composición de providers:
Ver guía
# De inner a outer:
primary = OpenAIProvider(model="gpt-4o", ...)
with_retry = RetryProvider(primary, max_attempts=3) # Para errores transitorios
with_cb = CircuitBreakerProvider(with_retry, failure_threshold=5, recovery_timeout=60)
# Para los outages de 4h
rate_limiter = RateLimitedProvider(with_cb, rpm=80) # 80% del límite de 100 RPM
fallback = FallbackProvider(
rate_limiter,
secondary=OpenAIProvider(model="gpt-4o-mini", ...), # Backup más barato
static_default={"sentiment": "unknown", "score": 0.0, "confidence": 0.0}
)
Ejercicio 3: Identificar el pattern incorrecto
En cada caso, alguien eligió el pattern equivocado. Explica por qué está mal y cuál usarías tú:
- Un dev puso retry con 10 intentos para un
AuthenticationError(401) - Un dev puso circuit breaker con
failure_threshold=1para un LLM de análisis de sentimiento - Un dev eliminó el rate limiter porque "ya OpenAI me limita"
Ver solución
- Retry para AuthError: un 401 significa que tu API key está mal. No importa cuántas veces lo reintentes — va a dar el mismo error. La acción correcta es no retry + alerta de config (log CRITICAL).
- Threshold=1: un solo failure abre el circuit. Esto causa falsos positivos constantes — cualquier timeout aislado va a activar el circuit. Threshold recomendado: 5-7 para servicios no críticos.
- Sin rate limiter: que OpenAI te limite significa que recibes 429s. Esos 429s activan retries, que consumen más recursos. El rate limiter del cliente previene que envíes requests que sabes que van a fallar. Te ahorras latencia, costo y carga en tu servidor.
Ejercicio 4: Calcular el impacto de un outage
Tu app tiene 50 usuarios/minuto. OpenAI tiene un outage de 15 minutos. Calcula para cada escenario:
| Métrica | Sin reliability | Con retry (3 intentos) | Con retry + circuit breaker |
|---|---|---|---|
| Requests a OpenAI | ? | ? | ? |
| Tiempo de respuesta promedio | ? | ? | ? |
| Usuarios que ven error | ? | ? | ? |
Ver solución
| Métrica | Sin reliability | Con retry (3 intentos) | Con retry + CB (threshold=5) |
|---|---|---|---|
| Requests a OpenAI | 50 × 15 = 750 | 750 × 3 = 2,250 | ~15 (los primeros 5 fallos) + ~15 probes |
| Tiempo de respuesta | ~30s (timeout) | ~45s (3 timeouts) | <1ms (circuit open, fallback) |
| Usuarios con error | 750 (todos) | 750 (todos, más lento) | ~5 (los que triggerearon el circuit) |
El retry sin circuit breaker empeora la situación: triplica los requests fallidos y la latencia. El circuit breaker detiene el sangrado después de los primeros 5 failures.
Troubleshooting
"No sé si mi error es transitorio o permanente"
Aplica esta regla: si envías el mismo request 5 minutos después y podría funcionar → es transitorio. Si envías el mismo request 100 veces y siempre falla → es permanente. Los timeouts y 429s son transitorios; los 401 y 400 son permanentes.
"¿Necesito implementar todos los patterns?"
No necesariamente todos desde el día 1. La prioridad mínima para producción es:
- Error classification (siempre — sin esto no puedes decidir nada)
- Retry con backoff (casi siempre — protege contra transitorios)
- Health checks (siempre — Kubernetes los necesita)
- Circuit breaker (cuando esperas outages frecuentes)
- Rate limiting + Fallback (cuando tienes tráfico alto o modelos de backup)
"¿Cómo pruebo todo esto si no puedo forzar errores de OpenAI?"
Usa mocks. El MockProvider del M6 te permite simular cualquier error:
from src.infrastructure.llm_provider import LLMProviderError
from src.infrastructure.error_classifier import ErrorCategory
def create_failing_provider(error_type: ErrorCategory):
"""Crea un provider que falla con el tipo de error que necesitas testar."""
mock = MockProvider()
original = mock.complete
def failing(messages, **kwargs):
raise LLMProviderError(
"Simulated error",
category=error_type,
should_retry=(error_type == ErrorCategory.TRANSIENT)
)
mock.complete = failing
return mock
"¿El orden de composición importa?"
Sí, importa mucho. El orden correcto es: FallbackProvider(RateLimiter(CircuitBreaker(Retry(Base)))). Si inviertes el order de circuit breaker y retry, el retry anula el beneficio del circuit — sigue reintentando aunque el servicio esté caído. La cápsula 04 explica esto en detalle.
Resumen
- Failure is not if, but when: los patterns no previenen fallos, los manejan
- 5 patterns clave: retry (transitorios), circuit breaker (persistentes), rate limiting (throttling), fallback (degradación controlada), health checks (observabilidad)
- La DI del M6 es la clave: wrappear providers sin tocar domain ni processing
- Composición: los patterns se apilan, cada uno maneja su tipo de fallo
Recursos adicionales
- Release It! (Michael Nygard) — El libro de referencia para reliability patterns
- tenacity Documentation — La librería de retry
- Circuit Breaker Pattern (Martin Fowler) — El patrón explicado
- OpenAI Rate Limits — Los límites reales
- Designing Data-Intensive Applications (Kleppmann) — Reliability en sistemas distribuidos