¿Qué es una dead letter queue, y cómo se maneja un 'poison message' que sigue fallando?
Los reintentos infinitos desperdician capacidad del consumidor y los descartes silenciosos pierden datos: una DLQ le da a los mensajes que fallan un número acotado de intentos antes de marcar uno para revisión humana.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es una dead letter queue, y cómo se maneja un 'poison message' que sigue fallando?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La dead letter queue es donde los mensajes van para ser revisados, no para desaparecer
Una dead letter queue (DLQ) es una cola separada que guarda los mensajes que un consumidor no pudo procesar exitosamente después de un número acotado de intentos, en lugar de reintentarlos para siempre o descartarlos en silencio. Existe porque dos resultados son igual de inaceptables: reintentar un mensaje infinitamente desperdicia capacidad del consumidor y, en una cola donde el orden de procesamiento importa, puede bloquear todo lo que viene detrás (head-of-line blocking) mientras el mensaje roto se sigue reentregando; descartarlo tras un solo fallo arriesga perder algo que importaba, sin ningún rastro de por qué falló.
Un poison message (mensaje envenenado) es el caso específico que hace necesaria una DLQ: un mensaje que nunca tendrá éxito sin importar cuántas veces lo reintentes, porque el fallo es determinista — un JSON mal formado, un campo obligatorio ausente, una clave foránea que apunta a un pedido que ya se borró, un bug que cierta forma de payload dispara siempre. Reintentar un poison message es distinto de reintentar un fallo transitorio (un timeout de un servicio downstream, un corte momentáneo de la conexión a la BD) donde un reintento sí tiene una probabilidad real de tener éxito en el segundo o tercer intento. El patrón de DLQ existe para darles sus reintentos a los fallos transitorios mientras corta de raíz a los poison messages antes de que hagan daño.
#Rastrear el número de reintentos es el detalle que sostiene todo
Nada de esto funciona sin una forma de saber, para un mensaje dado, cuántas veces ya se intentó procesarlo — eso es lo que decide si el próximo fallo se gana un reintento o un viaje sin retorno a la DLQ. Cada broker lo maneja distinto:
- SQS lo rastrea automáticamente mediante el atributo de mensaje
ApproximateReceiveCount, y una redrive policy en la cola mueve un mensaje a su DLQ configurada en cuanto ese contador cruza un umbral — sin código de aplicación necesario, aunque igual puedes leer el contador tú mismo para lógica personalizada. - RabbitMQ no tiene un contador de reintentos incorporado, pero un dead-letter-exchange (DLX) combinado con un TTL por mensaje te da una cola de reintento retardado, y tú mismo llevas la cuenta de intentos en un header del mensaje (
x-retry-count), incrementándolo en cada republicación. - Kafka no tiene ningún concepto nativo de DLQ — no existe un ack/nack por mensaje, solo commits de offset— así que el patrón se construye enteramente en código de aplicación: un topic de reintento (o varios, a menudo con retraso creciente) y un topic de dead-letter, cargando el número de reintento como header del registro.
Aquí está la versión de RabbitMQ de punta a punta, incluyendo el header de conteo de reintentos y la decisión de enrutamiento:
// consumer.js — consumidor de RabbitMQ con conteo de reintentos y enrutamiento a DLQconst amqp = require("amqplib");const MAIN_QUEUE = "orders.process";const RETRY_QUEUE = "orders.retry"; // los mensajes esperan aquí un TTL y luego rebotan a MAIN_QUEUEconst DLQ = "orders.dlq";const MAX_RETRIES = 3;const RETRY_DELAY_MS = 5000;async function start() {const connection = await amqp.connect("amqp://localhost");const channel = await connection.createChannel();// Nadie consume directamente de RETRY_QUEUE — los mensajes solo esperan su TTL,// y luego RabbitMQ los dead-letterea de vuelta directo a MAIN_QUEUE.await channel.assertQueue(RETRY_QUEUE, {durable: true,arguments: {"x-dead-letter-exchange": "","x-dead-letter-routing-key": MAIN_QUEUE,"x-message-ttl": RETRY_DELAY_MS,},});await channel.assertQueue(MAIN_QUEUE, { durable: true });await channel.assertQueue(DLQ, { durable: true });channel.prefetch(1);channel.consume(MAIN_QUEUE, async (msg) => {if (!msg) return;const retryCount = msg.properties.headers["x-retry-count"] || 0;try {await processOrder(JSON.parse(msg.content.toString()));channel.ack(msg);} catch (err) {if (isPoisonMessage(err) || retryCount >= MAX_RETRIES) {channel.sendToQueue(DLQ, msg.content, {persistent: true,headers: {"x-retry-count": retryCount,"x-failure-reason": err.message,"x-original-queue": MAIN_QUEUE,},});channel.ack(msg); // se terminó en la cola principal — ya se manejóconsole.error(`pedido movido a DLQ tras ${retryCount} intentos: ${err.message}`);} else {channel.sendToQueue(RETRY_QUEUE, msg.content, {persistent: true,headers: { "x-retry-count": retryCount + 1 },});channel.ack(msg); // se quita de la cola principal; ahora es dueña la de reintentos}}});}function isPoisonMessage(err) {// Los fallos deterministas nunca van a tener éxito al reintentar — directo a la DLQ.return err instanceof SyntaxError || err.code === "INVALID_PAYLOAD";}async function processOrder(order) {// procesamiento real — puede lanzar excepción por datos inválidos o una caída downstream}start();
Dos detalles importan más de lo que parece: el código siempre hace ack del mensaje original, ya sea que lo reintente o lo dead-lettere — el ack lo elimina de MAIN_QUEUE, y una copia nueva toma el relevo en RETRY_QUEUE o en DLQ. Olvidar ese ack deja al original atascado reentregándose para siempre junto a la copia que acabas de crear. Y isPoisonMessage corta camino directo a la DLQ para errores claramente deterministas (JSON inválido, un fallo de validación) en lugar de quemar tres ciclos de reintento en algo que ningún reintento podría arreglar jamás — clasificar los errores como transitorios o permanentes es lo que evita que la cola de reintentos se convierta en una forma lenta y costosa de llegar a la misma DLQ.
#Una DLQ solo sirve si alguien la está mirando
La forma más común en que los equipos se equivocan aquí no es la lógica de enrutamiento — es lo que pasa después de que un mensaje cae en la DLQ. Una cola sin alertas sobre su profundidad acumula en silencio pedidos fallidos, pagos fallidos, entregas de webhook fallidas durante semanas, hasta que alguien nota que los ingresos no cuadran. Trata la profundidad de la DLQ como una métrica de tasa de error: pon alertas sobre ella, revísala con una cadencia regular, y construye (o compra) una herramienta de replay que permita inspeccionar un mensaje dead-letterado, corregir el bug o el dato malo de fondo, y republicarlo de forma segura en la cola principal — "de forma segura" significa que el procesamiento de tu consumidor es idempotente, porque un mensaje reproducido es funcionalmente una reentrega, y vuelves a estar frente al problema de manejo de duplicados de at-least-once sin importar cómo se haya reentregado.
🧠 Check your understanding
0/2 · 0/2 answered1. Un consumidor sigue fallando al procesar un mensaje por un bug permanente al parsear un JSON mal formado. ¿Cuál es la mejor respuesta de diseño?
2. ¿Por qué un consumidor de RabbitMQ debe hacer ack del mensaje original incluso cuando lo está enrutando a una cola de reintento o a la DLQ en lugar de procesarlo con éxito?