Warren
Guide

Replay one message first: a canary for RabbitMQ dead letters

2026-10-12 · 7 min read · Viktor Baumann

The fix is merged, the pipeline is green, and 143 messages wait in orders.dlq. Replaying all of them at once is a bet: that the new version runs on every consumer replica, that the fix covers these payloads and not only the one in the ticket, and that the consumer does not fail on something else further down. If the bet is lost, 143 messages die a second time, the alerts fire again, and the dead-letter queue now mixes old failures with new ones. Sending one message first and waiting a moment costs a couple of minutes and settles the question for almost nothing.

The idea as a video (canary replay and the incident report, captions, no sound needed):

Watch it on YouTube →

Before you send anything

A canary only means something if its message is processed during the wait. Check two things on the work queue first:

Then pick the canary from the failure you mean to fix. If the dead-letter queue holds two different exceptions, one canary passing says nothing about the other; send one per exception.

Send one message, tagged

The replay is the same as for a whole batch, stopped after the first message: read it from the dead-letter queue, publish it to its original route with a header that names this replay, wait for the publisher confirm, then acknowledge the original.

import uuid, pika

DEATH = ("x-death", "x-first-death-", "x-last-death-", "x-delivery-count", "x-acquired-count")
replay_id = str(uuid.uuid4())

ch.confirm_delivery()
method, props, body = ch.basic_get("orders.dlq", auto_ack=False)
death = props.headers["x-death"][0]  # broker dead-lettering; Spring or MassTransit error queues keep the route elsewhere
headers = {k: v for k, v in (props.headers or {}).items() if not k.startswith(DEATH)}
headers["x-replay-id"] = replay_id
props.headers = headers
ch.basic_publish(death["exchange"], death["routing-keys"][0], body, props, mandatory=True)
ch.basic_ack(method.delivery_tag)  # confirm_delivery made the publish wait for the broker
print("canary", props.message_id, "replay", replay_id)

Your own headers survive dead-lettering on classic and quorum queues, so if the canary dies again it comes back carrying x-replay-id, together with a fresh x-death. That pair is what tells its return apart from a new failure that arrived in the meantime. The details are in Did the replay work?.

How long to wait

Long enough for the consumer's whole retry chain to run out, plus the time to reach the canary. The chain is usually the larger part:

Retry set-upWhen a failing message reaches the DLQ
No retries, basic.reject with requeue=falsewithin a second of delivery
Spring RetryTemplate / @Retryable, 3 attempts, backoff 1 s × 2after about 3 s (1 + 2), plus processing
Broker-side retry: reject into a wait queue whose TTL dead-letters back, 5 rounds of 30 safter about 2.5 minutes
MassTransit UseDelayedRedelivery with 5, 15, 30 minutesafter about 50 minutes

Read the numbers off your own configuration. Two minutes covers in-process retries with short backoffs; a delayed-redelivery set-up needs a canary that waits an hour, or a canary sent before lunch and the rest afterwards.

Watch for it

During the wait nothing else is replayed, so the dead-letter queue changes only through new failures and through the canary coming back. Check its depth; when it grows, look at what was added:

def came_back(ch, queue, replay_id, depth_before):
    depth = ch.queue_declare(queue, passive=True).method.message_count
    if depth <= depth_before:
        return False
    # returns arrive at the tail: read the whole queue and put everything back
    found, tags = False, []
    for _ in range(depth):
        method, props, _ = ch.basic_get(queue, auto_ack=False)
        if method is None:
            break
        tags.append(method.delivery_tag)
        h = props.headers or {}
        found = found or (h.get("x-replay-id") == replay_id and "x-death" in h)
    for tag in tags:
        ch.basic_nack(tag, requeue=True)
    return found

Take depth_before right after the canary left the queue, and call this every few seconds until the wait is over. Reading and putting back marks messages as redelivered. On a quorum dead-letter queue with a delivery limit, check what a requeue costs on your RabbitMQ version before you peek at all.

If your consumer sends failures to a different queue than the one the message came from (a parking queue after the last retry, a Spring .dlq next to a broker DLQ), watch that queue too.

Decide

When a passed canary proves nothing

Checklist