Production patterns — reference implementations¶
The shortest path to a deployment that survives broker restarts, deploys, poison messages, and 3 a.m. Written from the architect's seat: every knob below is set for a reason, and the reason is a specific production incident it prevents. Copy the reference code, then delete what you can justify deleting.
Companions: the scale & reliability handbook (throughput math, reconnect/heartbeat/retry mechanics end to end), the production checklist (the audit view of the same material), the idempotency contract (read it before any handler with side effects), Kubernetes guide, troubleshooting.
1. The reference consumer (async)¶
One file, production-shaped. Numbered comments are explained below the code.
"""orders_worker.py — reference production consumer."""
import asyncio
import os
from aiohttp import web
from rabbitkit import (
AsyncBroker,
ConnectionConfig,
ConsumerConfig,
LoggingConfig,
PublisherConfig,
RabbitConfig,
RabbitQueue,
RetryConfig,
broker_liveness,
broker_readiness,
)
from rabbitkit.core.types import QueueType
from rabbitkit.serialization import JSONSerializer
def make_config() -> RabbitConfig:
return RabbitConfig(
connection=ConnectionConfig(
host=os.environ.get("RABBITMQ_HOST", "127.0.0.1"), # (1)
port=int(os.environ.get("RABBITMQ_PORT", "5672")),
username=os.environ.get("RABBITMQ_USER", "guest"),
password=os.environ.get("RABBITMQ_PASSWORD", "guest"),
vhost=os.environ.get("RABBITMQ_VHOST", "/"),
nodes=tuple( # (2)
n for n in os.environ.get("RABBITMQ_NODES", "").split(",") if n
),
heartbeat=30, # (3)
blocked_connection_timeout=60.0, # (4)
connection_name=f"orders-worker@{os.environ.get('HOSTNAME', 'local')}", # (5)
),
consumer=ConsumerConfig(
prefetch_count=16, # (6)
graceful_timeout=45.0, # (7)
),
publisher=PublisherConfig(
confirm_delivery=True, # (8)
persistent=True,
),
retry=RetryConfig( # (9)
max_retries=4,
delays=(5, 30, 120, 600),
),
logging=LoggingConfig(render_json=True), # (10)
)
broker = AsyncBroker(make_config(), serializer=JSONSerializer()) # (10b)
ORDERS_QUEUE = RabbitQueue(
name="orders",
queue_type=QueueType.QUORUM, # (11)
durable=True,
delivery_limit=6, # (12)
consumer_timeout=3_600_000, # (13)
)
@broker.subscriber(queue=ORDERS_QUEUE)
async def handle_order(body: dict) -> None:
"""MUST be idempotent — at-least-once delivery means this can run
twice for the same order (see docs/production/idempotency.md).
Pattern: make the side effect keyed on a natural/business id so the
second run is a no-op, e.g. INSERT ... ON CONFLICT DO NOTHING on
order_id, or an idempotency key on the downstream API call."""
order_id = body["id"]
await process_order(order_id, body) # (14)
async def process_order(order_id: str, body: dict) -> None:
... # your business logic
# ── Health endpoints: served IN-PROCESS (exec probes cannot work) ── (15)
async def _live(_r: web.Request) -> web.Response:
ok = broker_liveness(broker)
return web.Response(status=200 if ok else 503, text="ok" if ok else "wedged")
async def _ready(_r: web.Request) -> web.Response:
ok = broker_readiness(broker)
return web.Response(status=200 if ok else 503, text="ok" if ok else "not ready")
async def serve_health() -> web.AppRunner:
app = web.Application()
app.router.add_get("/healthz", _live)
app.router.add_get("/readyz", _ready)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "0.0.0.0", 8080).start()
return runner
async def main() -> None:
runner = await serve_health()
try:
await broker.run() # (16)
finally:
await runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())
Why each number:
127.0.0.1, notlocalhost, for local dev — avoids IPv6::1vs IPv4-only-Docker connection resets. In cluster, the service name.nodes=for cluster failover — a singlehost=against a multi-node cluster means one dead node takes the client down for the whole backoff window.heartbeat=30— dead-peer detection in ~60 s. The negotiated value is min(client, server); 30 beats the server's 60 default.blocked_connection_timeout=60— a memory/disk alarm makes publishes stall while everything looks connected. This fails the connection fast instead, and readiness (not liveness) goes red.connection_name— shows in the management UI. Costs nothing; priceless mid-incident.prefetch_count=16— the consume-side concurrency AND memory bound, per queue. Size it: worst-case handler seconds x desired parallelism, then checkprefetch x avg_message_sizefits in memory.0is rejected at construction (it would mean unlimited in AMQP — the whole backlog in RAM).graceful_timeout=45— the drain budget on SIGTERM; must exceed your worst-case handler. KubernetesterminationGracePeriodSecondsmust exceed this plus your preStop sleep.- Confirms + persistence on (the defaults, stated explicitly) — retry/DLQ correctness assumes the retry republish is confirm-gated.
- The retry ladder — one delay queue per attempt, uniform TTL per
queue (broker-enforced backoff; the consumer never sleeps).
retry=declares the topology AND installs the middleware — one switch. - JSON logs — for the aggregator, with secret redaction built in.
(10b)
serializer=JSONSerializer()— this is what turns raw bytes into your handler's annotation; without it, abody: dicthandler receivesbytesand dies on first index. (Verified by executing this exact file against a real broker — the missing serializer is the most common "works in the README, fails in prod" mistake.) - Quorum queue — replicated; a node death doesn't take the queue
(or its messages) with it. The auto-declared
orders.dlqinherits quorum too — the DLQ stores failures indefinitely, exactly the data you chose replication for. delivery_limit=6— the broker-enforced poison-message backstop, independent of every client-side mechanism. Size it abovemax_retries + 1so it only fires when the app-level ladder is somehow bypassed.consumer_timeout=(1 h) — only if handlers can legitimately hold a delivery past the server's 30-minute ack timeout (which the server never advertises; the symptom is a channel force-closed mid-handler). Delete this line if your handlers finish in minutes.- The handler takes
dict— body deserialization is pipeline work. Keep handlers pure business logic; middleware owns cross-cutting concerns. - In-process HTTP health, never exec probes — an exec probe spawns a
fresh Python that cannot see the running broker.
broker_livenessignores broker connectivity (an outage must not restart pods — the reconnect loop itself refreshes the heartbeat);broker_readinessgoes red on disconnect, dead consumer channel, or a blocked connection. await broker.run(), not barestart()—run()joins the signal-driven drain, so SIGTERM finishes in-flight work before the process exits instead of fire-and-forgetting the stop.
2. The reference publisher¶
from rabbitkit import MessageEnvelope, MessageTooLargeError, PublishError
from rabbitkit.core.types import PublishStatus
async def publish_order_event(broker, order_id: str, payload: bytes) -> None:
"""Publish with the outcome actually checked — the API never raises on
its own; ignoring the outcome is how fire-and-forget sneaks back in."""
try:
outcome = await broker.publish(
MessageEnvelope(
exchange="events",
routing_key="orders.created",
body=payload,
message_id=f"orders.created:{order_id}", # stable id → enables dedup
mandatory=True, # unroutable → RETURNED, not void
)
)
except MessageTooLargeError:
# >16 MiB (the server would reject it anyway, destructively).
# Store the payload externally and publish a reference instead.
raise
outcome.raise_for_status() # NACKED/TIMEOUT/RETURNED/ERROR → PublishError
# If something else is settled on the strength of this publish
# (e.g. acking an inbound message), require a REAL broker confirm:
assert outcome.status is PublishStatus.CONFIRMED
Rules of the road:
- Never ignore
PublishOutcome..raise_for_status()is the one-liner..okisTrueforSENT(written to the socket, unconfirmed) as well asCONFIRMED— when durability gates another action, checkCONFIRMEDspecifically. mandatory=Trueon anything that must not vanish — an unroutable message comes back asRETURNEDinstead of being confirmed into the void by a binding typo.- A stable
message_idmakes consumer-side deduplication (DeduplicationMiddleware+ Redis) possible later without producer changes. - Bursty producers: wrap publishes with a
FlowController(BackpressureConfig(max_in_flight=...)) so a slow/blocked broker applies backpressure to your code instead of ballooning memory. - High throughput:
AsyncBroker+AsyncBatchPublisher(confirms pipelined). Don't scale sync publishers with threads — pika serializes confirms per channel.
3. If you must use the sync broker¶
The sync broker is production-grade with three rules the async one doesn't need:
- Consumers use
broker.run(worker_config=WorkerConfig(worker_count=N))withN > 1whenever handlers are slow or publish with confirms — single-worker handlers run on the connection's I/O thread, where a slow handler starves heartbeats and a confirm wait is unbounded (a startupRuntimeWarningflags both). - Publish-only processes call
broker.pump_idle()from their idle loop — nothing else services heartbeats between publishes, and the first publish after a quiet night otherwise hits a dead socket (it now reconnects and retries once, but proactive pumping avoids the hiccup). - Everything connection-touching happens on the thread that called
start()— worker-thread settlement is marshalled for you; don't wire your own cross-thread calls. A non-owner thread touching a dead connection gets a clean error instead of corrupting the connection — treat that error as transient.
Roll out sync multi-worker services through one canary deploy + broker restart drill before the fleet (see the checklist).
4. Topology lifecycle: how production avoids every 406¶
AUTO_DECLARE (the default) is right for dev and for services that own
their queues. At scale, split ownership:
# One-time provisioning job / CI step, privileged credentials:
provisioner = AsyncBroker(RabbitConfig(topology_mode=TopologyMode.AUTO_DECLARE))
# declare queues, exchanges, retry ladders — then exits.
# The long-running service, least-privilege credentials, no `configure` grant:
service = AsyncBroker(RabbitConfig(topology_mode=TopologyMode.PASSIVE_ONLY))
# verifies entities exist; never re-declares → argument drift cannot 406 at 3 a.m.
This is also the upgrade story: when a rabbitkit version changes what it
would declare (e.g. quorum DLQ inheritance — see docs/migration.md), a
PASSIVE_ONLY service is untouched; you reconcile topology deliberately in
the provisioning step. Never set on_topology_conflict="warn_continue" on
a reliability path — rabbitkit refuses to sever a dead-letter path even in
that mode, but the mode's whole premise is accepting drift you haven't
reviewed.
5. Anti-patterns → what to do instead¶
| Anti-pattern | The incident it causes | Instead |
|---|---|---|
| Handler with side effects, no idempotency | Duplicate charges/emails on every redeploy or redelivery | Key side effects on a business id; read the contract |
Ignoring PublishOutcome |
Silent loss under broker pressure, discovered weeks later | .raise_for_status(), CONFIRMED when it gates settlement |
time.sleep()/asyncio.sleep() retry loops in handlers |
Prefetch slots pinned, heartbeats starved, throughput collapse | retry=RetryConfig(...) — the broker does the waiting |
| Classic queues for money-path traffic | One node's disk loses the queue | Quorum + delivery_limit (the DLQ inherits quorum) |
prefetch_count sized by vibes |
OOMKill under backlog (too high) or idle workers (too low) | Size by handler time x parallelism; memory-check it |
| Liveness probe checks broker connectivity | Broker maintenance restarts every pod simultaneously | broker_liveness for liveness, broker_readiness for readiness |
| exec-based k8s probes | CrashLoopBackOff — a fresh process can't see the broker | In-process HTTP endpoints (§1) |
confirm_delivery=False to "go faster" on reliability paths |
A lost republish acks the source anyway | Keep confirms; use batch publishing for throughput |
Producers setting x-rabbitkit-retry-count / x-rabbitkit-original-queue |
Nothing — rabbitkit clamps/overwrites them — but it signals confusion | Producers set business headers only |
| Sharing one queue between two handlers | DuplicateRouteError (by design) |
One handler + filter_fn, or two queues on one exchange |
| Deploying from stale docs/runbooks | Probes and topology that don't match the shipped code | The docs ship with the version — mkdocs site or the repo at your installed tag |