In acknowledgements deep dive
we saw that requeuing a failing message with requeue = true creates a tight, CPU-burning
loop. Here we fix that properly with a RabbitMQ retry and dead-letter queue setup: retry a
message after a delay, and after enough failed attempts, park it somewhere safe instead
of retrying forever.
The building block: reject without requeue
Everything here rests on one fact from chapter 4: when you reject a message without requeue and the queue has a dead-letter exchange, the message is routed to that exchange instead of being discarded.
$callback = function ($msg) {
try {
process($msg->body);
$msg->getChannel()->basic_ack($msg->getDeliveryTag());
} catch (\Throwable $e) {
// Do NOT requeue. Let the dead-letter exchange take it.
$msg->getChannel()->basic_nack($msg->getDeliveryTag(), false, false);
}
};
That single false for requeue is the hinge the whole pattern turns on.
The retry pattern: a wait queue with TTL
We want a failed message to come back and be retried, but after a pause. We combine two things you already know: dead-lettering and per-queue TTL. The setup is two queues:
-
Main queue (
tasks) - dead-letters to aretryexchange when a message is rejected. -
Wait queue (
tasks.wait) - has a TTL and dead-letters back to the main queue.
use PhpAmqpLib\Wire\AMQPTable;
// Exchanges that move messages between the two queues.
$channel->exchange_declare('tasks.dlx', 'direct', false, true, false);
$channel->exchange_declare('tasks.retry', 'direct', false, true, false);
// Main queue: on reject, dead-letter to tasks.dlx.
$mainArgs = new AMQPTable([
'x-dead-letter-exchange' => 'tasks.dlx',
'x-dead-letter-routing-key' => 'wait',
]);
$channel->queue_declare('tasks', false, true, false, false, false, $mainArgs);
$channel->queue_bind('tasks', 'tasks.retry', 'tasks');
// Wait queue: hold for 10s, then dead-letter back to tasks.retry -> tasks.
$waitArgs = new AMQPTable([
'x-message-ttl' => 10000,
'x-dead-letter-exchange' => 'tasks.retry',
'x-dead-letter-routing-key' => 'tasks',
]);
$channel->queue_declare('tasks.wait', false, true, false, false, false, $waitArgs);
$channel->queue_bind('tasks.wait', 'tasks.dlx', 'wait');
The flow: a message fails in tasks, is rejected without requeue, and dead-letters into
tasks.wait. It sits there for 10 seconds (the TTL), then expires and dead-letters back
into tasks, where your consumer tries again. You get automatic retries with a cooldown,
and no busy loop.
Counting attempts so retries are not infinite
A retry loop with no limit is just a slower poison-message loop. Each time a message is
dead-lettered, RabbitMQ adds an x-death header recording how many times it happened. Read
it and give up after a few tries:
$callback = function ($msg) {
$deaths = 0;
$headers = $msg->get_properties()['application_headers'] ?? null;
if ($headers) {
$data = $headers->getNativeData();
$deaths = $data['x-death'][0]['count'] ?? 0;
}
if ($deaths >= 3) {
// Give up: send it to the poison queue for a human to look at.
$msg->getChannel()->basic_publish($msg, 'tasks.parking', 'dead');
$msg->getChannel()->basic_ack($msg->getDeliveryTag());
return;
}
try {
process($msg->body);
$msg->getChannel()->basic_ack($msg->getDeliveryTag());
} catch (\Throwable $e) {
$msg->getChannel()->basic_nack($msg->getDeliveryTag(), false, false);
}
};
Read x-death carefully, because it is not a single number. It is an array with one entry
per queue-and-reason pair the message has passed through, and each entry carries its own
count. Grabbing [0]['count'] works for the simple one-hop retry loop above, but once a
message bounces through several queues that first entry may not be the total you assume. The
reason field is useful too: it says expired when the TTL fired and rejected when your
consumer nacked, so you can tell a timed-out retry apart from a genuine failure.
The dead-letter queue for poison messages
After the last attempt, the message goes to a final resting place - the dead-letter queue (DLQ), sometimes called a parking or poison queue. Nothing consumes it automatically. It is where messages that can never succeed wait for a human:
$channel->exchange_declare('tasks.parking', 'direct', false, true, false);
$channel->queue_declare('tasks.dead', false, true, false, false);
$channel->queue_bind('tasks.dead', 'tasks.parking', 'dead');
You inspect this queue in the management UI, fix the underlying bug, and either replay or delete the messages. The key win: one broken message never blocks the main queue and never loops forever.
Common mistake: retrying a message that can never succeed
Not every failure should be retried. A malformed payload or a validation error will fail identically on every attempt, so retrying it just wastes 3 delays before it lands in the DLQ anyway. Retries are for transient problems (a timeout, a locked row). For a message that is simply wrong, skip straight to the dead-letter queue on the first failure.
FAQ
Why use a wait queue instead of just requeuing?
A plain requeue retries instantly, so a message that fails because a downstream service is down will hammer it again and again with no pause. The wait queue adds a delay between attempts, giving the transient problem time to clear.
How do I get a longer delay for each retry?
Use several wait queues with increasing TTLs (10s, 60s, 300s) and route a message to the
next one based on its x-death count. That gives you exponential backoff without any
plugin.
What consumes the dead-letter queue?
Usually nobody, on purpose. It is a holding area you monitor. When messages pile up there, that is your alert that something is genuinely broken and needs a person to decide what to do.