Skip to content

Add idempotency

A client retries a POST it isn't sure landed; an at-least-once queue delivers the same command twice. Idempotency makes the duplicate a no-op that returns the first result — the handler and its writes run exactly once. The concept is covered in Idempotency; this is the wiring.

The runnable version lives at examples/recipes/idempotency/ and runs on the in-memory mock store — no infrastructure needed.

Wrap the operation

Idempotency is a wrap on the operation registry. Bind the operation you want deduped and add an IdempotencyWrap — it sits outermost, so a replay skips the handler and its transaction, while before hooks (authn/authz) still run:

# The idempotency spec carries the TTL and names the adapter route.
IDEM = IdempotencySpec(name="orders")

# "orders.create" — the namespaced key of the registry's create operation.
CREATE = ORDER_SPEC.default_namespace.key(DocumentKernelOp.CREATE)

# Wrap that operation with idempotency. The wrap sits outermost, so a replay
# skips the handler (and its transaction); `before` hooks (authn/authz) still run.
REGISTRY = (
    build_document_registry(ORDER_SPEC, DocumentDTOs(read=ReadOrder, create=CreateOrder))
    .bind(CREATE)
    .bind_outer()
    .wrap(IdempotencyWrap(op=CREATE, spec=IDEM, result_type=ReadOrder).to_step())
    .finish(deep=True)
    .freeze()
)

IdempotencySpec.name is the adapter route and result_type must be a Pydantic model (the stored result is encoded and decoded on replay).

Bind the key and call it

Build the DocumentFacade over the wrapped registry. The wrap fires whenever an idempotency key is bound — passing a key, replaying the call returns the stored result:

async def idempotent_create(ctx: ExecutionContext) -> tuple[ReadOrder, ReadOrder]:
    facade = orders(ctx)
    cmd = CreateOrder(item="widget")

    # Over HTTP the FastAPI InvocationMetadataMiddleware binds the
    # Idempotency-Key header for you; here we bind the key explicitly.
    with ctx.inv_ctx.bind_idempotency("order-001"):
        first = await facade.create(cmd)
        second = await facade.create(cmd)  # replay — handler skipped, stored result

    assert first.id == second.id  # created once, returned twice
    return first, second

Over HTTP you don't bind by hand: the InvocationMetadataMiddleware reads the Idempotency-Key header and binds it around the request, so the route just calls facade.create(cmd).

Register a store

The mock module auto-registers an idempotency adapter, so the example needs no config. For production, register one — commonly Redis:

RedisDepsModule(
    client=redis,
    idempotency={"orders": RedisIdempotencyConfig(namespace="orders")},
)

The route key ("orders") matches IdempotencySpec.name; the TTL comes from the spec, not the config.

Notes

  • Same key, different payload → conflict. A key can't be reused for a different request; the store rejects a payload-hash mismatch.
  • Stable keys, generous TTL. The key must be the same across a client's retries, and the TTL must outlast them. Every worker that can handle the operation must share the same store namespace.
  • Not transactional with the write. The wrap records the result after the business transaction commits — a crash in the gap leaves the operation un-recorded, so it re-runs rather than replays. Idempotency dedupes the common case; it isn't a substitute for the outbox when you need exactly-once effects.