Saltar al contenido

Políticas de reintento resilientes en sistemas distribuidos

Políticas de reintento que se recuperan de fallos transitorios sin saturar servicios: backoff exponencial, jitter, circuit breakers y presupuestos.

4 min de lectura
Un diagrama de flujo de política de reintento que muestra backoff exponencial con jitter alimentando un circuit breaker que controla las llamadas descendientes

Los reintentos son peligrosos por defecto

Un reintento ingenuo —"si falla, inténtalo de inmediato"— es uno de los patrones más peligrosos en sistemas distribuidos. Cuando un servicio descendiente está sobrecargado, los reintentos de cientos de clientes multiplican la carga, convirtiendo una degradación parcial en una interrupción total. Las buenas políticas de reintento ayudan a los sistemas a recuperarse. Las malas aceleran el fallo.

Backoff exponencial con jitter

El backoff exponencial espacia los reintentos cada vez más con cada intento. El jitter añade aleatoriedad para que los clientes no reintenten en oleadas sincronizadas.

tstypescript
interface RetryConfig {
  maxRetries: number;
  baseDelayMs: number;
  maxDelayMs: number;
  jitterStrategy: "full" | "equal" | "decorrelated";
}
 
function calculateDelay(
  attempt: number,
  config: RetryConfig
): number {
  const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt);
  const capped = Math.min(exponentialDelay, config.maxDelayMs);
 
  switch (config.jitterStrategy) {
    case "full":
      // Random between 0 and capped delay
      return Math.random() * capped;
 
    case "equal":
      // Half fixed, half random
      return capped / 2 + (Math.random() * capped) / 2;
 
    case "decorrelated":
      // Each delay is random between base and 3× the previous delay
      return Math.min(
        config.maxDelayMs,
        config.baseDelayMs + Math.random() * (capped * 3 - config.baseDelayMs)
      );
  }
}
 
// ❌ Immediate retry — hammers the failing service
async function badRetry<T>(fn: () => Promise<T>): Promise<T> {
  for (let i = 0; i < 5; i++) {
    try { return await fn(); }
    catch { continue; }
  }
  throw new Error("All retries failed");
}
 
// ✅ Exponential backoff with jitter
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  config: RetryConfig
): Promise<T> {
  let lastError: Error | undefined;
 
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
 
      if (!isRetryable(error)) throw error;
      if (attempt === config.maxRetries) break;
 
      const delay = calculateDelay(attempt, config);
      await sleep(delay);
    }
  }
 
  throw lastError;
}
 
function isRetryable(error: unknown): boolean {
  if (error instanceof HttpError) {
    // Retry server errors and rate limits, not client errors
    return error.status >= 500 || error.status === 429;
  }
  if (error instanceof NetworkError) return true;
  if (error instanceof TimeoutError) return true;
  return false;
}

Circuit breakers: deja de llamar a lo que está roto

Los reintentos siguen llamando a un servicio que falla. Los circuit breakers dejan de llamarlo por completo durante un periodo de enfriamiento, dándole tiempo al servicio para recuperarse.

tstypescript
type CircuitState = "closed" | "open" | "half-open";
 
class CircuitBreaker {
  private state: CircuitState = "closed";
  private failures: number = 0;
  private lastFailureTime: number = 0;
  private successesSinceHalfOpen: number = 0;
 
  constructor(
    private readonly config: {
      failureThreshold: number;
      resetTimeoutMs: number;
      halfOpenMaxAttempts: number;
    }
  ) {}
 
  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === "open") {
      if (Date.now() - this.lastFailureTime < this.config.resetTimeoutMs) {
        throw new CircuitOpenError("Circuit is open — request rejected");
      }
      // Transition to half-open
      this.state = "half-open";
      this.successesSinceHalfOpen = 0;
    }
 
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
 
  private onSuccess(): void {
    if (this.state === "half-open") {
      this.successesSinceHalfOpen++;
      if (this.successesSinceHalfOpen >= this.config.halfOpenMaxAttempts) {
        this.state = "closed";
        this.failures = 0;
      }
    } else {
      this.failures = 0;
    }
  }
 
  private onFailure(): void {
    this.failures++;
    this.lastFailureTime = Date.now();
 
    if (this.state === "half-open") {
      this.state = "open";
    } else if (this.failures >= this.config.failureThreshold) {
      this.state = "open";
    }
  }
 
  getState(): CircuitState {
    return this.state;
  }
}

Presupuestos de reintento: límites a nivel de sistema

Las configuraciones individuales de reintento no consideran la carga agregada. Un presupuesto de reintento limita el porcentaje total de peticiones que pueden reintentarse, evitando la amplificación a nivel del sistema.

tstypescript
class RetryBudget {
  private requestCount: number = 0;
  private retryCount: number = 0;
  private window: number[] = [];
 
  constructor(
    private readonly config: {
      maxRetryRatio: number;     // e.g., 0.2 = 20% of requests can be retries
      windowMs: number;          // Rolling window size
      minRequestsForBudget: number; // Need this many requests before enforcing
    }
  ) {}
 
  recordRequest(): void {
    this.cleanup();
    this.requestCount++;
    this.window.push(Date.now());
  }
 
  canRetry(): boolean {
    this.cleanup();
 
    if (this.requestCount < this.config.minRequestsForBudget) {
      return true; // Not enough data to enforce budget
    }
 
    const retryRatio = this.retryCount / this.requestCount;
    return retryRatio < this.config.maxRetryRatio;
  }
 
  recordRetry(): void {
    this.retryCount++;
  }
 
  private cleanup(): void {
    const cutoff = Date.now() - this.config.windowMs;
    this.window = this.window.filter((t) => t > cutoff);
    this.requestCount = this.window.length;
  }
}

Composición de estrategias de reintento

Combina reintentos, circuit breakers y presupuestos en un cliente resiliente que maneje fallos en múltiples niveles.

tstypescript
class ResilientHttpClient {
  private circuitBreakers: Map<string, CircuitBreaker> = new Map();
  private retryBudget: RetryBudget;
 
  constructor(
    private readonly retryConfig: RetryConfig,
    budgetConfig: { maxRetryRatio: number; windowMs: number }
  ) {
    this.retryBudget = new RetryBudget({
      ...budgetConfig,
      minRequestsForBudget: 20,
    });
  }
 
  async request<T>(serviceKey: string, fn: () => Promise<T>): Promise<T> {
    const breaker = this.getCircuitBreaker(serviceKey);
    this.retryBudget.recordRequest();
 
    let lastError: Error | undefined;
 
    for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
      try {
        return await breaker.execute(fn);
      } catch (error) {
        lastError = error as Error;
 
        if (error instanceof CircuitOpenError) throw error;
        if (!isRetryable(error)) throw error;
        if (attempt === this.retryConfig.maxRetries) break;
 
        if (!this.retryBudget.canRetry()) {
          throw new RetryBudgetExhaustedError(
            "Retry budget exhausted — too many retries system-wide"
          );
        }
 
        this.retryBudget.recordRetry();
        const delay = calculateDelay(attempt, this.retryConfig);
        await sleep(delay);
      }
    }
 
    throw lastError;
  }
 
  private getCircuitBreaker(key: string): CircuitBreaker {
    if (!this.circuitBreakers.has(key)) {
      this.circuitBreakers.set(
        key,
        new CircuitBreaker({
          failureThreshold: 5,
          resetTimeoutMs: 30_000,
          halfOpenMaxAttempts: 3,
        })
      );
    }
    return this.circuitBreakers.get(key)!;
  }
}

Observabilidad del comportamiento de reintento

Sin métricas, no puedes saber si los reintentos están ayudando o perjudicando. Rastrea las tasas de reintento, las transiciones de estado del circuit breaker y el consumo del presupuesto.

tstypescript
function instrumentRetries(client: ResilientHttpClient, metrics: Metrics) {
  const originalRequest = client.request.bind(client);
 
  client.request = async function <T>(
    serviceKey: string,
    fn: () => Promise<T>
  ): Promise<T> {
    const start = performance.now();
    try {
      const result = await originalRequest(serviceKey, fn);
      metrics.increment("http_request_total", {
        service: serviceKey,
        status: "success",
      });
      return result;
    } catch (error) {
      metrics.increment("http_request_total", {
        service: serviceKey,
        status: "failure",
        error_type: (error as Error).constructor.name,
      });
      throw error;
    } finally {
      metrics.histogram("http_request_duration_ms", performance.now() - start, {
        service: serviceKey,
      });
    }
  };
}

Conclusiones clave

Los reintentos ingenuos amplifican los fallos. El backoff exponencial con jitter espacia los reintentos y evita las avalanchas de tráfico. Clasifica siempre los errores como reintentables o no: nunca reintentes un 400 Bad Request ni un 404 Not Found. Los circuit breakers dejan de llamar a servicios que claramente están rotos, dándoles tiempo para recuperarse.

Los presupuestos de reintento limitan la carga agregada de reintentos en todo el sistema, evitando que configuraciones individuales causen amplificación. Compón estos patrones en un cliente resiliente: reintentos con backoff, controlados por circuit breakers y limitados por presupuestos. Rastrea las tasas de reintento y las transiciones de estado del circuito en tus métricas: si tu tasa de reintento supera el 10% del tráfico total, hay algo mal que va más allá de lo que los reintentos pueden solucionar.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX