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:

  1. Operación de pago — si falla, el usuario pierde la transacción. Necesitas ser agresivo con retries.
  2. 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

  1. tenacity Documentation — La librería completa
  2. Exponential Backoff and Jitter (AWS) — El artículo canónico sobre jitter
  3. OpenAI Rate Limits — Guía oficial de retry
  4. Thundering Herd Problem — El problema que jitter resuelve