RabbitMQ¶
Haiway provides a context-aware RabbitMQ integration built on top of pika. It exposes typed queue
access through RabbitMQ state and message-level helpers through MQQueue and MQMessage.
Overview¶
- Context Managed: install a single
RabbitMQClientin a scope and resolveRabbitMQfrom context - Typed Queues: open queues with explicit encoder and decoder functions for your payload type
- Async Consumption: consume messages as
MQMessage[Content]values with async acknowledge and reject semantics - Queue Operations: declare, purge, and delete queues through state methods
Installation¶
Install the RabbitMQ extra to pull in pika:
Quick Start¶
Use RabbitMQClient as a disposable resource and open a typed queue from RabbitMQ:
import json
from haiway import MQMessage, ctx
from haiway.rabbitmq import RabbitMQ, RabbitMQClient
def encode_job(payload: dict[str, str]) -> bytes:
return json.dumps(payload).encode()
def decode_job(payload: bytes) -> dict[str, str]:
return json.loads(payload.decode())
async with ctx.scope("mq", disposables=(RabbitMQClient(),)):
await RabbitMQ.declare_queue("jobs", durable=True)
async with await RabbitMQ.queue(
"jobs",
content_encoder=encode_job,
content_decoder=decode_job,
) as queue:
await queue.publish({"task": "refresh"}, attributes={"trace": "abc"})
async with await queue.consume() as messages:
async for message in messages:
async with message as payload:
print(payload["task"])
break
Working with Queues¶
RabbitMQ.queue(...) returns an async context manager yielding MQQueue[Content].
await queue.publish(message, attributes=...)publishes one typed payload;attributesbecome AMQP headers on the message. Passexchange=to publish through a named exchange androuting_key=to route on something other than the queue nameawait queue.consume()returns an async context manager yielding an async generator ofMQMessage[Content]; each call registers its own consumer with its own buffer, so several concurrent consumers are supported- leaving the consume context cancels that one consumer at the broker and requeues the deliveries it buffered but never handed out, while the channel stays open for publishing and further consumers
- leaving the queue context closes the channel, which cancels whatever consumers are still running and requeues any delivery still in flight. It waits for the broker to acknowledge the close, so the consumers are really gone by the time the block exits
The encoder runs on publish and must return bytes. The decoder runs for consumed payloads and
should raise when the incoming bytes cannot be parsed into your target type.
Delivery Guarantees¶
By default publish waits for the broker to confirm the message and raises RabbitMQException when
it is rejected or cannot be routed to any queue, and messages are published with persistent delivery
mode. That makes an awaited publish mean what it looks like it means, at the cost of a round-trip
per message. Trade it for throughput explicitly:
RabbitMQClient(
publisher_confirms=False, # do not wait for broker acknowledgement
mandatory=False, # allow unroutable messages to be discarded
persistent=False, # transient delivery mode
)
mandatory requires publisher_confirms; with both enabled the adapter correlates an unroutable
return back to its publish through an x-haiway-publish-id header, since a Basic.Return carries
no delivery tag. That header is an adapter detail: it is stripped before meta reaches your code,
but it does travel on the wire, so consumers outside Haiway will see it.
Prefetch¶
Each consumer receives at most RABBITMQ_PREFETCH unacknowledged messages (8 unless the environment
variable overrides it), keeping the in-memory backlog bounded. The variable is read when the queue
is accessed, not when the module is imported, so load_env() applies regardless of import order.
Override it per queue with prefetch, where 0 restores unlimited delivery:
async with await RabbitMQ.queue(
"jobs",
content_encoder=encode_job,
content_decoder=decode_job,
prefetch=100, # at most 100 unacknowledged messages per consumer
) as queue:
...
Message Handling¶
MQMessage[Content] wraps the decoded payload plus broker callbacks.
async with await queue.consume() as messages:
async for message in messages:
async with message as payload:
await handle(payload)
Using the message as an async context manager acknowledges on success and rejects on exception. If you need manual control, call the settlement methods directly:
async with await queue.consume() as messages:
async for message in messages:
if should_retry(message.content):
await message.reject(requeue=True)
continue
await message.acknowledge()
message.meta carries the broker headers plus attempt, the number of this delivery counted from
RabbitMQ's x-delivery-count header, so it is 1 on a first delivery. Classic queues do not
publish that header, so there attempt falls back to the redelivered flag and saturates at 2 no
matter how many times the message was already delivered - declare the queue as
x-queue-type: quorum to count attempts properly. Use it to stop retrying a poison message:
async with await queue.consume() as messages:
async for message in messages:
if message.meta.get("attempt", 1) > 3:
await message.reject(requeue=False) # dead-letter it, or drop it without a DLX
continue
async with message as payload:
await handle(payload)
A payload the decoder cannot parse is rejected without requeueing regardless of the consumer's policy, since redelivering it would loop forever.
Rejecting without requeueing only dead-letters the message when the source queue was declared with
an x-dead-letter-exchange argument; without a DLX the broker discards it. Declare the queue with
one to keep those messages:
await RabbitMQ.declare_queue(
"jobs",
durable=True,
arguments={"x-dead-letter-exchange": "jobs.dlx"},
)
Queue Management¶
The RabbitMQ state also exposes queue-level operations:
from haiway.rabbitmq import RabbitMQ
await RabbitMQ.declare_queue("jobs", durable=True, arguments={"x-message-ttl": 60000})
await RabbitMQ.purge_queue("jobs")
await RabbitMQ.delete_queue("jobs", if_unused=True, if_empty=True)
These are @statemethods, so class calls resolve the current RabbitMQ instance from context. AMQP
queue arguments go through declare_queue(arguments=...); every other option a mistyped call could
name is rejected rather than taken for a queue argument or silently dropped.
Failure Handling¶
Every failure surfaces as RabbitMQException carrying operation, queue, and retryable, with
the originating pika error preserved as __cause__. Branch on retryable instead of matching on
the message text:
try:
await queue.publish(payload)
except RabbitMQException as exc:
if not exc.retryable:
raise # unroutable, or the encoder refused the payload
await backoff_and_retry(payload)
retryable is True for transient broker-side conditions - a lost connection or channel, a
timeout, a Basic.Nack, a consumer the broker cancelled, a recovery that ran out of attempts. It is
False where repeating cannot help: adapter misuse, an unroutable message, a payload the encoder
could not serialize, and settling a delivery whose channel is already gone (the broker requeues that
one regardless, so the work returns through a redelivery rather than through a retry).
Consumer Recovery¶
When the broker takes a consumer away - the channel or connection dropped, or it sent a
Basic.Cancel because the queue moved to another node - the adapter re-registers the consumer on a
replacement channel and the async for continues uninterrupted. It retries recovery_attempts
times (3 by default), immediately and then after recovery_delay seconds doubling each time, before
ending the iteration with a retryable RabbitMQException:
RabbitMQClient(
recovery_attempts=0, # end the iteration instead of re-establishing consumers
recovery_delay=1.0,
)
Recovery restores the subscription, not the work in flight. Everything unacknowledged goes back to
the queue when the channel dies, so a message being processed at that moment is delivered again -
settling it fails, and meta["attempt"] counts the redeliveries. Keep handlers idempotent, or
disable recovery where a duplicate is worse than a stopped consumer.
Flow Control¶
A broker under a resource alarm sends Connection.Blocked and stops reading from the socket, so no
confirmation can arrive until the alarm clears. That is reported as flow control rather than as an
opaque timeout, and logged as a warning when the alarm is raised and lifted.
Publish confirmations get their own publish_timeout (30 s) rather than sharing operation_timeout
(5 s), since a broker under load can take far longer to confirm a persistent write than to answer a
channel operation.
Operational Notes¶
- The connection URL defaults to
RABBITMQ_URL(amqp://localhost:5672when unset), read when the client is created rather than when the module is imported RabbitMQClient(url=..., connection_timeout=..., operation_timeout=..., publish_timeout=...)overrides connection settings;operation_timeoutbounds channel operations,publish_timeoutbounds publish confirmations- Queue access opens a channel on demand and reopens it for publishing if it closed
- Channel acquisition and teardown share one lock, so leaving the queue context concurrently with a publish or consume that is reopening the channel still closes that channel and ends its consumers
- Unsupported options passed to queue access, declare, publish, consume, acknowledge, or reject raise instead of being silently dropped or taken for AMQP arguments
- Leaving the client context waits for the
Connection.Closehandshake, so the socket is released before__aexit__returns rather than during event loop teardown - The queue is bound to its context; publishing or consuming through a
MQQueueafter its context exited raisesRabbitMQExceptioninstead of opening a channel nobody closes - Consumption is bound to its own context; leaving it releases the deliveries the iteration never asked for, which the broker would otherwise hold until the whole channel closed
- Decoder failures are logged and the message is rejected without requeueing, which dead-letters it
only when the queue has an
x-dead-letter-exchangeconfigured and discards it otherwise
Testing¶
Keep tests at the MQQueue or RabbitMQ protocol boundary by injecting fake queue accessors
instead of reaching a real broker.
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from haiway import MQMessage, ctx
async def one_message() -> AsyncGenerator[MQMessage[dict[str, str]]]:
# both settle callables accept backend-specific keyword options
async def acknowledge(**_: object) -> None:
return None
async def reject(**_: object) -> None:
return None
yield MQMessage(
content={"task": "refresh"},
acknowledge=acknowledge,
reject=reject,
meta={},
)
@asynccontextmanager
async def consume_once(**_: object) -> AsyncGenerator[AsyncGenerator[MQMessage[dict[str, str]]]]:
# consuming is scoped, so the fake is a context manager as well - and leaving
# it ends the message stream, exactly as cancelling a real consumer does
async with ctx.closing(one_message()) as messages:
yield messages
For application tests, prefer wiring a fake RabbitMQ state into ctx.scope(...) and asserting on
published payloads or consumed messages without network access. The adapter protocols are exported
for exactly that: