Retry and Deadlettering
Dead Lettering & Retry — Complete Summary¶
What Is Dead Lettering¶
Dead lettering is just a forwarding mechanism built into RabbitMQ. Nothing special. When a message cannot stay in a queue — it gets forwarded somewhere else. That "somewhere else" is whatever you configure. The broker has no concept of "dead letter queue" — it just knows "rejected message → forward to this exchange."
Three things trigger dead lettering:
1. BasicNack with requeue: false → you explicitly rejected it
2. Message TTL expires → message sat too long unprocessed
3. Queue length exceeded → queue is full, oldest pushed out
The Three Things Required For Dead Lettering To Work¶
All three must exist or messages are silently dropped — no error, no warning:
1. x-dead-letter-exchange config on source queue
→ tells broker where to forward rejected messages
2. The DLX exchange must be declared
→ just a normal exchange, nothing special
3. Destination queue must be declared AND bound to DLX exchange
→ just a normal queue, nothing special
// Step 1 — declare the DLX exchange
_channel.ExchangeDeclare("dlx.exchange", ExchangeType.Direct, durable: true);
// Step 2 — declare destination queue
_channel.QueueDeclare("order-created.dlq", durable: true);
// Step 3 — bind destination queue to DLX exchange
_channel.QueueBind("order-created.dlq", "dlx.exchange", "order-created.dlq");
// Step 4 — declare source queue WITH dead letter config
_channel.QueueDeclare(
queue: "order-created.queue",
durable: true,
arguments: new Dictionary<string, object>
{
{ "x-dead-letter-exchange", "dlx.exchange" },
{ "x-dead-letter-routing-key", "order-created.dlq" }
}
);
What Is A DLQ¶
Just a normal queue. The word "dead letter queue" is a human convention — the broker doesn't know or care. What makes it a DLQ is purely:
It is the destination where rejected messages land
Nothing else distinguishes it from any other queue
It can have a consumer or not. Without a consumer — messages pile up for manual inspection. With a consumer — you can alert, store, or replay them.
What Is A Retry Queue¶
Also just a normal queue. What makes it a "retry queue" is two configs:
x-message-ttl → message waits here for N milliseconds
x-dead-letter-exchange → after TTL expires, forward to this exchange
x-dead-letter-routing-key → with this routing key
Crucially — no consumer on the retry queue. It is intentionally just a waiting room. The only thing that moves messages out of it is the TTL expiry triggering the dead letter forward.
_channel.QueueDeclare(
queue: "order-created.retry",
durable: true,
arguments: new Dictionary<string, object>
{
{ "x-message-ttl", 30000 }, // wait 30 seconds
{ "x-dead-letter-exchange", "domain.events" }, // then forward here
{ "x-dead-letter-routing-key", "OrderCreatedEvent" } // back to main queue
}
);
// No QueueBind — no consumer — just a waiting room
The dead letter destination of the retry queue is the main exchange — so the message comes back around to the main queue after the delay. Dead lettering used as a delay mechanism.
Why Your Code Controls The Move — Not BasicNack¶
This is the key point. The main queue has no dead letter config in this pattern. So:
BasicNack requeue: true → goes straight back to main queue immediately
no delay, hammers failing dependency
poison message loop risk
BasicNack requeue: false → deleted, gone forever
no DLQ config on main queue = silent drop
Explicit BasicPublish → YOU decide where it goes
retry queue if retries remaining
DLQ if max retries exceeded
Your handler is the decision maker. The broker just receives the publish instruction — it has no idea it's a retry or a failure. It looks exactly like a normal message arriving from any producer.
The Complete Flow¶
consumer.Received += async (_, ea) =>
{
try
{
await HandleAsync(message);
_channel.BasicAck(ea.DeliveryTag, false); // success — remove from queue
}
catch (Exception ex)
{
var retryCount = GetRetryCount(ea.BasicProperties);
if (retryCount >= 3)
{
// Max retries exceeded — explicitly publish to DLQ
// (cannot BasicNack into DLQ — no dlx config on main queue)
_channel.BasicPublish(
exchange: "dlx.exchange",
routingKey: "order-created.dlq",
basicProperties: props,
body: ea.Body
);
}
else
{
// Retries remaining — explicitly publish to retry queue
props.Headers = new Dictionary<string, object>
{
{ "x-retry-count", retryCount + 1 }
};
_channel.BasicPublish(
exchange: "", // default exchange
routingKey: "order-created.retry", // directly to retry queue
basicProperties: props,
body: ea.Body
);
}
// Always ACK original — remove from main queue either way
_channel.BasicAck(ea.DeliveryTag, false);
}
};
The Retry Flow Visually¶
order-created.queue
│
▼
handler fails — retry count = 0
│
│ explicit publish
▼
order-created.retry (no consumer)
│
│ TTL expires after 30s
│ dead letters to domain.events
▼
domain.events exchange
│
│ routes via "OrderCreatedEvent" routing key
▼
order-created.queue
│
▼
handler fails — retry count = 1
│
│ explicit publish
▼
order-created.retry (waits 30s again)
│
▼
order-created.queue
│
▼
handler fails — retry count = 2
│
│ explicit publish
▼
order-created.retry (waits 30s again)
│
▼
order-created.queue
│
▼
handler fails — retry count = 3 — MAX REACHED
│
│ explicit publish
▼
order-created.dlq
│
▼
alert fires — human investigates
Dead Lettering Is Just Forwarding¶
The single most important mental model:
Dead lettering has no opinion about destination
It is just: "when message can't stay here, send it there"
Retry queue uses it to send back to main queue → retry pattern
Source queue uses it to send to graveyard → DLQ pattern
Same mechanism. Different destinations. Different purposes.
What Makes Each Queue What It Is¶
Main queue → has consumer
no dead letter config (your code handles failures explicitly)
Retry queue → NO consumer
has TTL (the delay)
has dead letter config pointing back to main exchange
just a waiting room with a timer
DLQ → optional consumer for alerting/replay
no special config
just where broken messages land
a graveyard
The Common Mistake¶
Configuring x-dead-letter-exchange on the source queue but forgetting to declare and bind the destination queue. No error is thrown. Messages are silently dropped. Always verify DLQ setup in the management UI after deployment.