El error de Node.js que puede disparar tu factura de AWS: bucles sin backoff

Hay un error que aparece en producción de madrugada y que no siempre salta en los tests: un bucle de reintentos sin espera entre intento e intento. En Node.js el problema se amplifica porque el event loop es brutalmente eficiente, y lo que en otro entorno sería un par de reintentos de más, aquí pueden ser miles de peticiones en segundos. El resultado llega en forma de factura de AWS al día siguiente.

El bug: reintentar sin parar

El código problemático suele tener este aspecto:

async function procesarMensaje(mensaje) {
  let intentos = 0;
  while (intentos < 10) {
    try {
      await enviarASQS(mensaje);
      break;
    } catch (err) {
      intentos++;
      // no hay await, no hay espera, vuelve al principio inmediatamente
    }
  }
}

La lógica parece razonable a primera vista: si falla, reintenta hasta diez veces. El problema es que no hay ninguna pausa entre intentos. Si enviarASQS falla, el bucle vuelve a empezar en microsegundos. Con diez mensajes en paralelo, tienes cien peticiones fallidas en décimas de segundo. Con cien mensajes concurrentes, son mil peticiones. Y si el servicio de AWS está degradado y todas fallan, el número crece de forma exponencial mientras dura la incidencia.

Los precios de AWS por petición no parecen altos hasta que haces los cálculos a escala:

  • SQS: 0,40 $ por millón de peticiones
  • API Gateway: 3,50 $ por millón de llamadas
  • DynamoDB: 1,25 $ por millón de lecturas

A un millón de peticiones por hora (perfectamente posible con un bucle descontrolado) estás gastando entre 0,40 $ y 3,50 $ por hora, multiplicado por las horas que tarde alguien en detectarlo. Si ocurre un viernes por la tarde, puedes encontrarte con una sorpresa el lunes.

Por qué en Node.js duele más

El event loop de Node.js está diseñado para manejar miles de operaciones asíncronas concurrentes con un solo hilo. Eso es precisamente lo que lo hace tan útil para servidores con muchas conexiones simultáneas. Pero ese mismo diseño convierte un bucle de reintentos sin await en una ametralladora de peticiones.

Cuando una operación asíncrona falla y el código entra en el catch sin ningún await de por medio, Node.js no tiene ningún motivo para ceder el control al event loop. El bucle sigue dando vueltas a velocidad máxima. Los errores de red, además, suelen propagarse rápido: un timeout de conexión puede resolverse en milisegundos si el servidor devuelve el error de inmediato, lo que hace el bucle todavía más agresivo.

En otros entornos con hilos bloqueantes hay un coste inherente al cambio de contexto que frena un poco las cosas. En Node.js no hay ese freno natural.

Qué es el backoff exponencial

La solución estándar es esperar antes de cada reintento, y esperar más cuanto más veces ha fallado. Si el primer reintento espera 1 segundo, el segundo espera 2, el tercero 4, el cuarto 8, y así sucesivamente. El resultado es que los primeros fallos se recuperan rápido y los fallos prolongados no saturan el servicio.

A este patrón se le añade habitualmente jitter: una variación aleatoria sobre el tiempo de espera. Sin jitter, si tienes cien clientes que fallan al mismo tiempo, todos reintentarán exactamente al mismo tiempo en el siguiente intervalo, lo que puede generar otro pico de carga. Con jitter, cada cliente espera un tiempo ligeramente distinto y la carga se distribuye.

Implementación manual en Node.js

Lo primero es una función sleep que devuelve una promesa:

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Con eso, el bucle de reintentos queda así:

async function procesarConBackoff(mensaje) {
  const maxIntentos = 5;
  const baseDelay = 1000;   // 1 segundo
  const maxDelay = 30000;   // 30 segundos como techo

  for (let intento = 0; intento < maxIntentos; intento++) {
    try {
      await enviarASQS(mensaje);
      return; // éxito, salimos
    } catch (err) {
      if (intento === maxIntentos - 1) throw err; // último intento, propagamos el error

      const delay = Math.min(baseDelay * Math.pow(2, intento), maxDelay);
      const jitter = Math.random() * delay;
      const espera = delay + jitter;

      console.log(`Intento ${intento + 1} fallido. Esperando ${Math.round(espera)}ms...`);
      await sleep(espera);
    }
  }
}

El Math.min con maxDelay es importante: sin ese techo, el delay puede crecer hasta valores absurdos tras muchos fallos. Con 30 segundos de máximo, el bucle nunca tarda más de medio minuto entre intentos aunque lleve muchas iteraciones.

Librerías que lo hacen por ti

Si no quieres implementarlo a mano, hay varias opciones maduras:

async-retry

const retry = require('async-retry');

await retry(
  async () => {
    await enviarASQS(mensaje);
  },
  {
    retries: 5,
    factor: 2,
    minTimeout: 1000,
    maxTimeout: 30000,
    onRetry: (err, intento) => {
      console.log(`Reintento ${intento}: ${err.message}`);
    }
  }
);

p-retry

const pRetry = require('p-retry');

await pRetry(() => enviarASQS(mensaje), {
  retries: 5,
  onFailedAttempt: error => {
    console.log(`Intento ${error.attemptNumber} fallido. Quedan ${error.retriesLeft} reintentos.`);
  }
});

Las dos librerías aplican backoff exponencial con jitter por defecto y permiten configurar el número de reintentos, el factor multiplicador y los límites de tiempo.

Por otro lado, el AWS SDK v3 ya incluye backoff automático para errores de throttling (ThrottlingException, ProvisionedThroughputExceededException). Si usas los clientes oficiales de AWS, ya tienes algo de protección incorporada, aunque sigue siendo buena idea añadir tu propia lógica de reintento para errores de red genéricos.

Detectar el problema antes de que ocurra

El backoff protege cuando el código ya está en producción, pero hay maneras de detectar antes que algo va mal:

  • CloudWatch Metrics: configura una alarma que se dispare si el número de peticiones a SQS o DynamoDB supera un umbral por minuto. Si normalmente envías 1.000 mensajes por hora y de repente ves 100.000, algo está fallando en bucle.
  • AWS Cost Explorer: activa las alertas de coste diario. Un umbral razonable es el doble de tu gasto habitual en el servicio.
  • AWS Budgets: configura un presupuesto mensual con alerta por email si el gasto proyectado lo supera. Es gratis para los dos primeros presupuestos activos.
  • Logs en local: un console.log en el catch con el timestamp revela en segundos si el código está reintentando demasiado rápido. Si ves diez entradas en el log con el mismo mensaje y timestamps separados por milisegundos, tienes un problema de backoff.

El circuit breaker: más allá del backoff

El backoff protege al servicio externo de una avalancha de peticiones, pero tiene un límite: si el servicio lleva caído diez minutos, tu aplicación sigue intentándolo cada treinta segundos. El circuit breaker va un paso más allá y detiene las llamadas cuando detecta demasiados fallos consecutivos.

El patrón tiene tres estados:

  • Closed (cerrado): todo normal, las llamadas pasan.
  • Open (abierto): demasiados fallos seguidos, las llamadas se rechazan directamente sin intentar llegar al servicio.
  • Half-Open (semiabierto): tras un tiempo, deja pasar una llamada de prueba. Si tiene éxito, vuelve a Closed. Si falla, vuelve a Open.

En Node.js la librería más usada es opossum:

const CircuitBreaker = require('opossum');

const options = {
  timeout: 3000,          // fallo si tarda más de 3 segundos
  errorThresholdPercentage: 50,  // abre si más del 50% de llamadas fallan
  resetTimeout: 30000     // espera 30 segundos antes de Half-Open
};

const breaker = new CircuitBreaker(enviarASQS, options);

breaker.on('open', () => console.log('Circuit breaker abierto: servicio no disponible'));
breaker.on('halfOpen', () => console.log('Probando si el servicio se ha recuperado'));
breaker.on('close', () => console.log('Circuit breaker cerrado: servicio recuperado'));

await breaker.fire(mensaje);

Combinado con backoff, el circuit breaker hace que tu aplicación falle de forma controlada en lugar de seguir martilleando un servicio caído.

Otros sitios donde pasa lo mismo

El bucle sin backoff no es exclusivo de las llamadas a AWS. Aparece en otros contextos igual de frecuentes:

  • WebSockets: cuando el servidor cae, el cliente intenta reconectar. Sin backoff, genera cientos de intentos de conexión por minuto.
  • Polling: si llamas a una API cada segundo y la petición tarda más de un segundo, las llamadas se solapan y el intervalo real se reduce a cero.
  • Streams: cuando el source de un stream falla, algunos patrones de lectura reintentarán en bucle sin espera.

La regla general es sencilla: cualquier operación que puede fallar y que se reintenta automáticamente necesita algún tipo de espera entre intentos. El cuánto depende del contexto, pero incluso esperar 100ms entre reintentos es infinitamente mejor que no esperar nada.

Si quieres profundizar en cómo Node.js gestiona la concurrencia de base, en JavaScript asíncrono y el event loop tienes una explicación del funcionamiento interno. Y si trabajas con la versión más reciente del entorno, Node.js 26 trae mejoras en networking y HTTP que también afectan a cómo se comportan las conexiones en escenarios de error.

Imagen: Pexels / Pixabay

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP