Módulo 7: Reliability Patterns & Production Checklist
3. Retry con Exponential Backoff
Descripción
Retry sin estrategia es peor que no retry: si OpenAI está saturado y tu app manda 100 requests simultáneos que fallan, retryearlos inmediatamente solo empeora el problema. Exponential backoff + jitter es la estrategia que convierte el problema de "thundering herd" en una distribución controlada de reintentos. En esta cápsula vas a implementar retry completo con tenacity, incluyendo callbacks de logging, soporte para el header Retry-After, y tests que verifican el comportamiento sin depender de delays reales.
El problema del retry ingenuo
# ❌ Retry inmediato — el anti-pattern
def call_llm_naive(prompt: str) -> str:
for attempt in range(3):
try:
return client.chat.completions.create(...)
except RateLimitError:
if attempt < 2:
continue # ← Retry INMEDIATO
raise
# Escenario: 50 usuarios hacen requests al mismo tiempo
# OpenAI devuelve 429 para todos
# Todos hacen retry INMEDIATO
# OpenAI recibe 150 requests (50 × 3) en milisegundos
# Todos fallan con 429 de nuevo
# Los retries empeoran el problema
# Resultado: todos los usuarios ven error 500
# ─────────────────────────────────────────────────────────────
# ✅ Exponential backoff + jitter — la solución
# Escenario: 50 usuarios hacen requests al mismo tiempo
# Todos reciben 429
# Retry 1: esperar 1.0s + random(0, 0.5)s → distintos momentos
# Retry 2: esperar 2.0s + random(0, 1.0)s → distribuido
# Retry 3: esperar 4.0s + random(0, 2.0)s → bien distribuido
# Los 50 retries se distribuyen en ~10 segundos en vez de milisegundos
# OpenAI procesa gradualmente, la mayoría completa
tenacity: la librería de retry
pip install tenacity
# Los building blocks de tenacity
from tenacity import (
retry, # Decorator principal
stop_after_attempt, # Máximo N intentos
stop_after_delay, # Máximo N segundos totales
wait_exponential, # Espera exponencial: 1s, 2s, 4s, 8s...
wait_random_exponential, # Exponencial + jitter: 1.2s, 2.7s, 4.1s...
wait_fixed, # Espera fija: siempre N segundos
retry_if_exception_type, # Retry solo para ciertos tipos de excepción
retry_if_exception, # Retry si la función retorna True para el error
before_sleep, # Callback ANTES de dormir (logging)
after, # Callback DESPUÉS de cada intento
RetryError, # Excepción si todos los intentos fallan
)
Configuración básica con tenacity
# src/infrastructure/retry_config.py
from openai import APITimeoutError, RateLimitError, APIConnectionError, InternalServerError
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception
def is_retryable_openai_error(exception: Exception) -> bool:
"""
Determina si una excepción de OpenAI merece ser reintentada.
Transitorios → retry:
- APITimeoutError: el modelo tardó mucho, puede funcionar de nuevo
- RateLimitError: el límite se libera con el tiempo
- APIConnectionError: la red falló momentáneamente
- InternalServerError (5xx): error del servidor, puede recuperarse
Permanentes → no retry:
- AuthenticationError (401): la key está mal, retry no ayuda
- BadRequestError (400): el input está mal, retry dará el mismo error
- PermissionDeniedError (403): sin acceso, retry no ayuda
"""
from openai import AuthenticationError, BadRequestError, PermissionDeniedError
# No retry para errores permanentes
if isinstance(exception, (AuthenticationError, BadRequestError, PermissionDeniedError)):
return False
# Retry para transitorios conocidos
if isinstance(exception, (APITimeoutError, RateLimitError,
APIConnectionError, InternalServerError)):
return True
# Para LLMProviderError, usar la clasificación integrada
from src.infrastructure.llm_provider import LLMProviderError
if isinstance(exception, LLMProviderError):
return exception.should_retry
# Para errores desconocidos, no retry por defecto (seguro)
return False
# Configuración estándar para la mayoría de apps AI
STANDARD_RETRY = retry(
# Parar después de 4 intentos (1 original + 3 retries)
stop=stop_after_attempt(4),
# Exponential backoff con jitter:
# Intento 1: 1s base + random(0, 1)s
# Intento 2: 2s base + random(0, 1)s
# Intento 3: 4s base + random(0, 2)s
# Máximo 30s de espera
wait=wait_random_exponential(multiplier=1, min=1, max=30),
# Solo retry errores transitorios
retry=retry_if_exception(is_retryable_openai_error),
# Si todos los intentos fallan, propagar la excepción original
reraise=True
)
# Configuración más agresiva para operaciones críticas
AGGRESSIVE_RETRY = retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=2, min=2, max=60),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
# Configuración ligera para operaciones de baja prioridad
LIGHT_RETRY = retry(
stop=stop_after_attempt(2), # Solo 1 retry
wait=wait_random_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
RetryProvider: wrapping del LLMProvider
# src/infrastructure/retry_provider.py
import structlog
from tenacity import retry, RetryError, before_sleep_log
from src.infrastructure.llm_provider import LLMProvider, LLMProviderError
from src.infrastructure.retry_config import is_retryable_openai_error, STANDARD_RETRY
log = structlog.get_logger()
class RetryProvider:
"""
Wrapper que añade retry con exponential backoff a cualquier LLMProvider.
Implementa el LLMProvider Protocol, por lo que es completamente
transparente para el código que lo usa.
"""
def __init__(
self,
inner: LLMProvider,
max_attempts: int = 4,
min_wait_seconds: float = 1.0,
max_wait_seconds: float = 30.0
):
self._inner = inner
self._max_attempts = max_attempts
self._min_wait = min_wait_seconds
self._max_wait = max_wait_seconds
self._attempt_count = 0 # Para métricas
self._retry_count = 0
def complete(self, messages: list[dict], **kwargs) -> str:
"""
Llama al inner provider con retry automático para errores transitorios.
"""
self._attempt_count += 1
attempt_number = 0
last_error = None
# Crear el decorator de retry dinámicamente con los parámetros configurados
from tenacity import (
retry as tenacity_retry, stop_after_attempt,
wait_random_exponential, retry_if_exception
)
@tenacity_retry(
stop=stop_after_attempt(self._max_attempts),
wait=wait_random_exponential(
multiplier=1,
min=self._min_wait,
max=self._max_wait
),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True,
before_sleep=self._log_retry_attempt
)
def _call_with_retry():
return self._inner.complete(messages, **kwargs)
return _call_with_retry()
def _log_retry_attempt(self, retry_state) -> None:
"""Callback que se ejecuta antes de cada sleep entre retries."""
self._retry_count += 1
log.warning(
"llm_retry_attempt",
attempt_number=retry_state.attempt_number,
exception_type=type(retry_state.outcome.exception()).__name__,
sleep_seconds=round(retry_state.next_action.sleep, 1),
total_retries=self._retry_count
)
@property
def total_retries(self) -> int:
"""Total de reintentos realizados (útil para métricas)."""
return self._retry_count
Usar tenacity como decorator directamente
# Si prefieres decorator en lugar de wrapper class:
from openai import APITimeoutError, RateLimitError, APIConnectionError
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
import structlog
log = structlog.get_logger()
def log_retry(retry_state):
log.warning(
"llm_retry",
attempt=retry_state.attempt_number,
error=str(retry_state.outcome.exception())[:100],
wait_seconds=round(getattr(retry_state.next_action, 'sleep', 0), 1)
)
@retry(
stop=stop_after_attempt(4),
wait=wait_random_exponential(multiplier=1, min=1, max=30),
retry=retry_if_exception_type((APITimeoutError, RateLimitError, APIConnectionError)),
reraise=True,
before_sleep=log_retry
)
def call_openai_with_retry(client, messages: list[dict], model: str,
temperature: float, max_tokens: int) -> str:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens
)
return response.choices[0].message.content
El efecto del jitter visualizado
# ¿Por qué jitter? Visualización del problema:
# SIN jitter — thundering herd:
# t=0: 50 requests fallan
# t=1s: 50 retries simultáneos fallan
# t=2s: 50 retries simultáneos fallan
# t=4s: 50 retries simultáneos fallan
# Todos los reintentos colisionan en los mismos momentos
# CON jitter — distribución natural:
# t=0: 50 requests fallan
# t=1.0s: 3 reintentos
# t=1.1s: 7 reintentos
# t=1.3s: 12 reintentos
# t=1.6s: 15 reintentos
# t=2.0s: 8 reintentos
# t=2.1s: 5 reintentos
# Los reintentos se distribuyen, reduciendo la presión sobre el API
import random
import time
def exponential_backoff_with_jitter(attempt: int, base: float = 1.0, max_wait: float = 30.0) -> float:
"""Calcula el tiempo de espera con jitter."""
exponential = base * (2 ** attempt) # 1, 2, 4, 8, 16...
with_cap = min(exponential, max_wait) # No más de max_wait
with_jitter = with_cap * (0.5 + random.random() * 0.5) # ±50% random
return with_jitter
# Ejemplo de tiempos generados para 3 reintentos:
for attempt in range(3):
wait_time = exponential_backoff_with_jitter(attempt)
print(f"Intento {attempt+1}: esperar {wait_time:.2f}s")
# Intento 1: esperar 0.73s
# Intento 2: esperar 1.87s
# Intento 3: esperar 3.14s
# (diferente cada vez por el random)
Respetando el Retry-After header
# Cuando OpenAI devuelve 429, a veces incluye Retry-After
# Deberíamos respetar ese tiempo en vez de nuestro backoff
from tenacity import wait_base
import time
class WaitWithRetryAfter(wait_base):
"""
Custom wait strategy para tenacity que respeta el header Retry-After.
Si el error tiene un retry_after, espera ese tiempo.
Si no, usa exponential backoff con jitter.
"""
def __init__(self, multiplier: float = 1.0, min_wait: float = 1.0, max_wait: float = 60.0):
self._multiplier = multiplier
self._min = min_wait
self._max = max_wait
def __call__(self, retry_state) -> float:
exc = retry_state.outcome.exception()
# Intentar obtener el Retry-After del error
if hasattr(exc, "retry_after") and exc.retry_after:
return min(exc.retry_after, self._max)
# También intentar en LLMProviderError
from src.infrastructure.llm_provider import LLMProviderError
if isinstance(exc, LLMProviderError) and exc.retry_after:
return min(exc.retry_after, self._max)
# Fallback a exponential backoff
attempt = retry_state.attempt_number - 1 # 0-indexed
exponential = self._multiplier * (2 ** attempt)
with_cap = min(exponential, self._max)
return max(self._min, with_cap * (0.5 + 0.5 * __import__("random").random()))
# Uso:
@retry(
stop=stop_after_attempt(4),
wait=WaitWithRetryAfter(multiplier=1.0, min_wait=1.0, max_wait=60.0),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
def call_with_retry_after_support(messages):
return client.chat.completions.create(...)
Tests del retry
# tests/unit/test_retry_provider.py
import pytest
from unittest.mock import MagicMock, patch
from openai import APITimeoutError, AuthenticationError
from src.infrastructure.retry_provider import RetryProvider
from src.infrastructure.mock_provider import MockProvider
from src.infrastructure.llm_provider import LLMProviderError
from src.infrastructure.error_classifier import ErrorCategory
class TestRetryProvider:
def test_succeeds_on_first_attempt_without_retry(self):
"""Si la primera llamada funciona, no hay retries."""
inner = MockProvider('{"sentiment": "positive", "score": 0.8, "confidence": 0.9}')
retry_provider = RetryProvider(inner, max_attempts=3)
result = retry_provider.complete([{"role": "user", "content": "test"}])
assert '{"sentiment"' in result
assert inner.call_count == 1
assert retry_provider.total_retries == 0
def test_retries_on_transient_error_then_succeeds(self):
"""Si falla 2 veces y luego funciona, debe tener 3 llamadas."""
transient_error = LLMProviderError(
"Timeout",
category=ErrorCategory.TRANSIENT,
should_retry=True
)
inner = MockProvider(
responses=["error", "error", '{"sentiment": "positive", "score": 0.8, "confidence": 0.9}'],
raise_error=None
)
call_count = 0
original_complete = inner.complete
def tracked_complete(messages, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2:
raise transient_error
return original_complete(messages, **kwargs)
inner.complete = tracked_complete
retry_provider = RetryProvider(inner, max_attempts=4, min_wait_seconds=0.01, max_wait_seconds=0.1)
result = retry_provider.complete([{"role": "user", "content": "test"}])
assert call_count == 3
def test_reraises_after_max_attempts(self):
"""Si falla max_attempts veces, debe propagar la excepción."""
error = LLMProviderError("Rate limit", category=ErrorCategory.TRANSIENT, should_retry=True)
call_count = 0
def failing_complete(messages, **kwargs):
nonlocal call_count
call_count += 1
raise error
inner = MockProvider()
inner.complete = failing_complete
retry_provider = RetryProvider(inner, max_attempts=3, min_wait_seconds=0.01, max_wait_seconds=0.1)
with pytest.raises(LLMProviderError):
retry_provider.complete([{"role": "user", "content": "test"}])
assert call_count == 3 # Exactamente max_attempts intentos
def test_does_not_retry_non_retryable_error(self):
"""Para errores no retryables, solo intenta una vez."""
error = LLMProviderError(
"Invalid API key",
category=ErrorCategory.AUTH_ERROR,
should_retry=False
)
call_count = 0
def auth_failing(messages, **kwargs):
nonlocal call_count
call_count += 1
raise error
inner = MockProvider()
inner.complete = auth_failing
retry_provider = RetryProvider(inner, max_attempts=3, min_wait_seconds=0.01, max_wait_seconds=0.1)
with pytest.raises(LLMProviderError):
retry_provider.complete([{"role": "user", "content": "test"}])
assert call_count == 1 # Solo 1 intento — no retry para auth errors
Ejercicios
Ejercicio 1: Calcular tiempos de espera
Para una configuración con wait_exponential(multiplier=1, min=1, max=30), ¿cuáles serían los tiempos de espera entre reintentos? (sin jitter)
Ver solución
- Retry 1: min(1 × 2^0, 30) = min(1, 30) = 1s
- Retry 2: min(1 × 2^1, 30) = min(2, 30) = 2s
- Retry 3: min(1 × 2^2, 30) = min(4, 30) = 4s
- Retry 4: min(1 × 2^3, 30) = min(8, 30) = 8s
- Retry 5: min(1 × 2^4, 30) = min(16, 30) = 16s
- Retry 6+: min(1 × 2^5, 30) = min(32, 30) = 30s (capped)
Total si todos fallan: 1+2+4+8 = 15s mínimo entre 4 intentos
Ejercicio 2: Añadir logging al retry
Modifica la configuración de tenacity para que logee cada intento con: número de intento, tipo de error, y segundos de espera:
Ver solución
import structlog
log = structlog.get_logger()
def log_before_retry(retry_state):
log.warning(
"llm_retry_attempt",
attempt=retry_state.attempt_number,
error_type=type(retry_state.outcome.exception()).__name__,
sleep_seconds=round(getattr(retry_state.next_action, 'sleep', 0), 1)
)
@retry(
stop=stop_after_attempt(4),
wait=wait_random_exponential(min=1, max=30),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True,
before_sleep=log_before_retry # ← callback
)
def my_llm_call(messages):
...
Ejercicio 3: Configurar retry para diferentes escenarios
Escribe la configuración de tenacity para estos dos casos:
- Operación de pago — si falla, el usuario pierde la transacción. Necesitas ser agresivo con retries.
- Pre-carga de cache — si falla, no pasa nada grave. Puede reintentar una vez y seguir.
Ver solución
# 1. Operación de pago (agresivo)
PAYMENT_RETRY = retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=2, min=2, max=60),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
# 2. Pre-carga de cache (ligero)
CACHE_RETRY = retry(
stop=stop_after_attempt(2),
wait=wait_random_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
La diferencia clave: más intentos + espera más larga para operaciones críticas. Menos intentos + espera corta para operaciones que no bloquean al usuario.
Ejercicio 4: Escribir test de retry sin delays reales
Escribe un test que verifique que RetryProvider hace exactamente 3 intentos cuando el inner provider falla con un error transitorio, sin esperar los delays reales:
Ver solución
def test_retries_exactly_max_attempts():
"""Usa min_wait y max_wait bajos para que los tests sean rápidos."""
call_count = 0
error = LLMProviderError(
"Timeout", category=ErrorCategory.TRANSIENT, should_retry=True
)
def always_fail(messages, **kwargs):
nonlocal call_count
call_count += 1
raise error
inner = MockProvider()
inner.complete = always_fail
# min_wait=0.01 y max_wait=0.01 eliminan los delays reales
provider = RetryProvider(inner, max_attempts=3,
min_wait_seconds=0.01, max_wait_seconds=0.01)
with pytest.raises(LLMProviderError):
provider.complete([{"role": "user", "content": "test"}])
assert call_count == 3
El truco es usar min_wait_seconds=0.01 para que los tests corran en milisegundos en vez de segundos.
Troubleshooting
"Mis retries tardan demasiado en tests"
Configura min_wait_seconds=0.01 y max_wait_seconds=0.1 en tus tests. Nunca uses los valores de producción (1-30s) en tests unitarios — harían que tu suite tarde minutos.
"tenacity no reintenta mi error"
Verifica que tu función is_retryable_openai_error retorna True para ese error. El error más común es pasar una excepción envuelta: si RetryProvider recibe un LLMProviderError pero is_retryable_openai_error solo checa las excepciones de OpenAI directamente, no matcheará. Asegúrate de que también checa LLMProviderError.should_retry.
"Obtengo RetryError en vez de la excepción original"
Si usas reraise=False (el default de tenacity), la excepción que obtienes es tenacity.RetryError, no la original. Siempre usa reraise=True para propagar la excepción original después de agotar los intentos.
"¿Cómo sé si el retry está funcionando en producción?"
Busca en tus logs el evento llm_retry_attempt. Si no lo ves nunca, o tu sistema funciona bien y no hay errores transitorios, o tu callback before_sleep no está configurado. Si lo ves demasiado seguido, tu API puede tener un problema persistente y necesitas un circuit breaker (cápsula 04).
Resumen
- Retry sin backoff es perjudicial: agrava el problema de rate limiting
- Exponential backoff: cada intento espera el doble del anterior
- Jitter: añade aleatoriedad para evitar thundering herd
- Solo errores transitorios: no retry para auth errors, bad requests, etc.
- Retry-After: si la API indica cuándo reintentar, respetarlo
- RetryProvider wrapper: transparente para el domain gracias a la DI del M6
- 3-4 intentos: más intentos = más latencia percibida por el usuario
Recursos adicionales
- tenacity Documentation — La librería completa
- Exponential Backoff and Jitter (AWS) — El artículo canónico sobre jitter
- OpenAI Rate Limits — Guía oficial de retry
- Thundering Herd Problem — El problema que jitter resuelve