Retry con Backoff Exponencial
Cómo implementar lógica de retry resiliente con backoff exponencial y jitter para fallos transitorios en llamadas de red y APIs.
Visión General
Los fallos transitorios —timeouts de red, rate limits, cortes temporales— forman parte de todo sistema distribuido. Reintentar de inmediato empeora las cosas: cada cliente sigue golpeando un servicio que ya está intentando recuperarse. El backoff exponencial separa los reintentos (1s, 2s, 4s, 8s…) y el jitter aleatoriza cada espera para que miles de clientes no reintenten a la vez, el problema del thundering herd. Esta receta construye un decorador de retry configurable en Python, JavaScript y Java, lo conecta con un circuit breaker y protege las escrituras reintentadas con claves de idempotencia.
Cuándo Usar
Recurre a esta receta cuando:
- Llamas a APIs externas que pueden aplicar rate limiting o sufrir cortes temporales. Consulta Call REST API para patrones de clientes API.
- Publicas mensajes en colas o buses de eventos que a veces no están disponibles. Consulta RabbitMQ Task Queue para resiliencia de message brokers.
- Ejecutas operaciones de base de datos que pueden encontrar deadlocks o timeouts de conexión. Consulta Connection Pooling para gestionar conexiones a DB.
- Construyes microservicios que necesitan resiliencia ante la degradación de dependencias externas. Consulta Event-Driven Microservices para arquitectura resiliente.
- No uses reintentos a ciegas en escrituras no idempotentes sin deduplicación: reintentar un POST de pago o un decremento de stock sin clave de idempotencia crea duplicados. Haz la operación idempotente primero.
Solución
Python
import time
import random
from functools import wraps
from typing import Callable, TypeVar, Tuple
T = TypeVar("T")
def retry(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
exceptions: Tuple[type, ...] = (Exception,),
jitter: bool = True,
on_retry: Callable[[Exception, int, float], None] = None
):
def decorator(fn: Callable[..., T]) -> Callable[..., T]:
@wraps(fn)
def wrapper(*args, **kwargs) -> T:
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except exceptions as e:
if attempt == max_retries:
raise
delay = min(base_delay * (2 ** attempt), max_delay)
if jitter:
delay = random.uniform(0, delay)
if on_retry:
on_retry(e, attempt + 1, delay)
time.sleep(delay)
return wrapper
return decorator
# Uso
@retry(max_retries=3, base_delay=1.0, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url: str) -> dict:
import requests
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
# Con clave de idempotencia — se genera una vez y se envía en cada intento
import uuid
idempotency_key = str(uuid.uuid4())
@retry(max_retries=3, exceptions=(ConnectionError, TimeoutError))
def create_order(payload: dict) -> dict:
import requests
response = requests.post(
"https://api.example.com/orders",
json=payload,
headers={"Idempotency-Key": idempotency_key},
timeout=5,
)
response.raise_for_status()
return response.json()
JavaScript
async function retry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 60000,
jitter = true,
shouldRetry = () => true,
onRetry = () => {},
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries || !shouldRetry(error)) {
throw error;
}
let delay = Math.min(baseDelay * (2 ** attempt), maxDelay);
if (jitter) {
delay = Math.random() * delay;
}
onRetry(error, attempt + 1, delay);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// Uso
async function fetchWithRetry(url) {
return retry(
() => fetch(url).then((r) => r.json()),
{
maxRetries: 3,
baseDelay: 1000,
shouldRetry: (err) => err.status >= 500 || err.code === "ETIMEDOUT",
onRetry: (err, attempt, delay) => {
console.warn(`Retry ${attempt} después de ${delay}ms: ${err.message}`);
},
}
);
}
// Estilo decorador con idempotencia
function withRetry(fn, options) {
return (...args) => retry(() => fn(...args), options);
}
Java
import java.time.Duration;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.function.BiConsumer;
import java.util.function.Predicate;
public class RetryExecutor {
private final int maxRetries;
private final Duration baseDelay;
private final Duration maxDelay;
private final boolean jitter;
private final Predicate<Throwable> shouldRetry;
private final BiConsumer<Throwable, Integer> onRetry;
private final Random random = new Random();
public RetryExecutor(int maxRetries, Duration baseDelay, Duration maxDelay,
boolean jitter, Predicate<Throwable> shouldRetry,
BiConsumer<Throwable, Integer> onRetry) {
this.maxRetries = maxRetries;
this.baseDelay = baseDelay;
this.maxDelay = maxDelay;
this.jitter = jitter;
this.shouldRetry = shouldRetry;
this.onRetry = onRetry;
}
public <T> T execute(Callable<T> action) throws Exception {
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
return action.call();
} catch (Exception e) {
Throwable cause = e instanceof java.util.concurrent.ExecutionException
? e.getCause() : e;
if (attempt == maxRetries || !shouldRetry.test(cause)) {
throw e;
}
long delayMs = Math.min(
baseDelay.toMillis() * (1L << attempt),
maxDelay.toMillis()
);
if (jitter) {
delayMs = (long) (random.nextDouble() * delayMs);
}
onRetry.accept(cause, attempt + 1);
Thread.sleep(delayMs);
}
}
throw new IllegalStateException("Unreachable");
}
// Uso
public static void main(String[] args) throws Exception {
RetryExecutor retry = new RetryExecutor(
3, Duration.ofSeconds(1), Duration.ofSeconds(60),
true,
t -> t instanceof java.net.ConnectException,
(err, attempt) -> System.out.println("Retry " + attempt + ": " + err.getMessage())
);
String result = retry.execute(() -> {
// simula una llamada que falla
if (Math.random() < 0.7) throw new java.net.ConnectException("timeout");
return "success";
});
}
}
Explicación
- Backoff exponencial duplica la espera tras cada fallo: 1s → 2s → 4s → 8s. El servicio gana tiempo para recuperarse en lugar de recibir un goteo constante de reintentos.
- Jitter aleatoriza cada espera (
random * delay) para que los clientes que se recuperan a la vez no disparen sus reintentos en sincronía. El «full jitter» elige un punto aleatorio entre 0 y el tope exponencial y reparte mejor la carga; el «jitter decorrelacionado» elige un punto entre el delay base y 3× la espera anterior, que converge más rápido en servicios saturados. - El delay máximo y la deadline total son dos topes distintos. El primero acota una espera individual; la segunda acota toda la operación. Sin deadline, quien llama puede quemar minutos en reintentos cuando el usuario ya se fue.
- Filtrado de excepciones limita los reintentos a errores transitorios (timeouts, 5xx, conexión rechazada). Un 4xx fallará igual en cada intento, así que reintentarlo solo añade latencia.
- Retry-After gana a cualquier fórmula. Cuando un 429 o un 503 trae ese header, espera exactamente lo que indica: el servidor te está diciendo cuándo se recupera.
- Las claves de idempotencia (un UUID enviado como header) permiten al servidor deduplicar los reintentos, de modo que un POST reintentado no crea el registro dos veces.
Variantes
| Estrategia | Fórmula de Backoff | Ideal Para |
|---|---|---|
| Fijo | delay constante | Intervalos predecibles, debugging |
| Lineal | delay * intento | Carga moderada sobre servicios en recuperación |
| Exponencial | delay * 2^intento | Más común, buen balance entre persistencia y carga |
| Exponencial + Jitter | random * exponencial | APIs de producción, previene thundering herd |
| Jitter decorrelacionado | min(cap, rand(base, prev * 3)) | APIs con mucha contención, clientes estilo AWS |
| Circuit Breaker | Fail-fast tras N errores | Protección contra fallos en cascada |
Lo que funciona
- Añade siempre jitter en producción. Los reintentos coordinados de miles de clientes pueden tumbar un servicio que acaba de recuperarse.
- Reintenta solo operaciones idempotentes. Un POST sin clave de idempotencia — o una escritura no transaccional — puede crear duplicados al reintentar. Si la operación no es idempotente, pásala por una cola o deduplícala primero.
- Acota el delay individual y el presupuesto total por separado. Esperar 60s en un reintento es aceptable para un job por lotes y fatal para un checkout: decide el presupuesto en cada punto de llamada.
- Registra cada reintento con el número de intento, el delay calculado y el tipo de excepción. Sin eso, los fallos intermitentes son invisibles hasta que se convierten en caída.
- Combina los reintentos con un circuit breaker. Si una dependencia falla de forma sostenida, abre el circuito durante un tiempo de enfriamiento y falla rápido en lugar de pagar el coste del reintento en cada petición. Ver Circuit Breaker Pattern.
Errores Comunes
- Reintentar de inmediato (delay 0) y amplificar la carga sobre un servicio que ya está bajo presión.
- Reintentar operaciones no idempotentes — pagos o decrementos de inventario — sin claves de deduplicación.
- Dejar el delay máximo sin acotar: una petición puede pasarse minutos en espera antes de que el usuario vea un error.
- Reintentar cualquier excepción sin filtrar, incluidos errores 4xx del cliente que nunca van a tener éxito.
- Omitir el jitter y provocar el efecto thundering herd cuando el servicio se recupera.
Implementaciones Adicionales
Integración con Circuit Breaker
import time
from enum import Enum
from functools import wraps
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=30):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.state = CircuitState.CLOSED
self.last_failure_time = None
def __call__(self, fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("Circuit breaker is OPEN")
try:
result = fn(*args, **kwargs)
if self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.CLOSED
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
raise
return wrapper
# Uso: retry + circuit breaker
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=30)
@retry(max_retries=3, base_delay=1.0)
@cb
def call_external_api():
import requests
return requests.get("https://api.example.com/data").json()
Go con Standard Library
package main
import (
"context"
"fmt"
"math"
"math/rand"
"time"
)
func withRetry(ctx context.Context, fn func() error, maxRetries int, baseDelay time.Duration) error {
for attempt := 0; attempt <= maxRetries; attempt++ {
err := fn()
if err == nil {
return nil
}
if attempt == maxRetries {
return fmt.Errorf("after %d retries: %w", maxRetries, err)
}
delay := float64(baseDelay) * math.Pow(2, float64(attempt))
jitter := rand.Float64() * delay * 0.5
select {
case <-time.After(time.Duration(delay + jitter)):
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
// Uso
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := withRetry(ctx, func() error {
// simula una llamada que falla
return nil
}, 5, time.Second)
fmt.Println("Result:", err)
}
Python con librería Tenacity
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception_type, before_sleep_log
)
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=1, max=60),
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def fetch_with_tenacity(url: str) -> dict:
import requests
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
Tips de Rendimiento
- Prefiere reintentos asíncronos para trabajo de E/S. Un hilo bloqueado en
time.sleepocupa su slot del pool durante todo el backoff:
import asyncio
async def async_retry(fn, max_retries=3, base_delay=1.0):
for attempt in range(max_retries + 1):
try:
return await fn()
except Exception:
if attempt == max_retries:
raise
await asyncio.sleep(base_delay * (2 ** attempt))
- Guarda en caché los resultados. Cuando una llamada reintentada por fin funciona, no tiene sentido pagar el coste del reintento en la siguiente lectura:
from functools import lru_cache
@lru_cache(maxsize=128)
@retry(max_retries=3)
def fetch_config(key: str) -> str:
return requests.get(f"https://config.example.com/{key}").text
- Reutiliza conexiones. Sin pooling, cada reintento paga un handshake TCP y TLS nuevo:
import requests
from requests.adapters import HTTPAdapter
session = requests.Session()
session.mount("https://", HTTPAdapter(pool_connections=10, pool_maxsize=100))
Ver También
- AWS Builder’s Library — Timeouts, retries, and backoff with jitter — la referencia canónica sobre full jitter y jitter decorrelacionado.
- RFC 9110 § Retry-After — el header que debería prevalecer sobre cualquier backoff calculado.
- Google SRE Book — Handling Overload — cómo los reintentos sin límite amplifican las caídas.
- Retry Backoff — resilience4j, Polly, retry budgets y deadlines a nivel arquitectónico.
- Repositorio companion — helpers de retry ejecutables en Python, JavaScript, Java y Go, con tests que verifican la secuencia de backoff mediante un sleep inyectado.
Preguntas frecuentes
¿Debería usar siempre backoff exponencial?
No. Una ronda corta de reintentos fijos (100ms × 3) basta para un servicio interno rápido que falla de vez en cuando. El backoff exponencial con jitter importa cuando muchos clientes pueden reintentar a la vez: APIs públicas, sistemas distribuidos, cualquier cosa con tráfico real.
¿Cuál es la diferencia entre retry y circuit breaker?
El retry repite la misma llamada tras un fallo transitorio por si esta vez funciona. Un circuit breaker corta las llamadas a un servicio que falla una vez superado el umbral de errores: fallas rápido en vez de alimentar la cascada. Se complementan: retry para fallos puntuales, circuit breaker para caídas sostenidas.
¿Debo reintentar ante un HTTP 429?
Sí, pero primero mira si trae el header Retry-After: cuando está presente, espera exactamente lo que indica en lugar de calcular tu propio backoff. Si no está, trata el 429 como un 5xx y aplica backoff exponencial con jitter.
¿Cuántos reintentos son demasiados?
Más allá de 5 reintentos la probabilidad de éxito cae rápido y la latencia sigue creciendo. Para llamadas de cara al usuario, 2-3 reintentos dentro de una deadline total de ~10s es un valor razonable; los jobs en segundo plano pueden permitirse 5+ con un delay máximo más alto.
¿Cómo hago segura una operación no idempotente para reintentar?
Genera una clave de idempotencia única (UUID) antes del primer intento y envíala en cada reintento: el servidor guarda las claves procesadas e ignora los duplicados. Para escrituras en base de datos, usa transacciones con optimistic locking o UPSERT, que son idempotentes por diseño.
Recursos Relacionados
Patrón Circuit Breaker en Python, JavaScript y Java
Previene fallos en cascada deteniendo solicitudes a servicios que están fallando. Un patrón arquitectural para sistemas distribuidos resilientes.
PatternPatrón Retry with Jitter
Cómo retryar failed operations con exponential backoff y random jitter. Cubre full jitter, equal jitter, decorrelated jitter, retry budgets, e idempotency.
RecipeRetry Backoff: Budgets, Deadlines y Config de Librerías
Implementa estrategias de retry resilientes con exponential backoff, jitter e integración de circuit breaker para recuperación de fallas transitorias.
RecipeLógica de Reintento en JavaScript Fetch con Backoff
Reintenta peticiones HTTP fallidas en JavaScript con backoff exponencial
RecipeEndpoints de API Idempotentes
Cómo diseñar e implementar endpoints de API idempotentes que manejen retries, requests duplicados y fallas de red sin efectos secundarios.
RecipeTask Queues y RPC con RabbitMQ y AMQP
Distribuí tareas en segundo plano e implementá patrones request-reply con RabbitMQ usando durable queues, dead-letter exchanges y prefetch.