Guía10 min

Dead-letter queue en agentes: el mensaje que no debe reintentarse

Resumen

El retry es por llamada; la DLQ es por mensaje cuando el presupuesto de reintentos se agotó o el payload es veneno. maxReceiveCount, DeadLetterReason y redrive humano. Distinto del circuit breaker y de la ejecución durable. AWS SQS, Azure Service Bus, Cloudflare Queues y RabbitMQ DLX.

AWSCloudflare
Cola principal de un agente que desvía mensajes irrecuperables a una cola muerta con alarma

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

El retry asume fallo transitorio. La dead-letter queue asume lo contrario: este mensaje ya no vuelve a la cola caliente hasta que un humano (o un redrive con contrato) lo mire. Reintentar un JSON inválido o un tool_call con schema roto no recupera nada: quema tokens y ensucia el SLO.

No es el circuit breaker (corta una dependencia). La DLQ aísla un mensaje. Tampoco es ejecución durable: el checkpoint reanuda un paso OK; la DLQ guarda el que nunca debió reintentarse.

Contrato: retry con techo. Veneno a DLQ con razón. Cero redrive autónomo. Alarma si la DLQ no está vacía. Retention de la DLQ más larga que la fuente.

Qué es (y qué no es) una DLQ

Amazon SQS: una DLQ es otra cola (misma cuenta y Region) a la que la fuente mueve mensajes no procesados. Sirve para aislar, mirar logs y decidir redrive. Azure Service Bus ya trae la subcola $deadletterqueue: no la creas, no la borras, no le aplica TTL; el mensaje se queda hasta que alguien lo complete.

Cloudflare Queues (docs 2026-04-21): el consumer declara dead_letter_queue y max_retries (default 3). Sin DLQ, el mensaje que agota reintentos se descarta. Si el nombre no existe, Cloudflare crea la cola: inútil sin alarma.

RabbitMQ no tiene “una DLQ”: tiene un Dead Letter Exchange. El mensaje se republica al DLX con nack/requeue=false, TTL, overflow o delivery-limit de quorum. Si expira la cola entera, no hay dead-letter. Si el exchange no existe, RabbitMQ tira en silencio.

SistemaQué es la DLQCuándo entraQué pasa si no hay
SQSOtra cola + redrive policy (maxReceiveCount)Recibir N veces sin borrarEl mensaje se queda (y en FIFO, el orden se rompe si sí hay DLQ)
Service BusSubcola $deadletterqueueMaxDeliveryCountExceeded (default 10), TTL, o DeadLetterMessageAsyncNo aplica: la subcola siempre existe
Cloudflare QueuesOtra cola en el consumerFalla o retryAll()max_retriesSe descarta
RabbitMQExchange normal + routing keynack sin requeue, TTL, overflow, delivery-limitDrop silencioso si falta el DLX

La fila de un agente no es “el modelo se equivocó de tono”. Es: el webhook ya contestó 200, el tool falló 4 veces con el mismo update_id, y el proceso sigue empujando el mismo JSON.

Tres clases de fallo, un destino

Antes de maxReceiveCount, clasifica. Mezclar las tres llena la DLQ de ruido.

ClaseEjemplo en un agenteAcción
Transitorio429, 503, 529, timeout de redRetry con backoff. No es DLQ todavía.
VenenoJSON que no pasa el schema, tool inexistente, firma HMAC inválidaDLQ inmediata con razón. Cero retry.
Presupuesto agotado5xx persistente después del techoDLQ con MaxDeliveryCountExceeded / max_retries

Una clave de idempotencia evita cobrar dos veces el mismo event.id. La DLQ evita reintentar para siempre el evento que no puede procesarse. No se sustituyen.

SQS usa maxReceiveCount. Un 1 manda a DLQ al primer fallo: útil para veneno, suicida para 503. Azure default 10 (MaxDeliveryCountExceeded; no se apaga). Cloudflare default 3. El techo sigue la clase, no “a ver si el modelo cambia de opinión”.

Razón, no solo el payload

Azure escribe DeadLetterReason y DeadLetterDescription (MaxDeliveryCountExceeded, TTLExpiredException, o los tuyos en DeadLetterMessageAsync). Sin razón, la DLQ es un cajón.

En un agente la razón es un enum corto: schema_invalid, signature_invalid, tool_unknown, guardrail_reject, budget_exhausted, dependency_open. El cuerpo es un handle (tenant, trace_id, tool, idempotency_key, hash). Cero prompt, cero PII, cero tool result de 40 KB. Eso vive en trazas. La DLQ guarda el caso.

Cola de redrive con un operador que decide devolver o archivar mensajes

Retention, FIFO y el reloj que no se resetea

SQS standard: el timestamp de enqueue no cambia al mover a la DLQ. Si el mensaje pasó 1 día en la cola fuente y la DLQ retiene 4 días, se borra a los 3. Best practice oficial: retention de la DLQ más larga que la de la fuente. FIFO sí resetea el timestamp al moverse.

FIFO + DLQ: AWS avisa que rompe el orden. Un agente “crear factura → cobrar → enviar recibo” no puede sacar el paso 2 y seguir el 3. Pausa el group id.

Service Bus: la DLQ no observa TTL y no puedes dead-letter desde ella. Sin consumer, el mensaje (y la factura) viven para siempre.

Cloudflare: retention default 4 días, máximo 14 — también en *-dlq. RabbitMQ: policies (dead-letter-exchange) sobre x-arguments; los arguments no se actualizan sin borrar la cola.

Redrive: humano, no el agente

SQS tiene redrive: sacar mensajes de la DLQ y devolverlos a la fuente. Eso no es un loop del agente. Es un runbook:

  1. Alarma si ApproximateNumberOfMessagesVisible (o el equivalente) de la DLQ > 0 durante N minutos.
  2. Agrupa por DeadLetterReason. Un schema_invalid masivo es un deploy malo, no un 503.
  3. Arregla el consumer / el schema / la firma.
  4. Redrive un lote con el mismo idempotency_key. Si el original ya se aplicó, el redrive debe ser no-op.
  5. Nunca redrive automático cada 5 minutos “por si acaso”. Eso es un retry infinito con extra hops.

Un mensaje en DLQ con razón y traza es candidato a caso dorado. No lo borres para “limpiar el dashboard”.

Dónde encaja

  • Retry: por llamada, techo bajo, solo transitorio.
  • Breaker: por dependencia. Open → fail-fast; si no hay presupuesto, DLQ con dependency_open.
  • Kill switch: apaga el proceso; al reanudar, no reinyectes la DLQ a ciegas.
  • Durable: un paso venenoso no se resume; se dead-lettera (failed_poison).
  • SLO: un mensaje en DLQ es un evento bad, no un warning.

Happy path: webhook → cola caliente → worker → (éxito | retry | DLQ). La arquitectura de producción dibuja eso. Esta guía es el desagüe.

Checklist

  1. Hay una DLQ distinta (o subcola) por cola caliente, no una DLQ global de todo el producto.
  2. maxReceiveCount / max_retries / MaxDeliveryCount está escrito y no es 1 “porque así es más simple”.
  3. Veneno (schema, firma, tool unknown) hace dead-letter explícito, no espera al techo.
  4. Cada mensaje muerto tiene reason + trace_id + idempotency_key. Cero prompt en el body.
  5. Retention DLQ > retention fuente (SQS standard). FIFO: decide si el group id se pausa en vez de DLQ.
  6. Alarma de profundidad y de edad. Cloudflare: si no pones dead_letter_queue, asume pérdida.
  7. RabbitMQ: policy, no x-dead-letter-exchange en el declare. Verifica que el DLX existe (si no, drop silencioso).
  8. Redrive es un comando con dueño, no un cron del agente.
  9. Consumer de DLQ aparte: peek, clasificar, nunca el mismo código que el worker caliente sin un flag mode=dlq.
  10. Los venenos recurrentes salen a evals, no se “ack para que baje el número”.

FAQ

¿Puedo usar la misma cola como DLQ de sí misma? No. En SQS la DLQ es otra cola. En Cloudflare el campo es el nombre de otra cola. En Service Bus es una subcola, no la misma entidad.

¿El agente puede leer la DLQ y “arreglar” el JSON con el LLM? No como default. Un modelo reescribiendo payloads muertos es un generador de side effects. Primero clasifica; el LLM entra, si acaso, en un job de triage sin tools de escritura.

¿Un 429 va a la DLQ? No al primer 429. Eso es retry. A la DLQ va cuando el techo se agotó o cuando el 429 es en realidad un schema/firma mal (clasificación, no status code).

¿Cloudflare crea la DLQ si el nombre no existe? Sí, según el doc de Configure Queues. Igual declárala, pon retention y alarma. Una cola auto-creada sin dashboard es un agujero.

¿FIFO + DLQ? Solo si aceptas romper el orden. Si el group id es una saga, pausa el grupo.

Triage de mensajes muertos agrupados por razón con alarma de profundidad

Errores que ya vimos

  • Retry infinito contra un JSON que nunca parsea: el worker se ve ocupado, el SLO arde.
  • DLQ sin alarma (Service Bus no limpia solo).
  • Redrive automático cada N minutos = retry infinito con hops extra.
  • Prompt completo en el mensaje muerto: leak a un store de retención larga.
  • RabbitMQ con DLX en x-arguments y rename del exchange: drop silencioso.
  • Cloudflare sin dead_letter_queue: el mensaje desaparece.

La DLQ no hace al agente más listo. Hace que el mensaje imposible deje de ocupar al worker listo.