The idempotency contract¶
rabbitkit's "safe retries" promise means retries and dead-lettering are implemented correctly — it does not mean your handler can safely have side effects that aren't idempotent. This page explains exactly where that line is, because it's easy to assume more than rabbitkit actually guarantees.
The short version¶
RabbitMQ (and therefore rabbitkit) gives you at-least-once delivery. That means a message can be delivered — and your handler can run — more than once for the same logical event. This is not a bug and not something rabbitkit can remove; it's an inherent property of any messaging system that refuses to silently drop messages on a crash or a lost acknowledgement.
Your handler must be safe to run more than once with the same input. If it charges a card, sends an email, or writes a non-idempotent database row, rabbitkit's retry/DLQ correctness does not protect you from a duplicate side effect.
Where this actually bites, concretely¶
1. A successful handler, then a failed result-publish¶
@broker.publisher(exchange="notifications", routing_key="order.confirmed")
@broker.subscriber(queue="orders")
def handle_order(body: bytes) -> bytes:
charge_card(body) # (1) side effect: real, already happened
return b'{"status": "confirmed"}' # (2) published to "notifications"
If step (1) succeeds but the publish of the return value in step (2) fails
(a transient broker hiccup, a full channel pool, a connection drop), the
source message is not acked — it's nacked and requeued, exactly as
you'd want for at-least-once safety around the publish. But that means
handle_order runs again on redelivery, and charge_card(body) runs a
second time.
This is not a theoretical edge case — it's the direct, structural
consequence of "don't ack a message whose result we couldn't confirm was
published." rabbitkit escalates this from a quiet WARNING to a loud
ERROR in the logs when it detects the message has already been
redelivered once and is still failing to publish its result, specifically
so a sustained outage doesn't hide in routine-looking log noise — but the
handler still reruns. There is no way to fix this generically inside the
library, because only your handler knows what "already done" means for
charge_card.
2. Ordinary redelivery after a crash or a lost ack¶
Even with no result-publish involved: if your process crashes (or is
SIGKILLed by Kubernetes past terminationGracePeriodSeconds) after a
handler's side effect completes but before the ack reaches the broker,
RabbitMQ has no way to know the work was done — it redelivers the message.
Same outcome: your handler runs again.
3. Retry after a transient failure¶
This is the case RetryConfig is explicitly for, and it's usually fine —
but only if the handler is written to be re-run safely. A handler that
partially completes (e.g. it debited an internal ledger, then failed before
crediting the destination) and gets retried from the top can leave your
data in a state neither "not done" nor "done" describes correctly.
What "idempotent" means here, precisely¶
Your handler is idempotent (for rabbitkit's purposes) if:
Running it twice, three times, or a hundred times with the same message body produces the same observable outcome as running it once — no duplicate charges, no duplicate rows, no duplicate emails, no double-decremented inventory.
It does not mean the handler must be free of side effects. It means the side effects must be safe to repeat.
How to make a handler idempotent¶
Pick whichever of these fits the side effect:
- Idempotency keys. Generate (or receive) a stable key per logical
operation (e.g.
order_id, or a UUID you control) and have the downstream system (payment processor, database, email provider) dedupe on that key. Most payment APIs support this natively (Stripe'sIdempotency-Keyheader, for example) — use it. - Upsert instead of insert.
INSERT ... ON CONFLICT DO NOTHING/ON CONFLICT DO UPDATEinstead of a bareINSERTthat fails or duplicates on a second attempt. - Check-then-act inside a transaction. Look up whether the operation already happened (by the same key) before performing it, inside the same transaction that records it as done, so there's no window for a concurrent duplicate.
- A dedup table /
DeduplicationMiddleware. rabbitkit ships a Redis-backedDeduplicationMiddlewarethat skips a message it's already seen (bymessage_id,correlation_id, or a body hash) within a TTL window. This reduces duplicate processing — it does not eliminate it (Redis can evict, lag, or be temporarily down;fallback_on_redis_errordefaults to processing anyway rather than blocking on a Redis outage). Treat it as a second layer, not the source of truth. Application-level idempotency (the previous three bullets) is the real guarantee. - Publish before the irreversible side effect, not after. If you have the choice, structure the handler as "record intent" → "check if intent was already fulfilled" → "fulfill" → "record fulfillment," so a crash at any point leaves you with an idempotency key to check against on redelivery, instead of an ambiguous partial state.
- A transactional outbox, for the strictest cases: write the side effect and the "I did this" record in the same local database transaction, and have a separate process publish from the outbox. This is real architectural weight — reach for it only when the two options above genuinely aren't enough (e.g. no idempotency-key support downstream, and the side effect is high-value).
What rabbitkit does and doesn't do for you¶
| rabbitkit's behavior | |
|---|---|
| A transient handler exception | Classified, retried with backoff via the delay-queue topology, dead-lettered on exhaustion. Correct and tested end-to-end. |
| A spoofed/corrupted retry-count header | Clamped to [0, max_retries] — can't be used to force infinite retries or skip to the DLQ early. |
| A failed result-publish after a successful handler | Nacks and requeues the source message (never falsely acks a lost result) — but that means the handler reruns. Escalates to ERROR logging on a repeat failure so it's not silent. Does not make your side effect idempotent for you. |
| A crash between side effect and ack | Redelivered, like any at-least-once system. Same requirement applies. |
| Deduplication | DeduplicationMiddleware reduces duplicate processing within a TTL window. It is a mitigation, not a substitute for idempotent handlers. |
The one-sentence version to remember¶
If your handler's side effect can't be repeated safely, at-least-once delivery — which is what "safe retries" is built on — will eventually repeat it. Design for that, don't fight it.
Result replay: the idempotent-receiver effect¶
DeduplicationConfig(mark_policy="claim", store_results=True) upgrades
dedup from "skip the duplicate" to "answer the duplicate": on handler
success the completed key stores the handler's JSON-serializable result,
and a later duplicate delivery replays it — the pipeline re-publishes
the stored result byte-identically to the route's result publisher /
reply_to, so a redelivered RPC request gets the same answer without the
handler's side effects running twice.
This is the strongest duplicate posture rabbitkit offers, and it is still
not exactly-once delivery — that does not exist on RabbitMQ. It is the
idempotent-receiver effect: at-least-once transport, at-most-once
handler execution per key, deterministic responses. Degradations are
graceful and never fail the handler: results that aren't JSON-serializable
or exceed max_result_bytes store the plain completed mark (the duplicate
skips without replay), and keys written by older deployments read as
completed-without-replay — rolling upgrades are safe.