--- title: "Idempotency" description: "The meta.id consumption contract and the optional dedupe helper every SDK ships (ADR-0022) — a seen-set Store keyed on meta.id, wrapped around a handler so an already-processed message is skipped, not run twice. Single-process InMemory store for tests; a shared store for production fleets. The wire envelope is untouched at schema_version 1." source: https://babelqueue.com/docs/spec/1.x/idempotency/ updated: 2026-06-21T00:00:00.000Z --- # Idempotency At-least-once delivery is the floor every broker gives you: a message **can** arrive more than once — after a worker crash before ack, a redelivery, or a manual replay. The [wire contract](/docs/spec/1.x/envelope/) already carries the one field that makes de-duplication possible across every language: `meta.id`, the unique identity of **this specific message** (distinct from `trace_id`, which spans a whole causal chain — see [`id` vs `trace_id`](/docs/spec/1.x/envelope/#id-vs-trace_id--do-not-conflate)). This page is the cross-SDK contract for **consuming `meta.id`** and the optional, dependency-free **dedupe helper** each SDK ships to enforce it (ADR-0022). > **Status:** Authoritative · Helper layer · envelope frozen at `schema_version: 1` This is a **tooling layer**, not a wire change. The helper adds nothing to the envelope and removes nothing — `meta.id` is already in `schema_version: 1`. Idempotency is a choice the consumer makes about how it treats that field. ## The consumption contract Handlers SHOULD be idempotent (error-handling §1): processing the same `meta.id` twice MUST be safe. The helper gives you a mechanical way to honour that without rewriting each handler — it is **seen-set** dedupe, keyed on `meta.id`: 1. **Read `meta.id`.** It is the per-message key. A message with no usable `meta.id` runs unchanged (**fail-open**) — the helper never drops work it cannot identify. 2. **If the id was already processed, skip and return.** The runtime acks it, so the broker stops redelivering. 3. **Otherwise run the handler, then — on success only — remember the id.** A handler that throws leaves the id **unmarked**, so retry / [dead-letter](/docs/spec/1.x/dead-letter-and-tracing/) still apply and a later delivery runs the handler again. This is **post-success dedupe under at-least-once**, not exactly-once and not an in-flight concurrency lock. The store answers *"was this id processed?"* — never *"what did it return"*, because queue handlers have no response to replay (unlike an HTTP idempotency key). The narrow at-least-once window — the handler succeeds but the mark fails — is documented and bounded by the "handlers are idempotent" guarantee: a redelivery simply reprocesses harmlessly. ## The `Store` Every SDK exposes the same three-method record of processed ids, keyed on `meta.id`: | Operation | Meaning | | :--- | :--- | | `seen(id)` | Has this id already been processed (remembered)? | | `remember(id)` | Record this id as processed. | | `forget(id)` | Drop an id (manual eviction; a backend may also expire on its own TTL). | Each SDK ships a reference **in-memory** store implementing those three methods. It is process-local and **not** persistent — fine for tests and a single-process consumer, but **not** for a production fleet, where two workers each have their own map and neither sees the other's "seen" set. Production needs a **shared** store (a Redis key, a database table, a PSR-16 cache) behind the same interface; the interface is stable, so swapping the backend is a one-line change. For the full store contract — the in-memory reference, how to back it with a shared persistent store, the at-least-once → exactly-once-effect guarantee, and the broker-free conformance fixtures that lock it — see [Idempotency Stores & the Effect Guarantee](/docs/spec/1.x/idempotency-stores/). ## Go The helper lives in the **core** module (stdlib only), so it ships with every consumer. `idempotency.Wrap(store, handler)` decorates a handler; register it like any other: ```go import ( babelqueue "github.com/babelqueue/babelqueue-go" "github.com/babelqueue/babelqueue-go/idempotency" ) store := idempotency.NewInMemoryStore() // tests / single process app.Handle("urn:babel:orders:created", idempotency.Wrap(store, func(ctx context.Context, env babelqueue.Envelope) error { // runs at most once per env.Meta.ID, even on redelivery return chargeOnce(ctx, env) })) ``` `InMemoryStore` satisfies the `Store` interface (`Seen` / `Remember` / `Forget`). For a production fleet, implement those three methods over Redis or a database and pass your store to `Wrap` unchanged. ## PHP `BabelQueue\Idempotency\Idempotent::wrap($store, $handler)` is the PHP mirror, composing with the consume runtime's ack-on-return / redeliver-on-throw contract: ```php use BabelQueue\Idempotency\Idempotent; use BabelQueue\Idempotency\InMemoryStore; $store = new InMemoryStore(); // tests / single process $dispatch->on('urn:babel:orders:created', Idempotent::wrap($store, function (\BabelQueue\Contracts\ConsumedMessage $message): void { chargeOnce($message); // runs at most once per meta.id })); ``` A production store implements `BabelQueue\Idempotency\IdempotencyStore` (`seen` / `remember` / `forget`) over Redis, a database table, or any PSR-16 cache. ## Python `babelqueue.idempotency.wrap(store, handler)` keeps the handler's signature (via `functools.wraps`), so the runtime still passes it the right positional args: ```python from babelqueue import BabelQueue from babelqueue.idempotency import InMemoryStore, wrap app = BabelQueue("redis://localhost:6379/0", queue="orders") store = InMemoryStore() # tests / single process app.register("urn:babel:orders:created", wrap(store, on_order_created)) ``` A production store implements the `IdempotencyStore` protocol (`seen` / `remember` / `forget`) over a shared backend. ## Per-SDK The helper is optional and identical in spirit everywhere — same `Store` shape, same seen-set semantics, same fail-open on a missing `meta.id`. The in-memory reference is for tests and single-process consumers; bring a shared store for a fleet. | SDK | Entry point | Reference store | | :--- | :--- | :--- | | Go | `idempotency.Wrap(store, handler)` | `idempotency.NewInMemoryStore()` | | PHP | `Idempotent::wrap($store, $handler)` | `BabelQueue\Idempotency\InMemoryStore` | | Python | `idempotency.wrap(store, handler)` | `babelqueue.idempotency.InMemoryStore` | | Node | `Wrap(store, handler)` | `InMemoryStore` (`@babelqueue/core`) | | Java | `Idempotent.wrap(store, handler)` | in-memory `Store` (`com.babelqueue.idempotency`) | See each SDK's reference for store backends and registration: [Go](/docs/babelqueue-go/1.x/introduction/), [PHP](/docs/php-sdk/1.x/introduction/), [Python](/docs/babelqueue-python/1.x/introduction/), [Node](/docs/babelqueue-node/1.x/introduction/), [Java](/docs/babelqueue-java/1.x/introduction/). For the related guard that stops an **intended** replay from re-firing external side-effects, see [DLQ redrive & replay-bypass](/docs/spec/1.x/redrive-and-replay/). For the producer-side mirror that makes the message *commit* atomically with your business write — removing the dual write — see the [Transactional Outbox](/docs/spec/1.x/outbox/). Continue to [Observability (OpenTelemetry)](/docs/spec/1.x/observability/).