113 lines
4.3 KiB
Markdown
113 lines
4.3 KiB
Markdown
[← The silent drop](03-the-silent-drop.md) · [Module README](../README.md) · [Acknowledgement →](05-acknowledgement.md)
|
|
|
|
# 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.
|
|
|
|
```java
|
|
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`](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
|
|
|
|
```java
|
|
channel.basicNack(deliveryTag, false, true); // requeue = true
|
|
```
|
|
|
|
From [`docs/output/requeue-loop.txt`](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:
|
|
|
|
```java
|
|
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.
|
|
|
|
[Acknowledgement →](05-acknowledgement.md)
|