Tutorial 2 — Surviving failure

20 minutes. Continues from tutorial 1. Same broker.

Your handler fails. This tutorial is about what should happen next, and about the answer most services reach for first — which is wrong in a way that takes a production incident to notice.

Step 1 — Watch it fail

async def ship(message: Message) -> Ack:
    print("attempt", message.envelope.attempt)
    raise RuntimeError("the warehouse is not answering")


consumer = await mq.consume("shipping.orders", ship)
attempt 1

One attempt, and the message is in shipping.orders.dlq with a reason on it.

That is the default because the default retry policy is no_retry() — one delivery, no second chance. A handler that raises is asking for a retry, the policy says there is none, and the message is dead-lettered with exhausted 1 attempt: RuntimeError: the warehouse is not answering written into its envelope.

This is a deliberate choice and worth a sentence. The obvious alternative default is requeue, which is what most AMQP clients do: a message that fails, requeues, is redelivered instantly, fails again and requeues is a poison message loop — one bad message saturating a consumer at thousands of attempts a second with nothing else getting through. A hot loop against somebody's broker is not a default this library is willing to have. Dead-lettering is loud, recoverable, and says why.

Step 2 — The answer that looks right

Almost every service reaches for this first:

async def ship(message: Message) -> Ack:
    for attempt in range(3):
        try:
            await warehouse.reserve(message.payload)
            return accept()
        except Exception:
            await asyncio.sleep(1)      # ← the problem
    return reject(RuntimeError("gave up"))

Do not do this. The sleep is inside the consumer, which means:

The last one is the real problem, and it is the one a policy fixes.

Step 3 — A policy

from datetime import timedelta

from acemq_amqp import exponential_retry

policy = exponential_retry(
    4,                          # attempts, including the first
    timedelta(seconds=20),      # the first delay
    timedelta(minutes=5),       # the ceiling
)

consumer = await mq.consume("shipping.orders", ship, retry=policy)

Or on the connection, for every consumer on it:

async with await connect(url, retry=policy) as mq:

exponential_retry doubles, so this waits 20s, 40s and 80s. You can look at the schedule before running anything:

policy.schedule()        # [0:00:20, 0:00:40, 0:01:20]
policy.broker_rungs()    # [0:00:40, 0:01:20]

schedule() reports the delays without jitter, which is why it is the thing to assert on in a test: a jittered schedule is not reproducible and a test against one fails on a Tuesday.

The attempt counter rides on the message

A retry is republished, not requeued. A requeue hands back the bytes the broker already had, so the attempt header would still read what the publisher wrote however many times the message had come round, and the count would live only in this process's memory — the one place it is lost when the process that has been failing restarts.

The cost is that a retried message goes to the back of the queue rather than the front. For a message that has already failed once, that is the better trade.

Where the waiting happens

This is the part broker_rungs() was hinting at. Thirty seconds is the line.

A wait below it is held here: the delivery is kept, one prefetch slot is spent, and for a few seconds that is cheaper than asking somebody's broker for a queue. A wait above it is not. The message is published to a rung queue whose x-message-ttl is the delay and whose dead-letter target is the queue it came from, and the consumer is free immediately.

shipping.orders                  your queue
shipping.orders.retry.40s        a rung: TTL 40s, dead-letters back
shipping.orders.retry.80s        a rung
shipping.orders.dlq              attempts exhausted
shipping.orders.parked           the body could not be decoded
acemq.dlx, acemq.retry           the two exchanges that route those

There is no shipping.orders.retry.20s, and that is the threshold doing its job. Twenty seconds lost to a restart is twenty seconds; eighty is where that stops being acceptable, because a consumer that dies mid-wait does not resume mid-wait — the broker redelivers the unacknowledged message at once, and a policy that said eighty seconds delivers in none.

One rung per distinct delay, not one queue per message. The delay is a property of the queue, so a thousand waiting messages cost one queue.

The obvious simplification — one rung and a per-message expiration — does not work, and it is worth knowing why before someone suggests it. RabbitMQ only ever expires the message at the head of a queue, so a ten-minute message at the front holds back every thirty-second one behind it, and the delays that come out bear no resemblance to the ones that went in.

Move the line, or remove it:

policy = policy.wait_in_broker_from(timedelta(minutes=1))   # rungs past a minute
policy = policy.wait_in_broker_from(timedelta(0))           # no rungs at all

Zero is the way out, not the way in: with no threshold, nothing is long enough to reach the broker, so every wait is spent in the consumer and no rung queue is needed. That is the right setting for a service whose broker it may not declare queues on, and the wrong one for a policy with delays measured in minutes.

Note the reassignment. RetryPolicy is frozen, like everything else on the wire here, so these return a new policy and calling one for its effect does nothing.

Jitter

Twenty percent, in both directions, already applied. Consumers that fail together do not then all retry at the same instant and hit the recovering service simultaneously. with_jitter(factor) changes the amount, and fixed_retry starts with none because a fixed delay is an exact request.

Jitter is never applied to a wait the broker holds: a rung's TTL is fixed when the queue is declared, so a moved delay would name a queue that does not exist.

Step 4 — Say which failures are worth retrying

Retrying is only correct for failures that might not happen next time. A malformed message will be malformed on every attempt, and four attempts only delay the inevitable by two minutes.

from acemq_amqp import FatalError, accept, park, reject, retry


async def ship(message: Message) -> Ack:
    order = message.payload
    if order["totalCents"] <= 0:
        return reject(ValueError(f"total must be positive, was {order['totalCents']}"))
    try:
        await warehouse.reserve(order)
    except Unreachable as down:
        return retry(down)
    except NoSuchItem as gone:
        return retry(FatalError(f"no such item: {gone}"))
    return accept()
retry(e) ask the policy. Another attempt if there is one, dead-letter if not
retry(FatalError(...)) the handler asked for a retry and marked the reason as one that will not change. The mark wins: straight to the dead-letter queue
reject(e) this message is wrong. Dead-letter it now
park(e) nothing could read it
raising the same as retry(e) — an unclassified failure is assumed transient, because the cost of retrying a permanent failure is a delay and the cost of not retrying a transient one is a lost message

Reject or park?

reject is for a message that was read and refused; park for one that could not be read at all. They go to different queues on purpose. A message that failed five times and a message nothing can decode are different problems with different answers — one is usually the world, the other is usually a producer that deployed a format change ahead of its consumers — and whoever drains the dead letters should not have to sort them by hand.

A body the codec itself refuses is parked without the handler ever running, for the same reason.

Step 5 — Read the dead letters

The reason and the attempt count are attached when the message is set aside, not reconstructed later from logs:

async def look(message: Message) -> Ack:
    print(f"gave up on {message.payload['orderId']}")
    print(f"  attempts: {message.envelope.attempt}")
    print(f"  reason:   {message.envelope.error}")
    return accept()


from acemq_amqp import dead_letter_queue

watcher = await mq.consume(dead_letter_queue("shipping.orders"), look, declare=False)
gave up on o-1
  attempts: 4
  reason:   exhausted 4 attempts: RuntimeError: the warehouse is not answering

Note declare=False. A one-off reader of shipping.orders.dlq has no business creating shipping.orders.dlq.dlq, and that is what leaving it on would do.

dead_letter_queue(name) and parked_queue(name) build the names, so nothing in your code has to know the suffix is .dlq.

Step 6 — Put them back

The warehouse is fixed. The messages are still in the dead-letter queue.

from acemq_amqp.patterns import replay

result = await replay(mq, dead_letter_queue("shipping.orders"), limit=500)
print(result)      # moved 137, skipped 0 (drained)

replay reads a queue and republishes what it finds, keeping each message's own routing key so a message goes back where it came from rather than everywhere.

Every message gets a fresh set of attempts, because that is what a replay is for: a message arriving back on attempt four of a four-attempt policy is dead-lettered again before a handler sees it, and the operator who has just fixed the bug has moved two thousand messages from one queue to the same queue. Pass restart=False to put back exactly what was there.

Replay some of them when only some should go back:

result = await replay(
    mq,
    dead_letter_queue("shipping.orders"),
    limit=100,
    only=lambda envelope, body: "warehouse" in envelope.error,
)

What the filter declines goes back, not away — and it is held unsettled until the pass is over rather than returned one at a time, because a message returned immediately goes to the head of the queue and the next read hands over the same one for ever.

Replayed messages are marked with acemq-replayed-from, acemq-replayed-at and acemq-replay-count, so a message going round for the third time is visible as such rather than looking fresh. Those names carry no x-acemq- prefix on purpose: that namespace is stripped before a handler sees it, and these are meant to reach one.

Step 7 — All together

import asyncio
from datetime import timedelta

from acemq_amqp import (
    Ack, Message, Topology, accept, connect, dead_letter_queue, fixed_retry,
)
from acemq_amqp.patterns import replay


async def main() -> None:
    policy = fixed_retry(3, timedelta(seconds=1))

    async with await connect("amqp://guest:guest@localhost:5672/", retry=policy) as mq:
        await mq.declare(Topology().queue("shipping.orders", dead_letter=True, retry=policy))

        attempts = []
        gave_up = asyncio.Event()

        async def ship(message: Message) -> Ack:
            attempts.append(message.envelope.attempt)
            print(f"attempt {message.envelope.attempt} for {message.payload['orderId']}")
            raise RuntimeError("the warehouse is not answering")

        async def look(message: Message) -> Ack:
            print(f"dead: {message.payload['orderId']}"
                  f" after {message.envelope.attempt} attempts"
                  f" — {message.envelope.error}")
            gave_up.set()
            return accept()

        consumer = await mq.consume("shipping.orders", ship)
        watcher = await mq.consume(
            dead_letter_queue("shipping.orders"), look, declare=False
        )

        await mq.publisher(routing_key="shipping.orders").send(
            {"orderId": "o-1", "totalCents": 4250}
        )

        await asyncio.wait_for(gave_up.wait(), 30)
        await consumer.close()

        moved = await replay(mq, dead_letter_queue("shipping.orders"), limit=10)
        print(moved)
        await watcher.close()


asyncio.run(main())
attempt 1 for o-1
attempt 2 for o-1
attempt 3 for o-1
dead: o-1 after 3 attempts — exhausted 3 attempts: RuntimeError: the warehouse is not answering
moved 0, skipped 0 (drained)

Note the gaps between the attempts, and note that nothing slept in a handler to produce them. fixed_retry(3, 1s) is below the threshold, so the library holds the delivery rather than your handler — a wait the retry accounting knows about either way. The replay moved nothing because the watcher had already drained the dead-letter queue; leave it out and it moves the one message.

What to watch in production

Depth of *.dlq rising means something is failing permanently. This is the alert
Depth of *.retry.* rising means something is failing transiently, right now
Depth of *.parked anything above zero is a message nothing can decode
acemq.retry.rung.missing the one nothing else shows. A long backoff waited in the consumer because its rung queue is not on the broker: throughput and dead letters all read as normal while the reason the rung exists is quietly gone

Depth alone is not an alert. A queue is a buffer and it is supposed to have things in it — alert on depth that is not draining, or on the age of the oldest message, never on a threshold.

Next

Tutorial 3 — Never processing twice. Retries create duplicates by construction. What that means when the handler takes money.