StackPractices
intermediate Por Mathias Paulenko

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.

Temas: devops

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.
Secuencia de reintentos — la primera llamada falla con 503, se espera ~1s más jitter, la segunda falla, se espera ~2s más jitter, la tercera tiene éxito.

Variantes

EstrategiaFórmula de BackoffIdeal Para
Fijodelay constanteIntervalos predecibles, debugging
Linealdelay * intentoCarga moderada sobre servicios en recuperación
Exponencialdelay * 2^intentoMás común, buen balance entre persistencia y carga
Exponencial + Jitterrandom * exponencialAPIs de producción, previene thundering herd
Jitter decorrelacionadomin(cap, rand(base, prev * 3))APIs con mucha contención, clientes estilo AWS
Circuit BreakerFail-fast tras N erroresProtección contra fallos en cascada

Lo que funciona

  1. Añade siempre jitter en producción. Los reintentos coordinados de miles de clientes pueden tumbar un servicio que acaba de recuperarse.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Reintentar de inmediato (delay 0) y amplificar la carga sobre un servicio que ya está bajo presión.
  2. Reintentar operaciones no idempotentes — pagos o decrementos de inventario — sin claves de deduplicación.
  3. Dejar el delay máximo sin acotar: una petición puede pasarse minutos en espera antes de que el usuario vea un error.
  4. Reintentar cualquier excepción sin filtrar, incluidos errores 4xx del cliente que nunca van a tener éxito.
  5. 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

  1. Prefiere reintentos asíncronos para trabajo de E/S. Un hilo bloqueado en time.sleep ocupa 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))
  1. 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
  1. 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

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.