Retry and Dead Letter Queues
Retry Policies
Configure retry attempts per receive endpoint:
rmq.ReceiveEndpoint("payments", e =>
{
e.RetryCount = 3;
e.RetryInterval = TimeSpan.FromSeconds(1);
e.Consumer<PaymentProcessor, ProcessPayment>();
});
When a consumer throws an exception, BareWire retries the message up to RetryCount times with RetryInterval delay between attempts. After all retries are exhausted, the message is either nacked or routed to a Dead Letter Exchange.
Dead Letter Exchange (DLX)
RabbitMQ's native DLX mechanism routes failed messages to a separate queue for inspection and reprocessing.
Topology Setup
rmq.ConfigureTopology(topology =>
{
// Main exchange and queue with DLX routing
topology.DeclareExchange("payments", ExchangeType.Direct, durable: true);
topology.DeclareQueue("payments", durable: true, arguments: new Dictionary<string, object>
{
["x-dead-letter-exchange"] = "payments.dlx"
});
topology.BindExchangeToQueue("payments", "payments", routingKey: "");
// Dead letter exchange and queue
topology.DeclareExchange("payments.dlx", ExchangeType.Fanout, durable: true);
topology.DeclareQueue("payments-dlq", durable: true);
topology.BindExchangeToQueue("payments.dlx", "payments-dlq", routingKey: "");
});
DLQ Consumer
Create a consumer on the dead letter queue to handle failed messages — log them, persist them, or trigger alerts:
public sealed class DlqConsumer : IConsumer<ProcessPayment>
{
private readonly PaymentDbContext _db;
public DlqConsumer(PaymentDbContext db) => _db = db;
public async Task ConsumeAsync(ConsumeContext<ProcessPayment> context)
{
_db.FailedPayments.Add(new FailedPayment
{
OrderId = context.Message.OrderId,
Amount = context.Message.Amount,
FailedAt = DateTime.UtcNow,
Reason = "Retry limit exceeded"
});
await _db.SaveChangesAsync(context.CancellationToken);
}
}
Register the DLQ consumer on its own endpoint:
rmq.ReceiveEndpoint("payments-dlq", e =>
{
e.Consumer<DlqConsumer, ProcessPayment>();
});
See:
samples/BareWire.Samples.RetryAndDlq/
Per-Key Poison Handling
When an endpoint enables per-key consumer ordering, a poison message at the head of a key would otherwise block that key's entire stream (head-of-line blocking). The anti-starvation contract resolves this: bounded retry → park/DLQ → key release.
rmq.ReceiveEndpoint("ordered-processing", e =>
{
e.OrderedBy(o =>
{
o.ByHeader("ordering-key");
o.TransportAffinity(TransportAffinity.SingleActiveConsumer);
o.MaxDeliveryAttempts(2); // park the poison head after 2 attempts
});
e.Consumer<OrderShippedConsumer, OrderShipped>();
});
- The head message is retried up to
MaxDeliveryAttempts(reusing the endpointRetryCount/RetryInterval). - After the threshold, the message is dead-lettered (wire a DLX on the queue) and leaves the head of the key.
- The key stream then resumes for subsequent messages. The skipped (parked) message is an ordering gap, which is logged — there is no permanent block.
- Key release happens only after the broker durably confirms the parking of the head; if settlement fails, the key is not released (the head stays at the front and the operation is retried).
MaxDeliveryAttempts defaults to 0 (disabled — plain at-least-once without parking).
Message Loss Protection
If a queue has no Dead Letter Exchange configured and a message is rejected after retry exhaustion, it is permanently lost. BareWire logs a warning in this situation:
warn: BareWire.Pipeline.DeadLetterMiddleware
Message {MessageId} on endpoint {Endpoint} was NACKed but no DLX is configured — message will be permanently lost.
The framework detects missing DLX via EndpointBinding.HasDeadLetterExchange, which is set based on the declared topology. Recommendation: always configure a DLX on production queues to avoid silent message loss.
Inspecting Failed Messages
The RetryAndDlq sample exposes an endpoint to query persisted failures:
GET /payments/failed — returns all failed payments from the DLQ consumer