Idempotency¶
IdempotencyMiddleware implements the Idempotency-Key pattern used by Stripe, AWS, GitHub and Plaid: the client sends a unique header and, once the first request completes, the server replays the same response on any retry — no duplicate row in the database, no double charge.
The in-progress lock is per process
Within one process, concurrent requests sharing a key are serialized: the second waits for the first to finish and replays its response. Across replicas it does not hold — the store deduplicates retries (the first has finished and written), but two simultaneous requests landing on different replicas both run the handler. Keep client timeouts generous relative to handler latency to avoid premature retries.
How it works¶
- Client sends
POST /chargewithIdempotency-Key: chk_<uuid>. - Middleware runs the handler, stores the complete response keyed by
(caller, method, path, key). - Client retries? Middleware returns the same cached response. Handler doesn't run again.
Only mutating verbs (POST / PUT / PATCH / DELETE) are eligible — GET is already idempotent.
Opt-in per request
Requests without the header pass straight through. Existing endpoints aren't disturbed — only callers that need the guarantee send the header.
The key is scoped to the caller¶
The header value is chosen by the client. On its own it identifies nobody: two callers picking the same string on the same endpoint would share the entry, and the replay hands back the stored response — body and headers included.
So the middleware folds a digest of the request's credentials (Authorization / Cookie) into the key. An entry is only ever replayed to the credentials that created it.
from fastapi import FastAPI
from tempest_fastapi_sdk import IdempotencyMiddleware, MemoryIdempotencyStore
app = FastAPI()
app.add_middleware(
IdempotencyMiddleware,
store=MemoryIdempotencyStore(),
ttl_seconds=24 * 3600,
principal_resolver=lambda request: request.headers.get("x-tenant-id", ""),
)
Use principal_resolver= when identity lives elsewhere — an API-key id, a tenant header. Returning a constant there restores the old behavior (one entry per key, shared across callers) and is unsafe on a multi-tenant endpoint.
What is not replayed
A Set-Cookie from the original response is left out of the stored copy — re-issuing the first caller's session on a replay would hand that session over. The original caller still gets its cookie; only the replay does not.
5xx is not cached
By default responses >= 500 do not enter the store, so the client's retry actually reaches the handler. A transient failure cached for ttl_seconds would pin that key to the error for the entry's whole life. Pass cache_server_errors=True if your case needs the opposite.
Minimum setup (single-replica / dev)¶
from fastapi import FastAPI
from tempest_fastapi_sdk import (
IdempotencyMiddleware,
MemoryIdempotencyStore,
)
app = FastAPI()
app.add_middleware(
IdempotencyMiddleware,
store=MemoryIdempotencyStore(),
ttl_seconds=24 * 3600,
)
MemoryIdempotencyStore keeps entries in a local dict — works for one replica only. For production use Redis.
Process-local, volatile state
MemoryIdempotencyStore lives in process memory: each replica has its own dict and it is wiped on every restart / redeploy. Keys stored before a restart stop deduplicating afterwards. Use it only in dev / single-replica; for persistence across restarts and across replicas, use RedisIdempotencyStore.
Production setup (multi-replica via Redis)¶
Installation
In-memory idempotency ships with tempest-fastapi-sdk — the minimum
setup needs no extra. RedisIdempotencyStore needs the [cache]
extra — uv add "tempest-fastapi-sdk[cache]" (pulls in redis).
from fastapi import FastAPI
from redis.asyncio import Redis
from tempest_fastapi_sdk import (
IdempotencyMiddleware,
RedisIdempotencyStore,
)
from src.core.settings import settings
redis = Redis.from_url(settings.REDIS_URL)
app = FastAPI()
app.add_middleware(
IdempotencyMiddleware,
store=RedisIdempotencyStore(redis, prefix="idem:"),
ttl_seconds=24 * 3600,
)
Why Redis.from_url here, not AsyncRedisManager?
This client feeds a middleware, built in create_app (sync), before any
async lifespan runs. Redis.from_url() is lazy — it constructs without
opening a connection, so it fits here. AsyncRedisManager needs await
connect() and fits where there's an async context: a client via
Depends(cache.client_dependency), or the SSEBroker built in the lifespan.
Both need the [cache] extra (the redis package).
Stripe defaults to 24h — coherent with client-side exponential retry.
Client¶
import uuid
import httpx
async def create_charge(amount_cents: int) -> dict[str, object]:
"""Idempotent POST with automatic retry."""
key = uuid.uuid4().hex
async with httpx.AsyncClient() as c:
for _ in range(3):
try:
r = await c.post(
"https://api/charge",
json={"amount_cents": amount_cents},
headers={"Idempotency-Key": key},
timeout=10,
)
return r.json()
except httpx.ReadTimeout:
continue
raise RuntimeError("3 retries failed")
Whichever of the 3 attempts reaches the server, the end state is the same resource created exactly once — remaining replicas receive the cached response.
When to use¶
- Payments / charges
- Webhook delivery (client retries with the same key)
- External side-effect operations (email send, SMS)
- Any
POST /createwhose retry could duplicate records
When NOT to use¶
GET(already idempotent)- Trivially reentrant operations (
PATCHrewriting the same value) - When duplication has no consequence (logs, metrics)
Custom backend¶
Implement the IdempotencyStore protocol:
from tempest_fastapi_sdk import CachedResponse, IdempotencyStore
class DynamoIdempotencyStore:
"""Example DynamoDB-backed store."""
async def get(self, key: str) -> CachedResponse | None:
...
async def set(
self,
key: str,
response: CachedResponse,
*,
ttl_seconds: int,
) -> None:
...
# Works with the middleware just like the built-in stores:
assert isinstance(DynamoIdempotencyStore(), IdempotencyStore)
Recap¶
- The
Idempotency-Keyheader makes the server replay the same response on any retry once the first request completes — no duplicate record. - Only mutating verbs (
POST/PUT/PATCH/DELETE) carrying the header are eligible; everything else passes straight through (opt-in per request). - There is an in-progress lock, per process: inside one replica, concurrent requests with the same key are serialized and the second replays the first's response. Across replicas it does not hold — keep client timeouts generous.
MemoryIdempotencyStoreis process-local and volatile (dev / single-replica);RedisIdempotencyStorecovers multi-replica and survives restarts.- Implement the
IdempotencyStoreprotocol to plug in any backend (e.g. DynamoDB).
Next step: combine it with @cached to speed up reads, or with the Outbox pattern for reliable delivery of side-effects triggered by the handler.