Interceptors
Every organisation has something every message needs and no library can guess: a tenant identifier, a trace context, an authorisation token, a schema version, a correlation identifier in the logging context, a timer, a size limit.
Without a seam these end up copied into every call site, where one of them is eventually forgotten and nobody finds out until the message that needed it is the one that went without it.
One function, handed the message and the rest of the work
import time
from acemq_amqp import Ack, ConsumeContext, ConsumeNext, PublishContext, PublishNext
async def stamped(context: PublishContext, send: PublishNext):
context.set_header("tenant", tenant.current())
return await send(context)
async def timed(context: ConsumeContext, handle: ConsumeNext) -> Ack:
started = time.monotonic()
try:
return await handle(context)
finally:
log.info("%s took %.3fs", context.queue, time.monotonic() - started)Register them on the connection:
mq = await connect(url, on_publish=[stamped], on_consume=[timed])Or afterwards, which still reaches publishers that already exist:
mq.intercept_publish(stamped).intercept_consume(timed)Both return the connection, so they chain. Reading the interceptors per message rather than at construction is what makes the second form work: a publisher built during start-up picks up an interceptor registered after it.
Why this shape
Java has a pair of before-and-after hooks. Python already has the
construct: try/finally runs the way out in the
reverse of the way in, nests correctly without anybody having to reverse
a list, and makes an interceptor that opens something and closes it
one function instead of two halves that have to agree
about a key in a map.
It composes the way ASGI middleware does, which is the arrangement a Python programmer already knows.
Order
Interceptors run in the order they were registered. First registered is outermost: it sees the message first on the way in and last on the way out.
stamped ──► authorised ──► sized ──► the publish
◄── ◄── ◄──
That matters as soon as one of them reads what another wrote. A tenant interceptor that must run before an authorisation interceptor is registered first.
What a publish interceptor can touch
PublishContext is a mutable dataclass — mutable rather
than rebuilt-and-returned, because an interceptor that only wants to add
one header should not have to reconstruct the rest, and one that reads
what an earlier interceptor wrote needs to be looking at the same object
rather than a copy of an earlier version.
exchange |
where it is going. Changing it redirects the message |
routing_key |
what it is published under |
envelope |
the metadata. Reserved names are still refused when it is rendered, whoever wrote them |
payload |
what is about to be encoded, before the codec sees it |
persistent |
whether the broker is asked to write it to disk |
mandatory |
whether reaching no queue is an error rather than a silence |
set_header(name, value) |
add one application header, without rebuilding the envelope |
payload being visible before the codec runs is the point
of putting the chain where it is. An interceptor can change the message
and not only its metadata — a field redacted, a body encrypted, a claim
check substituted for a payload too big to send — where a codec that had
already run would leave it holding bytes and nothing it could do with
them.
What a consume interceptor can touch
ConsumeContext, likewise mutable:
queue |
what it was read from |
envelope |
the metadata as it will reach the handler. Changing it changes what the handler is given |
payload |
the decoded body, likewise |
body |
the undecoded bytes, for an interceptor that wants to see what actually arrived |
content_type |
what the sender said the body was, or None |
routing_key |
the key it arrived under |
redelivered |
the broker saying it has handed this one over before |
state |
somewhere to leave something for a later interceptor, or for your own way out. This library never reads it |
Whatever the interceptors left is what gets used. An envelope
rewritten on the way in is the one the handler is given
and the one that gets dead-lettered, so an operator
reading {queue}.dlq is not missing the very field the
interceptor exists to add.
Knowing how the delivery really ended
The Ack an interceptor sees on the way out is what the
handler asked for, which is not always what happened. A handler
asking for another attempt when there are none left is dead-lettered,
and an interceptor that recorded the Ack would report a
retry that never happened — which is exactly how a trace backend ends up
with no dead letters in it and a dead-letter queue that is full.
when_settled closes that gap:
async def audited(context: ConsumeContext, handle: ConsumeNext) -> Ack:
def settled(settlement: Settlement) -> None:
if settlement.dead_lettered:
audit.record(context.envelope.id, settlement.reason)
context.when_settled(settled)
return await handle(context)A Settlement carries the outcome —
acked, retried, rejected,
dead_lettered or parked — the
reason it was set aside for, and the delay
before the next attempt. Its dead_lettered property is true
for a rejection as well: both end up in the same queue, and the
difference between the two words is who decided. It is
false for parked, which went to a
different queue on purpose; settlement.parked is the
property for that one.
The listener is called once, on the consumer's task,
after the decision and before it is carried out. Before, so that a
consumer-side backoff is not something you are made to wait through.
when_settled returns whether anything will ever call it —
False when the chain is being run by something other than a
consumer, so nothing should be waiting on an answer that is not coming.
A listener that raises is logged and otherwise ignored; the delivery
still has to be settled.
This is what the tracing adapter uses, and the reason a message that
ran out of attempts has a span saying dead_lettered — see
observability.
Refusing is raising
A publish interceptor that raises stops the publish, and the caller sees the exception. That is the whole point of being able to intercept rather than only observe: a message that must not go out can be stopped in one place rather than in every publisher.
async def within_limits(context: PublishContext, send: PublishNext):
if len(str(context.payload)) > 128 * 1024:
raise ValueError(f"{context.routing_key} is too big to publish")
return await send(context)A consume interceptor that raises is treated exactly as a handler that raised: retried, and then dead-lettered with the reason it gave. The alternative is acknowledging a message nothing processed.
Not calling send or handle at all, and
returning something instead, is the quieter form. A consume interceptor
can return accept() to swallow a message without running
the handler, which is how a filter or a deduplicator is written — see idempotent, which does
the same job as a handler wrapper.
Timing
acemq.consume.duration is measured around the
interceptors as well as the handler, because what an
operator wants to know is how long a message takes to deal with, and an
interceptor that opens a transaction is part of dealing with it.
They are public API all the way down
Everything an interceptor touches is public. An interceptor sees the envelope, the payload, the destination and the body, and may change any of them — and that is the same rule the patterns follow.
It is what keeps this a seam rather than a privileged back door: anything an interceptor can do, application code could have done, and nothing here depends on reaching inside the library.
Composing them by hand
publish_chain and consume_chain are
exported, so a test can run a chain without a broker:
from acemq_amqp import ConsumeContext, Envelope, accept, consume_chain
async def handler(context: ConsumeContext):
return accept()
chain = consume_chain([timed], handler)
ack = await chain(
ConsumeContext(
queue="shipping.orders",
envelope=Envelope(type="order.placed"),
payload={"id": "7"},
body=b'{"id": "7"}',
content_type="application/json",
routing_key="order.placed",
redelivered=False,
)
)The chain is folded backwards — the last interceptor wraps the work, the one before wraps that — which is what puts the first on the outside.
On the blocking API
sync.connect(...).connection is the asynchronous
connection underneath, so registering there applies to everything above
it:
with sync.connect(url) as mq:
mq.connection.intercept_consume(timed)The interceptor still runs on the loop thread; only the handler is moved to a worker. An interceptor that blocks blocks the loop, exactly as it would on the asynchronous API.