Consuming
async def ship(message: Message) -> Ack:
place(message.payload)
return accept()
consumer = await mq.consume("shipping.orders", ship)consume subscribes and returns a running
Consumer. It is a coroutine because subscribing is a round
trip to the broker.
The decision is the return value
from acemq_amqp import accept, park, reject, retry
accept() # done. Acknowledged and gone
retry(error) # try again, if the policy has an attempt left
reject(error) # do not try again. Straight to {queue}.dlq
park(error) # unreadable. Straight to {queue}.parkedFour functions returning an Ack, not four exceptions and
not a mutable parameter. The value carries the error with it, and that
error is what ends up in x-acemq-error on the dead letter,
so an operator draining shipping.orders.dlq reads
why rather than only that:
return retry(RuntimeError("the warehouse is not answering"))The reason is worth writing properly.
exhausted 5 attempts: RuntimeError: the warehouse is not answering
is a message somebody can act on;
exhausted 5 attempts: Exception is not.
A handler that raises
An exception escaping the handler is treated as retry
with that exception. That is deliberate: in Python an exception is how a
thing says it failed, and most of the failures it carries are transient,
so the useful default is another attempt. It is also what the Java
library does with the same situation, and what a handler written without
reading this page will expect.
To say the opposite, raise FatalError:
from acemq_amqp import FatalError
if message.payload.get("version") != 2:
raise FatalError("this producer is on a schema this consumer cannot read")FatalError skips the attempts that are left, because
they would all fail the same way. Returning
retry(FatalError(...)) means the same thing — the mark on
the error is honoured over the request in the acknowledgement, which is
the point of having it.
Retry or reject?
retry for anything that might work next time: a timeout,
a dependency that is down, a lock somebody else holds.
reject for anything that will not: a payload that fails
validation, an entity that does not exist and will not appear, a message
this consumer is not the right version to read.
A rejected message goes to {queue}.dlq immediately, with
no attempts spent.
Reject or park?
reject is understood, and refused — a bad
request. park is never understood at all: a body
in a shape nothing here can make sense of, a schema version this service
was not taught, a field whose type is not what the name says it is
anywhere.
A parked message goes to {queue}.parked immediately,
which is the queue the engine already uses for a body its codec could
not decode. Keeping the two apart is the whole reason that queue exists.
Everything in {queue}.dlq is a message this service
understood and could not process — usually a business problem, and
usually transient in aggregate. Everything in
{queue}.parked is a message nobody could read — almost
always one producer emitting rubbish, and findable in seconds so long as
it is not filed among the thousands that merely ran out of attempts.
if message.payload.get("schema") not in KNOWN_VERSIONS:
return park(ValueError(f"schema {message.payload.get('schema')} is not one this service reads"))park was added so a handler could reach that queue at
all. Before it, a handler that knew a message was unreadable had to
reject it into the dead letters and lose the
distinction.
What the handler receives
Message is a frozen dataclass:
payload |
the decoded body, from the connection's codec |
envelope |
the envelope: id, type, correlation, attempt, origin, first seen, your headers |
routing_key |
what it was published under |
content_type |
what the producer said the body was, or None |
redelivered |
the broker's flag: this delivery has been attempted before |
body |
the raw bytes, before the codec |
reply_to |
AMQP's own reply-to property, or "". See
request and reply |
redelivered is the broker's own flag and is not the same
as envelope.attempt. The attempt count is on the message
and advances when this library republishes it; redelivered
is set when the broker hands the same delivery back, which
happens after a consumer dies without settling. A message can be on
attempt 1 and redelivered, and that is a useful thing to notice — see idempotency.
body is there for the handler that wants the bytes as
they were: writing them to a log, forwarding them unchanged, or
comparing them against a signature.
Concurrency
consumer = await mq.consume("shipping.orders", ship, concurrency=8)One by default, which keeps a queue's messages in order. Raising it trades that order for throughput, which is the right trade for handlers that spend their time waiting on something else — a database, an HTTP call — and the wrong one where a later message about the same entity must not overtake an earlier one.
It is not the loop's concurrency by accident: the consumer runs
exactly concurrency worker tasks and feeds them from a
queue of its own. Handing every delivery straight to a task would make
prefetch the real concurrency limit, which is not what anyone asked
for.
Where ordering matters for some messages and not others, keep the concurrency and order by key:
from acemq_amqp.patterns import by_header, ordered
await mq.consume("shipping.orders", ordered(by_header("orderId"), ship), concurrency=8)See ordering.
A group is the other axis. concurrency
runs several handlers on one consumer sharing one channel and one
prefetch; a ConsumerGroup runs several consumers, each with
its own. Reach for a group when one channel's prefetch is the limit, or
when a fair share across processes matters. See consumer groups.
Prefetch
consumer = await mq.consume("shipping.orders", ship, prefetch=100)How many unacknowledged messages the broker will send before waiting.
The connection's prefetch — 20 by default — applies unless
the consumer overrides it.
Too low and the consumer waits on the network between messages. Too
high and one instance takes a share of the queue it cannot work through,
so a second instance starves and a restart redelivers a pile. Twenty is
a reasonable default for handlers that take tens of milliseconds; raise
it for handlers that take one, and drop it towards
concurrency for handlers that take seconds.
A consumer waiting out a short retry delay holds its delivery and therefore one prefetch slot. That is one of the two reasons long waits go to the broker instead; see where the waiting happens.
Naming the consumer
consumer = await mq.consume("shipping.orders", ship, tag="shipping@pod-7")The tag is what the consumer is called in
rabbitmqctl list_consumers and in the management UI. Left
out, the broker generates one, and an operator looking at four identical
generated tags cannot tell which pod is the slow one.
args passes broker-specific consumer arguments straight
through — a stream offset, a single-active-consumer flag, a
priority.
What starting a consumer declares
Before it subscribes, a consumer declares the queues it will need
when a message fails: {queue}.dlq,
{queue}.parked, acemq.dlx and — when its retry
policy has waits long enough for the broker to hold —
acemq.retry and the rungs. Not the source queue, which
belongs to whoever set the service up.
That is a floor, not a replacement for a topology:
Topology is reviewable ahead of time and is the only place
x-dead-letter-exchange can be put on the source queue. What
it buys is the service deployed without one, where a dead letter used to
be republished to a queue that was not there and dropped by the broker
in silence.
consumer = await mq.consume("shipping.orders", ship, declare=False)Turn it off for a login with no configure permission on
the vhost — the broker refuses rather than ignores the attempt, so the
consumer would not start — and for a tool draining a queue it does not
own. See who declares
what.
A different codec
from acemq_amqp import BytesCodec
await mq.consume("shipping.orders.dlq", inspect, codec=BytesCodec(), declare=False)The connection's codec applies unless a consumer overrides it.
BytesCodec is what to read a dead-letter queue with: the
message that went there may be exactly the one nothing could decode, and
a codec that fails on it would park it a second time. See codecs.
declare=False for the same reason: a one-off reader of
shipping.orders.dlq has no business creating
shipping.orders.dlq.dlq.
When a message cannot be decoded
It goes to {queue}.parked, not to
{queue}.dlq, with x-acemq-error saying
could not be decoded: ....
That is a different queue on purpose. A message that failed five times and a message nothing could read are different problems with different answers — one is usually the world, the other is usually a producer — and whoever drains the dead letters should not have to sort them by hand. A body that will not decode decodes no better next time, so it does not go round the retry schedule until it ages out either.
Topology().queue(name, dead_letter=True) declares
{name}.parked alongside {name}.dlq for this
reason. A library that parks messages into a queue nobody declared has
only moved the disappearance somewhere else.
A handler that returns the wrong thing
A handler returning something that is not an Ack — most
often because a branch forgot to return at all, and returned
None — has its message dead-lettered with
the handler returned NoneType instead of an Ack. Not
acknowledged, and not retried into a loop: the message is set aside and
the reason says what the bug is.
Stopping
await consumer.close()Or as a context manager, which closes it however the block is left:
async with await mq.consume("shipping.orders", ship):
await asyncio.Event().wait()close unsubscribes, then waits for the handlers already
running to finish and settle — for up to timeout seconds,
twenty by default, the same figure Java, .NET and Ruby use
(DEFAULT_DRAIN_TIMEOUT; None waits for as long
as they take). A message that had been delivered but not started is
given back to the broker rather than held: it is the
broker's to hand to another consumer, and working through a retry delay
for it would make closing take as long as the schedule.
A handler still running when the time is up is cancelled, and its
message is left unsettled — neither acknowledged nor
rejected — so the broker redelivers it once the channel goes. Shutdown
is never the reason a message is lost or dead-lettered.
close says which way it went:
if not await mq.close(timeout=10):
log.warning("shutdown cut handlers off; their messages will be redelivered")True means every handler finished, False
that some were cut off. Ignoring the answer is fine: the consumer is
stopped either way.
Closing the connection closes every consumer on it first, so a
service that exits its async with await connect(...) block
has already drained. The bound is one deadline for every handler of
every consumer together, not one each. The async with exit
uses the default.
A caller that gives up on closing —
asyncio.wait_for(mq.close(), 5), or a task that is
cancelled — is told so: the running handlers are cancelled the same way,
the connection is still released, and the cancellation (or the
TimeoutError) is raised again.
The blocking API takes the same timeout and returns the
same answer — SyncConsumer.close(timeout=...) and
SyncConnection.close(timeout=...) — with one difference a
thread forces on it: a handler still running at the deadline is
abandoned, not stopped. Python cannot cancel a thread, so it
carries on until the handler returns by itself, and whatever it returns
is discarded. Its message is left unsettled exactly as above and
redelivered; a thread that finishes late cannot settle anything, because
the settlement is the consumer's and the consumer has gone. The thread
is not a daemon, so the interpreter waits for it at exit — a handler
that can hang should carry its own timeout.
Closing is not itself a cancellation, though. A shutdown that cancelled the tasks on the loop before closing the connection leaves workers that are already cancelled; closing does not report those, because a caller who is merely shutting down should not be told that they were cancelled. A worker that failed on its own is still reported.
What the consumer will tell you
consumer.queue # the queue it reads
consumer.running # subscribed, with workers left to hand a delivery to
consumer.in_flight # how many messages it is working on right now
consumer.closed # whether it has been stoppedrunning is the interesting one and is what
Connection.health() reads. A consumer whose workers have
all finished without it being closed is one the broker is still sending
messages to and nothing is reading — indistinguishable from a quiet
queue from outside. See metrics, health and
tracing.