Getting started
The overview shows a whole program in one block. This takes it apart.
Install it
pip install --extra-index-url https://acemq.org/pypi/simple/ "acemq-amqp[rabbitmq]==0.7.12"The package is not on PyPI. Releases are served from AceMQ's own
static index, https://acemq.org/pypi/simple/
— no account, no credential, each link carrying the sha256
pip checks — and everything it depends on still comes from PyPI. The
current release is 0.7.12. pip merges the two indexes
and takes the highest version it finds under a name, whichever index it
came from, so pin the version rather than floating it. To have the
shorter pip install lines elsewhere in this guide work as
written, set the index once:
export PIP_EXTRA_INDEX_URL=https://acemq.org/pypi/simple/Python 3.10 or newer. The extra is the broker client —
aio-pika — and it is an extra rather than a dependency
because the contract layer needs nothing at all:
pip install acemq-amqp # envelopes, codecs, retry arithmetic
pip install "acemq-amqp[rabbitmq]" # and a way to talk to a broker
pip install "acemq-amqp[prometheus]" # and a registry to report intoA service that reads an envelope off a message somebody else
delivered — a log shipper, a replay tool, a test — installs the first
line and never links an AMQP client in. connect reaches for
aio-pika at the moment it is called rather than at import,
so the split is real rather than nominal.
One import
from acemq_amqp import Ack, Message, Topology, accept, connect, retryEverything worth naming is re-exported from the package root. There is no separate module to remember for the acknowledgements, the envelope or the topology.
Two names are worth reading twice. retry at the package
root is the acknowledgement — what a handler returns to ask for
another attempt — and it is what the Java, Go, Ruby and .NET libraries
call it too. The retry policy lives in
acemq_amqp.retry, and
from acemq_amqp.retry import RetryPolicy still reaches it;
exponential_retry, fixed_retry and
no_retry are re-exported from the root alongside
RetryPolicy itself.
Connect
mq = await connect("amqp://guest:guest@localhost:5672/")connect is a coroutine, so it is
await connect(...), and the connection is an async context
manager:
async with await connect("amqp://guest:guest@localhost:5672/") as mq:
...async with await reads oddly the first time. It is two
things: await connect(...) produces the connection, and
async with closes it — every consumer stopped and the
transport shut down — however the block is left.
Three keyword arguments are worth setting from the start:
mq = await connect(
"amqp://guest:guest@localhost:5672/",
origin="checkout@pod-7",
retry=exponential_retry(5, timedelta(seconds=1), timedelta(minutes=1)),
prefetch=20,
)origin is stamped on every message this connection
publishes and is what an operator reads off a dead letter to find out
who sent it. Left out, it is derived from the process and the host.
retry is the default policy for every consumer on this
connection; without one the default is one delivery and no
second chance. prefetch is how many unacknowledged
messages a consumer will hold, and defaults to 20.
For a broker that is not on this laptop, see security: an amqps:// URL and,
usually, a Security naming the authority that issued the
broker's certificate.
Declare what you use
await mq.declare(
Topology()
.exchange("orders-events", "topic")
.queue("shipping.orders", dead_letter=True)
.binding("shipping.orders", "orders-events", "order.placed")
)A Topology is a description before it is an action. It
can be printed and checked before anything reaches the broker:
topology = Topology().queue("shipping.orders", dead_letter=True)
print(topology)
for action in topology.plan():
print(action)dead_letter=True declares
shipping.orders.dlq and shipping.orders.parked
beside the queue and points the broker at them, so a message that runs
out of attempts or cannot be decoded lands somewhere an operator can
find by name. See exchanges, queues and
bindings.
Publish
publisher = mq.publisher("orders-events", "order.placed")
await publisher.send({"orderId": "o-1", "totalCents": 4250})publisher(...) is not a coroutine — it builds a small
object and does not touch the broker — so it is
mq.publisher(...) and only send is awaited.
Keep one per message type rather than building one per call: it is where
the codec, the durability flag and the destination are settled.
The payload is anything the codec can encode, which for the default
JsonCodec means anything json.dumps accepts —
a dict, a list, or a dataclass by way of
dataclasses.asdict. send returns a
PublishResult saying whether the broker confirmed it and
whether it reached a queue. See publishing.
Consume
async def ship(message: Message) -> Ack:
if not warehouse_is_up():
return retry(RuntimeError("the warehouse is not answering"))
place(message.payload)
return accept()
consumer = await mq.consume("shipping.orders", ship)The handler's return value is the decision, not an exception and not a side effect on a parameter:
accept() |
done. The message is 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) |
nothing could read it. Straight to {queue}.parked |
A handler that raises is treated as retry with that
exception, because in Python an exception is how a thing says it failed
and most failures it carries are transient. Raising
FatalError — or returning
retry(FatalError(...)) — skips the attempts that are left,
because they would all fail the same way.
A synchronous handler works too: the library accepts both
Callable[[Message], Ack] and
Callable[[Message], Awaitable[Ack]] and awaits what needs
awaiting. A blocking handler on the asyncio API still blocks the loop,
though — if the handler is blocking, use the sync API, which runs handlers
on worker threads for exactly that reason.
The consumer runs until it is closed, and is an async context manager as well:
async with await mq.consume("shipping.orders", ship):
await asyncio.Event().wait()See consuming.
Not running an event loop
Plenty of Python is not asyncio and has no reason to become asyncio because it sends a message:
from acemq_amqp import Topology, accept, sync
with sync.connect("amqp://guest:guest@localhost:5672/") as mq:
mq.declare(Topology().queue("shipping.orders", dead_letter=True))
mq.publisher(routing_key="shipping.orders").send({"id": "7"})
with mq.consume("shipping.orders", lambda message: accept()):
...It is a facade, not a second implementation. A loop runs on a thread
of its own and every call here is handed to it, so the envelope rules,
the codec negotiation and the retry engine are the ones in
acemq_amqp.connection — there is one set of retry
arithmetic in this library and it is not the sync module's. Handlers run
on a worker thread, so a blocking handler blocks nothing but itself.
sync.connect takes the same keyword arguments,
security= included. Reach for the asynchronous API when the
process is already asyncio, and this one otherwise.
A whole program
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from acemq_amqp import (
Ack, Message, Topology, accept, connect, exponential_retry, retry,
)
@dataclass
class OrderPlaced:
order_id: str
total_cents: int
async def main() -> None:
async with await connect(
"amqp://guest:guest@localhost:5672/",
origin="checkout@pod-7",
retry=exponential_retry(5, timedelta(seconds=1), timedelta(minutes=1)),
) as mq:
await mq.declare(
Topology()
.exchange("orders-events", "topic")
.queue("shipping.orders", dead_letter=True)
.binding("shipping.orders", "orders-events", "order.placed")
)
async def ship(message: Message) -> Ack:
print("shipping", message.payload, "attempt", message.envelope.attempt)
return accept()
async with await mq.consume("shipping.orders", ship):
await mq.publisher("orders-events", "order.placed").send(
OrderPlaced(order_id="o-1", total_cents=4250)
)
await asyncio.sleep(1)
asyncio.run(main())Against a local RabbitMQ, that prints the payload and the attempt number. Where to go from here: publishing and consuming for what each half can do, testing for running the same handler without a broker at all.