Dead letters
A job that cannot be processed is published to a dead-letter queue and acked on the original. The point is that nothing is ever left to be redelivered forever: one bad message cannot stall a consumer, and the operator gets a queue they can inspect.
For a work queue q, the dead-letter queue is q.dead by default.
What gets dead-lettered
Section titled “What gets dead-lettered”| Cause | Reason recorded |
|---|---|
Handler returned JobError::Fatal |
fatal error: ... |
| Retry policy ran out of attempts | max attempts (N) exhausted after attempt M: ... |
| No handler registered for the job type | no handler for job type `X` |
| Payload does not decode into the job type | decode error: ... |
Body does not decode as an Envelope at all |
malformed envelope |
The first two are your code’s decisions. The last three are structural, and all three are cases where retrying could not possibly help, so none of them consults the retry policy.
Headers on a dead letter
Section titled “Headers on a dead letter”Dead-lettered envelopes arrive on q.dead with:
| Header | Value |
|---|---|
x-death-reason |
The reason string from the table above |
x-original-queue |
The work queue the job came from |
x-attempts |
How many attempts it had used |
The body is the original envelope JSON, so the payload, job_id and
enqueued_at_ms all survive. That is enough to decide whether to fix and
replay, or to discard.
Poison messages
Section titled “Poison messages”A body that does not decode as an Envelope — something else published to the
queue, a truncated message, a different serialisation format — cannot be
retried, logged with a job id, or handed to a handler. It is copied verbatim
to q.dead with x-death-reason: "malformed envelope" and
x-original-queue, then acked.
It never surfaces as a stream error, because one unparseable message must not take down a consumer that is otherwise healthy.
Replaying
Section titled “Replaying”There is no built-in replay, deliberately: moving messages back onto a work queue without understanding why they failed is usually the wrong move, and when it is the right one, the decision is yours.
The two approaches that work:
Shovel them back. RabbitMQ’s shovel plugin or the management UI can move
messages from q.dead to q. The envelope’s attempt is whatever it was when
the job died, so a job that exhausted three attempts is immediately out of
attempts again. Reset it, or raise max_attempts for the replay.
Re-enqueue as new jobs. Read q.dead, decode the envelope’s payload into
your job type, and producer.enqueue it. The job gets a fresh job_id and
attempt 1, which is usually what you actually want.
Using the broker’s own policy instead
Section titled “Using the broker’s own policy instead”If your deployment already manages dead-lettering with a broker policy — an
x-dead-letter-exchange set on the queue by your infrastructure — turn
queuey’s off:
let backend = RabbitMqBackend::with_options( "amqp://guest:guest@localhost:5672/%2f", RabbitMqOptions::default().declare_dead_letter_queues(false),).await?;That changes both what is declared and how dead-lettering happens. Nothing is
published. The original message is rejected with requeue = false, so the
broker’s own policy on q applies, or the message is dropped if there is none.
The reason is logged at WARN rather than travelling in a header.
Renaming the queue
Section titled “Renaming the queue”RabbitMqOptions::default().dead_suffix("-dlq")q.dead becomes q-dlq. The suffix is part of the name the backend computes,
so every process talking to the same queues needs the same option.
In tests
Section titled “In tests”MemoryBackend records dead letters with their reasons, which makes the whole
mechanism assertable without a broker:
h.wait_for("the dead letter", || !h.backend.dead_letters(EMAILS).is_empty()).await;
assert_eq!( h.log.attempts_of("fatal@example.com"), vec![1], "a fatal error must not be retried");let dead = h.backend.dead_letters(EMAILS);assert_eq!(dead.len(), 1);assert_eq!(dead[0].0.attempt, 1);assert!(dead[0].1.contains("malformed recipient"));assert!(h.backend.acked(EMAILS).is_empty());Watch the depth
Section titled “Watch the depth”q.dead is the queue to alarm on. It is empty when everything is fine, so any
sustained growth is a real signal, and the x-death-reason header tells you
which of the five causes it is without reading a single payload.