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.

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.
| Sistema | Qué es la DLQ | Cuándo entra | Qué pasa si no hay |
|---|---|---|---|
| SQS | Otra cola + redrive policy (maxReceiveCount) | Recibir N veces sin borrar | El mensaje se queda (y en FIFO, el orden se rompe si sí hay DLQ) |
| Service Bus | Subcola $deadletterqueue | MaxDeliveryCountExceeded (default 10), TTL, o DeadLetterMessageAsync | No aplica: la subcola siempre existe |
| Cloudflare Queues | Otra cola en el consumer | Falla o retryAll() ≥ max_retries | Se descarta |
| RabbitMQ | Exchange normal + routing key | nack sin requeue, TTL, overflow, delivery-limit | Drop 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.
| Clase | Ejemplo en un agente | Acción |
|---|---|---|
| Transitorio | 429, 503, 529, timeout de red | Retry con backoff. No es DLQ todavía. |
| Veneno | JSON que no pasa el schema, tool inexistente, firma HMAC inválida | DLQ inmediata con razón. Cero retry. |
| Presupuesto agotado | 5xx persistente después del techo | DLQ 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.

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:
- Alarma si
ApproximateNumberOfMessagesVisible(o el equivalente) de la DLQ > 0 durante N minutos. - Agrupa por
DeadLetterReason. Unschema_invalidmasivo es un deploy malo, no un 503. - Arregla el consumer / el schema / la firma.
- Redrive un lote con el mismo
idempotency_key. Si el original ya se aplicó, el redrive debe ser no-op. - 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
- Hay una DLQ distinta (o subcola) por cola caliente, no una DLQ global de todo el producto.
maxReceiveCount/max_retries/MaxDeliveryCountestá escrito y no es 1 “porque así es más simple”.- Veneno (
schema, firma, tool unknown) hace dead-letter explícito, no espera al techo. - Cada mensaje muerto tiene
reason+trace_id+idempotency_key. Cero prompt en el body. - Retention DLQ > retention fuente (SQS standard). FIFO: decide si el group id se pausa en vez de DLQ.
- Alarma de profundidad y de edad. Cloudflare: si no pones
dead_letter_queue, asume pérdida. - RabbitMQ: policy, no
x-dead-letter-exchangeen el declare. Verifica que el DLX existe (si no, drop silencioso). - Redrive es un comando con dueño, no un cron del agente.
- Consumer de DLQ aparte: peek, clasificar, nunca el mismo código que el worker caliente sin un flag
mode=dlq. - 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.

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-argumentsy 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.
Lecturas relacionadas
Sigue explorando AgentOps y otras piezas para builders.

Shadow, canary y feature flags en agentes: soltar sin redeploy

Fallback de modelos en agentes: otro modelo, no el mismo 429

SLO y error budget en agentes: 4 SLIs, burn rápido/lento y política 50/100
