Idempotency
Client retries and at-least-once delivery mean the same operation can arrive twice. Idempotency makes the duplicate a no-op that returns the first attempt's result — the handler and its writes run exactly once.
How it works¶
When a request carries an idempotency key, the engine wraps the operation as the outermost wrap of its pipeline. On each call it:
- fingerprints the arguments (a stable payload hash);
- claims
(operation, key, payload hash); - if that claim already completed, returns the stored result — decoded back into the operation's typed result — and skips the handler and its transaction entirely;
- otherwise runs the handler and stores the encoded result.
The before hooks run ahead of the wrap chain, so authentication and
authorization still run first — a replayed result is never an unauthorized one.
What a duplicate skips is everything inside the wrap: the inner wraps, the
transaction, and the handler.
Keys and payloads¶
- The key comes from the caller. Over HTTP it's the
Idempotency-Keyheader, bound to the context by the FastAPI middleware. No key → no dedup; the operation just runs. - The same key with a different payload is a conflict — a key can't be reused for a different request.
- A key belongs to the caller. A claim is scoped to the tenant and the authenticated principal — on a delegated call, the subject together with its whole actor chain — so two callers who pick the same key never meet: each runs its own operation and replays its own result, and neither is told the other used it. An agent retrying its own request for a user replays; the user, or another agent, reusing that key runs its own operation under its own authorization. Calls with no authenticated principal share one anonymous space of their own. A tenant-aware store keeps each tenant's claims apart, whichever caller took them.
Upgrading to chain-scoped claims
A delegated call's claim taken before this scope was stored under the subject's own key, so until its window lapses the subject acting directly replays a record an agent produced. A tenant-aware Postgres store now carries the tenant in its keys, so a claim taken there before the upgrade is not replayed after it, once, within its window.
Two identities ride on a claim, and they answer different questions:
| Question | Effect | |
|---|---|---|
| Principal | whose key is it? | another caller's key behaves as unused |
| Owner (the invocation) | who may finish it? | a claim reclaimed by a duplicate cannot be completed by the one that lost it |
Keys stored before claims were scoped to a principal have a different shape, so a
key claimed before that upgrade does not replay after it: a retry that spans the
deploy runs again. Drain the dedup window across the deploy if that matters.
Every shipped store scopes its claims; a store of your own should inherit
ClaimPrincipalMixin and wire principal_provider, and one that does not is
named in a warning when an operation resolves it.
Wiring¶
Declare an IdempotencySpec (it carries a TTL for how long a result is
remembered) and register an idempotency adapter — commonly Redis —
under that name. The operation's result type must be a Pydantic model,
since the stored result is encoded and decoded. Wired end to end in the
Add idempotency recipe.
When an operation outlives its window¶
The TTL is a dedup window, so an operation slow enough to outlive it can find its key already reclaimed by a duplicate. The claim records which invocation took it, and a store refuses to complete or release a claim that now belongs to someone else — otherwise the slow operation would overwrite the live one's claim, and a third duplicate would replay a result for work that never finished.
On Postgres this needs one column on your table:
ALTER TABLE <your idempotency table> ADD COLUMN owner uuid;
It is nullable and additive, so nothing breaks before you run it and existing rows need no backfill, and you can apply it to a running deployment — the store notices the column within a minute and starts fencing, no restart required. Until you run it, that table works exactly as before but without the refusal above, and the store logs the relation once so the state is visible rather than assumed. Redis, Mongo and the mock need no migration.
Size the TTL for your redelivery horizon and this stays a corner case; the column is what makes the corner safe rather than silent.
Idempotency vs the inbox¶
Both dedupe, at different layers: idempotency dedupes inbound operations by caller-supplied key; the inbox dedupes consumed events by event id. Same principle — exactly-once effects over an at-least-once world.