RabbitMQ
Dead Letter Queue
Created: 05 Jan 2026
Updated: 05 Jan 2026
Implementing Robust Retry Mechanisms with RabbitMQ Quorum Queues and DLX
While RabbitMQ Classic Queues have traditionally used Dead Letter Exchanges (DLX) for retries, Quorum Queues provide a more reliable, replicated, and modern approach to handling failed messages.
1. Why Quorum Queues?
Quorum Queues are designed for high availability and data safety. Unlike Classic Queues, they are replicated across multiple nodes using the Raft consensus protocol. When implementing retries, Quorum Queues offer a built-in feature called Poison Message Handling, which tracks how many times a message has been delivered.
Code
using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
// 1. Connection Setup
var factory = new ConnectionFactory() { HostName = "localhost" };
using var connection = await factory.CreateConnectionAsync();
using var channel = await connection.CreateChannelAsync();
// Constant Definitions
const string MainExchange = "poison-main-exchange";
const string MainQueue = "poison-main-queue";
const string MainRoutingKey = "poison.route";
const string DeadLetterExchange = "poison-dlx";
const string DeadLetterQueue = "poison-dead-letter-queue";
const string DeadLetterRoutingKey = "poison.failed";
const int DeliveryLimit = 3;
// --- INFRASTRUCTURE SETUP ---
// 2. Declare Dead Letter Exchange and Queue
await channel.ExchangeDeclareAsync(DeadLetterExchange, ExchangeType.Direct, durable: true, autoDelete: false);
await channel.QueueDeclareAsync(DeadLetterQueue, durable: true, exclusive: false, autoDelete: false, arguments: null);
await channel.QueueBindAsync(DeadLetterQueue, DeadLetterExchange, DeadLetterRoutingKey);
// 3. Declare Main Infrastructure (Quorum + Delivery Limit)
await channel.ExchangeDeclareAsync(MainExchange, ExchangeType.Direct, durable: true, autoDelete: false);
var arguments = new Dictionary<string, object?>
{
{ "x-queue-type", "quorum" }, // Must be quorum for delivery-limit
{ "x-dead-letter-exchange", DeadLetterExchange },
{ "x-dead-letter-routing-key", DeadLetterRoutingKey },
{ "x-delivery-limit", DeliveryLimit } // Message moves to DLX after 3 failed attempts
};
await channel.QueueDeclareAsync(MainQueue, durable: true, exclusive: false, autoDelete: false, arguments: arguments);
await channel.QueueBindAsync(MainQueue, MainExchange, MainRoutingKey);
// --- PRODUCER: Publishing a Test Message ---
string message = "Test Poison Message";
var body = Encoding.UTF8.GetBytes(message);
await channel.BasicPublishAsync(exchange: MainExchange, routingKey: MainRoutingKey, body: body);
Console.WriteLine($" [x] Sent: '{message}'");
// --- CONSUMER 1: The Failing Consumer (Main Queue) ---
var mainConsumer = new AsyncEventingBasicConsumer(channel);
mainConsumer.ReceivedAsync += async (model, ea) =>
{
var msg = Encoding.UTF8.GetString(ea.Body.ToArray());
// Simulating a permanent failure (e.g., database down or bug)
Console.WriteLine($" [Main Queue] Received attempt. Rejecting... Message: {msg}");
// We MUST use BasicReject or BasicNack with requeue: true
// to increment the delivery count.
await channel.BasicRejectAsync(deliveryTag: ea.DeliveryTag, requeue: true);
};
await channel.BasicConsumeAsync(queue: MainQueue, autoAck: false, consumer: mainConsumer);
// --- CONSUMER 2: The DLQ Monitor (Dead Letter Queue) ---
var dlqConsumer = new AsyncEventingBasicConsumer(channel);
dlqConsumer.ReceivedAsync += async (model, ea) =>
{
var msg = Encoding.UTF8.GetString(ea.Body.ToArray());
Console.WriteLine("\n--------------------------------------------------");
Console.WriteLine($" [DLQ ALERT] Message moved to DLQ after {DeliveryLimit} attempts!");
Console.WriteLine($" Content: {msg}");
Console.WriteLine("--------------------------------------------------");
await channel.BasicAckAsync(ea.DeliveryTag, false);
};
await channel.BasicConsumeAsync(queue: DeadLetterQueue, autoAck: false, consumer: dlqConsumer);
Console.WriteLine("\n Press [enter] to exit.");
Console.ReadLine();