4.3 KiB
← The silent drop · Module README · Acknowledgement →
4. A dead-letter queue that actually works
There is no "send to DLQ" operation in AMQP. A dead-letter exchange is a queue argument, and messages arrive there as a side effect of three specific events.
QueueBuilder.durable("orders.work")
.deadLetterExchange("orders.dlx")
.deadLetterRoutingKey("failed")
.build();
deadLetterRoutingKey matters more than it looks. Without it, the message is republished with
its original routing key, so your DLQ binding has to anticipate every routing key the
upstream might have used. Setting it to a constant means one binding catches everything.
The three triggers
| Event | x-death reason |
|---|---|
basicNack / basicReject with requeue=false |
rejected |
message TTL expires (x-message-ttl or per-message) |
expired |
queue length or byte limit exceeded (x-max-length) |
maxlen |
And the one that is not a trigger: requeue=true. More on that below.
What arrives
Both captured from real runs, in
docs/output/dead-letter.txt:
=== x-death after basicNack(requeue=false) ===
reason rejected
count 1
exchange
time Sat Aug 29 09:53:17 IST 2026
routing-keys [orders.work]
queue orders.work
=== x-death after x-message-ttl expiry ===
reason expired
count 1
queue orders.ttl
Same destination queue, different reason. That field is the whole diagnostic: rejected means
a consumer looked at the work and refused it; expired means nobody got to it in time. Those are
different incidents with different fixes, and they are indistinguishable without reading the
header.
The third trigger has a detail worth knowing: when x-max-length is exceeded RabbitMQ drops from
the head, so the message that gets dead-lettered is the oldest one already queued, not the
one that just arrived:
=== x-death after x-max-length overflow ===
reason maxlen
queue orders.bounded
body {"orderId":"m-1","detail":"detail for m-1"}
Three messages into a queue that holds two, and m-1 is the one on the DLQ. A bounded queue
under sustained overload therefore dead-letters your backlog while continuing to accept new
work — which is usually what you want for telemetry and exactly wrong for orders.
x-death is a list, not a map — one entry per queue the message has been dead-lettered from,
and count accumulates. That is how you build a retry limit: read
x-death[0].count, and stop republishing past a threshold.
The infinite loop
channel.basicNack(deliveryTag, false, true); // requeue = true
From docs/output/requeue-loop.txt, 200 delivery attempts:
redelivered 199 times
dead-lettered 0
still on queue 1
The dead-letter exchange is never consulted. x-death is never written. There is no counter to
exhaust and no backoff. One poison message with an unconditional requeue=true handler will
saturate a consumer indefinitely, and the queue depth stays at 1 the whole time — so a depth
alarm will not fire either. The only visible signal is CPU.
requeue=true is only correct when the failure is transient and you have a delay. With no
delay, a message that fails because a database is down comes straight back while the database is
still down. The redelivered flag on the envelope is your one piece of state:
boolean retried = response.getEnvelope().isRedeliver();
channel.basicNack(tag, false, !retried); // one requeue, then dead-letter
That is a one-attempt retry with no extra infrastructure. For anything more, use a delay queue: a queue with a TTL and a DLX pointing back at the work exchange turns "expire after 30 seconds" into "retry in 30 seconds".
Do not point the DLQ's DLX at its own source
If orders.dlq dead-letters to an exchange bound back to orders.work, a message that fails
consistently cycles between the two forever, gaining an x-death entry each time until the
header itself is the biggest thing in the message. The DLQ in this module deliberately has no
dead-letter exchange at all.