Publishing

A publisher per destination

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

Connection.publisher takes an exchange and a routing key, and both default to the empty string. Publishing to the default exchange — no exchange, routing key equal to a queue name — is how a message goes straight to one queue:

direct = mq.publisher(routing_key="shipping.orders")

Build them at start-up and keep them. A Publisher holds a destination, a codec and two flags; it opens nothing, registers nothing and is safe to share across tasks, so one per message type costs nothing and puts the decisions in one place. Building one per message is not wrong, only pointless.

It is not a coroutine

mq.publisher(...) is an ordinary method call and only send is awaited. That is deliberate: nothing about constructing a publisher touches the broker, and a coroutine there would suggest it did.

Sending

result = await orders.send({"orderId": "o-1", "totalCents": 4250})
result.message_id   # the envelope's id, which is also the AMQP message id
result.confirmed    # the broker acknowledged it, and is always true here:
                    # a refusal raises rather than coming back as a field
result.routed       # it reached at least one queue

The payload is whatever the codec can encode. With the default JsonCodec that is a dict, a list, a scalar — or a dataclass, which goes on the wire as an object with its field names:

@dataclass
class OrderPlaced:
    order_id: str
    total_cents: int


await orders.send(OrderPlaced("o-1", 4250))

A publisher bound to an exchange rather than to one key can name the key per message:

events = mq.publisher("orders-events")
await events.send(payload, routing_key="order.cancelled")

What the envelope gets

send builds an envelope when none is given: a fresh identifier, a correlation identifier defaulting to it, this connection's origin, attempt 1, version 1, and the time now. The type falls back to the routing key, which is why a publisher bound to order.placed needs no further configuration to say what it sends.

Carrying context forward

A message published because of another one should say so, or a trace through four services has four unrelated identifiers in it:

async def handle(message: Message) -> Ack:
    await shipped.send(
        {"orderId": message.payload["orderId"]},
        envelope=message.envelope.with_(
            causation_id=message.envelope.id,
            type="order.shipped",
        ),
    )
    return accept()

with_ returns a copy, because Envelope is frozen: an envelope describes a message that has already been published or received, and changing one in place would change what a log line said after it was written.

Carrying the envelope forward keeps correlation_id — the field the whole chain shares — and causation_id names the immediate parent. Note what is not carried: id should be new for a new message, so pass id=str(uuid.uuid4()) or build a fresh Envelope and copy the correlation across:

Envelope(
    type="order.shipped",
    correlation_id=message.envelope.correlation_id,
    causation_id=message.envelope.id,
)

origin is filled in for you either way: an envelope arriving without one gets this connection's, because a reply is published by this process and an origin naming the service before it would be a lie in a log line somebody is going to trust.

Your own headers

Application headers live on the envelope, apart from the reserved ones:

await orders.send(
    payload,
    envelope=Envelope(type="order.placed", headers={"tenant": "acme"}),
)

A reserved name — anything in the x-acemq- set — is refused rather than dropped, with a ValueError naming the offenders. Silently discarding a header somebody set is worse than saying no. See the reserved namespace.

For a header every message needs, do not put it at every call site. That is what interceptors are for:

async def stamped(context: PublishContext, send: PublishNext):
    context.set_header("tenant", tenant.current())
    return await send(context)


mq.intercept_publish(stamped)

Several at once

results = await orders.send_all([first, second, third])

Every message goes out before any confirm is awaited, and only then are all of them checked together — the throughput of pipelining with the safety of having waited. A loop calling send waits for the broker between one message and the next, so a thousand messages on a link with 5 ms of latency cost five seconds of waiting that nothing needed. The results come back in the order the payloads did, whatever order the broker answers in.

This is not atomic. AMQP has no such thing: there is no way to publish a hundred messages such that all or none arrive, and a library that offered one would be lying. If any message fails, the rest of the batch is still awaited and then PublishError is raised naming how many were not confirmed and how many were:

2 of 100 messages were not confirmed; 98 were. The first failure was: ...

The counts are the point. A batch that half succeeded is the ordinary outcome of a broker problem partway through, and a caller told only "it failed" resends messages that already arrived. The named failure is named rather than printed: a failure with nothing in its message — the CancelledError every send gets when the connection closes underneath the batch is the common one — is reported by its class, so the sentence never ends The first failure was: and stops. __cause__ carries the first underlying failure in payload order — not the first one the broker answered — for callers that need to tell a rejection from a timeout:

try:
    results = await orders.send_all(batch)
except PublishError as failure:
    log.error("%s", failure)            # the counts
    log.error("%s", failure.__cause__)  # what went wrong first

How wide the batch gets on the wire is capped by the connection rather than by this method — see back pressure below. A list of a million is a million tasks and a thousand messages in flight, which is the difference between backpressure and a memory leak; a million tasks is still a million tasks, so chunk what comes out of a database rather than handing a cursor's worth of rows to one call.

The blocking API has the same method: sync.connect(...) gives a publisher whose send_all runs the batch on the loop thread and returns when every confirm is in.

Java spells this sendAll and .NET SendAllAsync, with the same ordering, the same partial-batch counts and the same wording in the failure. Go and Ruby have no equivalent yet.

Back pressure

max_outstanding_publishes — a thousand by default — caps how many publishes may be waiting for the broker at once, across the whole connection:

mq = await connect(
    "amqp://broker",
    max_outstanding_publishes=1000,          # the default
    confirm_timeout=timedelta(seconds=10),   # the default
)

The permit is taken before the message is written, which is the whole point: a bound applied after the write has already let the message into memory. Without one, a caller publishing faster than the broker confirms accumulates unconfirmed messages until the process dies — and that looks like throughput right up to the moment it does not.

It is the connection's bound and not a publisher's, so it counts everything: send, send_all, the retry rungs, the dead letters and a replay all come through one place. mq.outstanding_publishes is how many are waiting right now, and a number pinned at the ceiling is a broker that has stopped answering.

When every permit is taken and none comes free within confirm_timeout, the publish raises rather than waiting on:

acemq: cannot publish message 0f9c… to exchange '' with key 'orders.new':
1000 publishes are already waiting for a confirm and none completed within
0:00:10. The broker is not keeping up; publish more slowly rather than
buffering more.

That sentence is Java's word for word, so one runbook covers both. A publisher parked for ever behind a broker that has stopped answering is indistinguishable from a quiet service, which is the failure the deadline exists to prevent.

Java spells the setting maxOutstandingPublishes and .NET MaxOutstandingPublishes, both with the same default of a thousand. Java bounds its wait with confirmTimeout; .NET waits without one.

A blocked broker: refused, not failed

When RabbitMQ is low on memory or disk it blocks the connection and stops reading the socket until the alarm clears. A publish made while mq.blocked is True is refused at once with PublishingPausedError, before a permit is taken or a byte is written:

from acemq_amqp import PublishError, PublishingPausedError

try:
    await publisher.send(order)
except PublishingPausedError:
    # Declined, not lost: nothing was sent, so a retry cannot duplicate it.
    await asyncio.sleep(1)       # back off, shed load, or buffer
except PublishError:
    # Failed: the message may or may not have reached the broker.
    raise

It subclasses PublishError, so an existing except PublishError still catches it; catch it first to tell back pressure from a loss. Every other PublishError — a nack, an unroutable mandatory message, the permit deadline above — means the message may have been lost, and none of them is a PublishingPausedError. The permit deadline stays a failure on purpose: a broker that stopped confirming may be dead rather than busy, and Java, .NET and Go call it a failure too.

RabbitMQ only tells a connection it is blocked once it publishes, so the first publish after an alarm is written and then held with no confirm. It waits at most confirm_timeout (ten seconds by default) for that confirm and then raises a PublishError — failed, since it was written and may have arrived — instead of hanging until the alarm clears. The publish after it sees the block and is refused.

blocked being None — the state could not be read — is not treated as blocked. Go returns PublishingPausedError at the same point, .NET throws ConnectionBlockedException, and Java's standing load counts the same event as refused rather than failed.

Durability

transient = mq.publisher("metrics", "tick", persistent=False)

Messages are persistent by default: the broker writes them to disk and they survive a restart. persistent=False is for the ones where the next message makes the last one irrelevant — a gauge, a heartbeat, a cache invalidation on a short timer. It is faster, and it means a broker restart loses whatever was in the queue.

Persistence is not durability on its own. A persistent message on a non-durable queue still goes when the broker does; see exchanges, queues and bindings.

Messages that reach no queue

required = mq.publisher("orders-events", "order.placed", mandatory=True)
await required.send(payload)   # raises PublishError if nothing is bound

An unroutable message is the quietest failure AMQP has: the publish succeeds, the message is discarded, the consumer waits, and nothing anywhere says why. mandatory=True makes the broker return it, and this library raises PublishError rather than leaving the fact in the result, because a caller who does not read the result would otherwise carry on believing the message went somewhere:

from acemq_amqp import PublishError

try:
    await required.send(payload)
except PublishError as failure:
    log.error("%s went nowhere: %s", failure.message_id, failure)

Without mandatory, result.routed still says what happened — it is only the raising that is opted into.

A different codec for one publisher

from acemq_amqp import BytesCodec

raw = mq.publisher("files", "uploaded", codec=BytesCodec())
await raw.send(b"\x89PNG\r\n\x1a\n...")

The publisher's codec decides the content type on the wire, and a consumer picks its codec by reading that. See codecs.

What can go wrong

The payload will not encode TypeError from the codec, naming the type. Nothing is published
An interceptor refused whatever it raised, reaching the caller. Nothing is published — that is the point of intercepting rather than observing
The broker rejected the publish the transport's exception, unwrapped. acemq.publish.total is counted with outcome="failed"
Nothing was bound to receive it PublishError when mandatory=True; result.routed is False either way
The broker has blocked the connection PublishingPausedError, a PublishError. Nothing is published; retry once it unblocks

Most of what goes wrong with a broker is the broker's own exception, and this library does not wrap every one of them in a class of its own: that would hide the detail somebody actually needs while adding a name they then have to learn. The exceptions it does define are AceMQError, PublishError, PublishingPausedError and SecurityError, plus FatalError, which is a statement about a message rather than about the library.

Publisher confirms

Publisher confirms are on. send does not return until the broker has acknowledged the message, and result.confirmed says so. That is a round trip per message, which is the cost of knowing; a service that publishes in bulk should pipeline — send_all — rather than turning confirms off, because there is no way to turn them off here.

A refusal raises. If the broker declines to keep a message — a nack, which is different from an unroutable one — PublishError is raised:

cannot publish message o-1 to exchange "orders-events" with key "order.placed":
the broker refused to confirm it. The message reached the broker and the broker
has not taken responsibility for it, so it must be treated as not sent

So result.confirmed is always true on a result you are holding; a false one never reaches you. That is deliberate, and it was not always so. Until 0.7.2 a nack came back as confirmed=False and raised nothing, which meant a caller who did not inspect the field carried on believing the message had gone — and the outbox relay, which removes a record when the publish does not raise, deleted records for messages the broker had refused. Every other library in this family raises on a nack; now so does this one.

For the stronger guarantee — that a message and the database row it describes either both happen or neither does — see the outbox.