Colas de reintentos: idempotencia, backoff y dead-letter queues
Cómo funciona una cola de reintentos: idempotencia, backoff exponencial con jitter, dead-letter queues y el patrón outbox para no perder datos.
Equipo menululo · Equipo fundador de menululo, Cali
Publicado el · 7 min de lectura
En este artículo respondemos:
¿Qué es una cola de reintentos y cómo evita que un sistema pierda datos?
Resumen
- Una cola de reintentos guarda el trabajo pendiente y lo vuelve a intentar cuando falla, en lugar de perderlo o bloquear al usuario.
- Reintentar solo es seguro si la operación es idempotente; esperar entre intentos con backoff exponencial y jitter evita empeorar una caída.
- Lo que no se puede procesar va a una dead-letter queue, y el patrón outbox asegura que el trabajo llegue a la cola en primer lugar.
¿Qué es una cola de reintentos?
Es un mecanismo que almacena tareas pendientes (enviar un correo, registrar un pago, sincronizar un cambio) y las reintenta cuando fallan, hasta que se completan o se declaran imposibles.
La idea responde a un hecho básico de los sistemas conectados: la red se corta, los servicios se reinician y las bases de datos rechazan conexiones por unos segundos. Si cada una de esas fallas pasajeras se convierte en un dato perdido, el sistema no es confiable. Con una cola, la tarea queda registrada de forma durable y un proceso trabajador la toma, la ejecuta y solo la marca como terminada cuando recibe confirmación.
Detrás de cualquier sistema que “nunca pierde nada” suele haber cuatro piezas: una cola durable, operaciones idempotentes, una política de reintentos y un lugar para los casos perdidos.
¿Qué errores vale la pena reintentar?
Solo los transitorios: los que tienen una probabilidad razonable de no repetirse en el siguiente intento.
- Transitorios: tiempo de espera agotado, conexión rechazada, servicio no disponible, límite de peticiones excedido. En HTTP, típicamente las respuestas
503o429, que pueden venir con la cabeceraRetry-Afterindicando cuánto esperar, según la especificación HTTP (RFC 9110). - Permanentes: datos inválidos, recurso inexistente, permisos insuficientes. Reintentar un
400o un403no lo arregla: solo gasta recursos y retrasa el diagnóstico.
Clasificar mal es uno de los errores más caros: reintentar errores permanentes llena la cola de trabajo inútil, y no reintentar los transitorios pierde datos.
¿Qué es la idempotencia y por qué va primero?
Una operación es idempotente si ejecutarla varias veces produce el mismo efecto que ejecutarla una. Sin idempotencia, reintentar es peligroso.
El problema clásico: un cliente envía un pago, el servidor lo procesa, pero la respuesta se pierde por un corte de red. El cliente no sabe si el pago se hizo y reintenta. Si la operación no es idempotente, se cobra dos veces.
La RFC 9110 define que, entre los métodos HTTP estándar, PUT, DELETE y los métodos seguros (como GET) son idempotentes. POST no lo es por definición. La solución habitual es que el cliente genere una clave de idempotencia única por operación y la envíe en cada intento. La IETF llegó a trabajar un borrador para estandarizar la cabecera Idempotency-Key, que hoy figura como expirado, pero el patrón se usa ampliamente.
Del lado del servidor, la clave se registra junto con el resultado:
async function procesarPago(req: SolicitudPago, db: Db) {
const previo = await db.idempotencia.get(req.idempotencyKey);
if (previo) return previo.respuesta; // reintento: misma respuesta, sin cobrar otra vez
return db.transaction(async (tx) => {
// La restricción única sobre la clave evita carreras entre dos intentos simultáneos
await tx.idempotencia.insert({ clave: req.idempotencyKey, estado: "en_proceso" });
const respuesta = await tx.pagos.crear(req);
await tx.idempotencia.update(req.idempotencyKey, { estado: "hecho", respuesta });
return respuesta;
});
}
Dos detalles importan: la clave debe tener una restricción única en la base de datos (una verificación previa sin ella no evita que dos intentos simultáneos pasen), y el registro de la clave debe vivir en la misma transacción que el efecto.
¿Qué es el backoff exponencial?
Es una política que aumenta el tiempo de espera entre reintentos de forma multiplicativa (por ejemplo, 1 s, 2 s, 4 s, 8 s) hasta un máximo. Da tiempo al servicio para recuperarse sin abandonar la tarea.
Reintentar de inmediato y sin pausa es contraproducente: si un servicio cayó por sobrecarga, miles de clientes insistiendo a la vez lo mantienen caído. El backoff reduce esa presión con cada fallo.
Un ejemplo concreto y público es el protocolo de backoff de conexión de gRPC, un proyecto de código abierto, que documenta como parámetros una espera inicial de 1 segundo, un multiplicador de 1,6, un máximo de 120 segundos y un jitter de 0,2.
¿Para qué sirve el jitter?
El jitter agrega aleatoriedad a cada espera para que los clientes que fallaron juntos no reintenten juntos.
Sin jitter, si mil clientes pierden la conexión en el mismo segundo, todos reintentan exactamente al segundo 1, luego al 2, luego al 4: oleadas sincronizadas contra un servicio frágil. Con una variación aleatoria, esos intentos se reparten en el tiempo.
function esperaConJitter(intento: number, {
inicialMs = 1000, multiplicador = 1.6, maximoMs = 120_000, jitter = 0.2,
} = {}): number {
const base = Math.min(inicialMs * multiplicador ** intento, maximoMs);
const variacion = base * jitter;
return base + (Math.random() * 2 - 1) * variacion; // base ± 20 %
}
async function conReintentos<T>(fn: () => Promise<T>, maxIntentos = 6): Promise<T> {
for (let intento = 0; ; intento++) {
try {
return await fn();
} catch (e) {
if (!esTransitorio(e) || intento + 1 >= maxIntentos) throw e;
await new Promise((r) => setTimeout(r, esperaConJitter(intento)));
}
}
}
¿Qué es una dead-letter queue?
Es una cola aparte a donde van los mensajes que no se pudieron procesar después de agotar los reintentos. Así un mensaje defectuoso no bloquea a los demás y tampoco se pierde.
El patrón aparece como Dead Letter Channel en el catálogo Enterprise Integration Patterns de Gregor Hohpe y Bobby Woolf, que también recoge el nombre habitual de dead letter queue (DLQ). Buenas prácticas alrededor de ella:
- Guardar el mensaje original, el error y el número de intentos, no solo un contador.
- Alertar cuando la DLQ crece: una cola de mensajes muertos que nadie mira es una forma lenta de perder datos.
- Tener una herramienta para reprocesar mensajes una vez corregida la causa.
¿Qué garantías de entrega existen?
Las tres clásicas son como máximo una vez, al menos una vez y exactamente una vez; en la práctica, los sistemas confiables combinan “al menos una vez” con idempotencia.
- Como máximo una vez: se envía y no se reintenta. Puede perder mensajes.
- Al menos una vez: se reintenta hasta confirmar. No pierde mensajes, pero puede duplicarlos.
- Exactamente una vez: el ideal. En lugar de perseguirlo en la red, se consigue el mismo efecto con entrega al menos una vez y procesamiento idempotente.
¿Cómo asegurar que la tarea llegue a la cola?
Con el patrón de bandeja de salida transaccional (outbox): la tarea se escribe en una tabla de la misma base de datos, dentro de la misma transacción que el cambio de negocio.
El riesgo que resuelve es la “doble escritura”: guardar un pedido en la base y luego publicar un evento en la cola. Si el proceso se cae entre ambos pasos, el pedido existe pero el evento nunca sale. El patrón, documentado en microservices.io, guarda el evento en una tabla outbox junto con el pedido, y un proceso aparte lo publica y lo marca como enviado. La misma idea sirve en el cliente: es la base de las aplicaciones offline-first.
¿Qué errores son los más comunes?
Casi siempre aparecen cuando el sistema está bajo presión, justo cuando más duele.
- Reintentos en varias capas. Si el cliente, el servidor intermedio y la librería de base de datos reintentan tres veces cada uno, una sola petición puede convertirse en 3 × 3 × 3 = 27 intentos contra el servicio caído. Reintenta en una sola capa.
- Reintentos infinitos. Siempre debe haber un tope de intentos o de tiempo total, y un destino final (la DLQ).
- Olvidar la idempotencia en los consumidores, no solo en la API.
- Perder el orden cuando importa. Si dos cambios sobre el mismo registro se reintentan en paralelo, el más viejo puede aplicarse después. Particiona por entidad o usa versiones, un tema que tratamos en algoritmos de sincronización.
- Mezclar tareas de distintos clientes en una misma cola sin límites por cliente, de modo que uno con muchos fallos retrasa a todos. Lo vemos en modelo de datos multi-tenant.
Para cerrar
Un sistema que no pierde nada no es uno que nunca falla, sino uno que falla de forma ordenada: registra el trabajo de forma durable, lo reintenta con paciencia y aleatoriedad, lo hace sin duplicar efectos y aparta lo imposible para que una persona lo revise. Idempotencia primero, backoff con jitter después y una dead-letter queue que alguien mire.
Preguntas frecuentes
¿Qué es la idempotencia en una API?
Una operación es idempotente cuando ejecutarla varias veces deja el servidor en el mismo estado que ejecutarla una sola vez. La especificación HTTP, RFC 9110, define como idempotentes a PUT, DELETE y los métodos seguros. Para POST se suele usar una clave de idempotencia enviada por el cliente.
¿Para qué sirve el jitter en el backoff exponencial?
El jitter agrega una variación aleatoria a cada espera para que muchos clientes que fallaron al mismo tiempo no reintenten todos en el mismo instante. Sin él, los reintentos llegan en oleadas sincronizadas que pueden volver a tumbar al servicio que se estaba recuperando.
¿Qué es una dead-letter queue?
Una dead-letter queue es un canal aparte donde se mueven los mensajes que el sistema no pudo procesar tras agotar sus reintentos. Evita que un mensaje defectuoso bloquee la cola principal y conserva el caso para revisarlo o reprocesarlo después, en lugar de descartarlo en silencio.
Sigue leyendo
- Arquitectura de software 7 min de lectura
Offline-first: cómo diseñar apps que funcionan sin internet
Qué es la arquitectura offline-first, cómo funcionan la base local, la cola de escritura y la sincronización, y cuándo conviene usarla.
- Algoritmos 7 min de lectura
Algoritmos de sincronización: LWW, relojes vectoriales y CRDT
Cómo dos copias de un mismo dato vuelven a estar de acuerdo: last-write-wins, relojes de Lamport, relojes vectoriales y CRDT con ejemplos.
- Arquitectura de software 6 min de lectura
Modelo de datos multi-tenant: base por cliente o compartida
Cómo diseñar la base de datos de un software que atiende a muchos clientes: base por cliente, esquema por cliente o tablas compartidas.