I'm working on a message processing system using RabbitMQ. My goal is to retry failed messages after 30 seconds, up to 3 times. If the message still fails after the third attempt, it should be moved to a dead-letter queue (DLQ) for further manual investigation.
Here's how I approached it:
I declared three exchanges: main, retry, and dead-letter — and three corresponding queues. The main queue is bound to the main exchange and is configured with:
x-dead-letter-exchange
: pointing to the retry exchangex-dead-letter-routing-key
: pointing to the retry queue
When a message fails processing, it is nacked with requeue: false, causing it to be dead-lettered into the retry queue.
The retry queue has:
x-message-ttl
: 30 secondsx-dead-letter-exchange
: pointing back to the main exchangex-dead-letter-routing-key
: pointing to the main queue
Since the retry queue has no consumer, after the TTL expires, the message becomes dead and is forwarded back to the main exchange — effectively retrying the original message. This cycle repeats.
In the consumer, I inspect the x-death header to determine how many times the message has already been retried. If it has failed 3 times or more, I manually publish it to the dead-letter exchange and acknowledge it to prevent further retries.
This is the relevant part of the retry logic:
private async Task ProcessEventAsync(object model, BasicDeliverEventArgs ea){ var body = ea.Body.ToArray(); var message = Encoding.UTF8.GetString(body); try { var retryCount = 0L; if (ea.BasicProperties.Headers != null && ea.BasicProperties.Headers.TryGetValue("x-death", out var xDeathRaw)) { ... } if (retryCount >= 3) { _logger.LogError("Message exceeded max retry count. Routing to dead-letter queue."); await _channel.BasicPublishAsync( exchange: _deadLetterExchangeName, routingKey: _deadLetterQueueName, body: body ); await _channel.BasicAckAsync(ea.DeliveryTag, false); return; } var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(message); _logger.LogInformation("It contains {@NotificationEvent}", notificationEvent); await _channel.BasicAckAsync(ea.DeliveryTag, false); } catch (Exception ex) { _logger.LogError("Processing failed. Will retry."); await _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false); } await Task.Yield();}
Is this a good approach for implementing limited retries with RabbitMQ?Are there any cleaner, more idiomatic, or fault-tolerant patterns I should consider for this scenario?
Full code you can find here: https://github.com/kotenko2002/DLXtesting/blob/master/DLXtesting/Workers/NewWoker.cs