Skip to content

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.

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.

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.

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.

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.

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.

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.

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());

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.