FastAPI cookbook · Advanced
Support Idempotency-Key headers in FastAPI
Let clients retry POST requests (like creating payments) safely without duplicate side effects.
Approach
A dependency reads Idempotency-Key, and a table with a unique (user_id, key) constraint stores the request hash, status and final response. The first request claims the key; repeats return the stored response, conflicting bodies get 422, and in-flight duplicates get 409.
Solution
import hashlib, json
from fastapi import Header, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy.dialects.postgresql import insert
async def run_idempotent(db, user_id: int, key: str, body: dict, handler):
fingerprint = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
claimed = await db.execute(
insert(IdempotencyKey).values(user_id=user_id, key=key, fingerprint=fingerprint, status="processing")
.on_conflict_do_nothing(index_elements=["user_id", "key"]).returning(IdempotencyKey.id)
)
await db.commit()
if claimed.scalar() is None:
existing = await db.scalar(select(IdempotencyKey).where(IdempotencyKey.user_id == user_id, IdempotencyKey.key == key))
if existing.fingerprint != fingerprint:
raise HTTPException(422, "Idempotency-Key reused with a different request body")
if existing.status == "processing":
raise HTTPException(409, "A request with this Idempotency-Key is in progress")
return JSONResponse(existing.response_body, status_code=existing.response_status)
status_code, payload = await handler()
await db.execute(update(IdempotencyKey).where(IdempotencyKey.user_id == user_id, IdempotencyKey.key == key)
.values(status="done", response_status=status_code, response_body=payload))
await db.commit()
return JSONResponse(payload, status_code=status_code)
@router.post("/payments")
async def create_payment(body: PaymentIn, db: DB, user: CurrentUser,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key", min_length=8, max_length=100)]):
async def handler():
payment = await payments.charge(db, user.id, body, provider_key=idempotency_key)
return 201, PaymentOut.model_validate(payment).model_dump(mode="json")
return await run_idempotent(db, user.id, idempotency_key, body.model_dump(mode="json"), handler)
Pitfalls
- Not scoping keys per user/account.
- Keys that never expire (clean up after ~24h).
- Crashes leaving keys stuck in 'processing' — add a timeout for reclaiming.
Variations
- Pass the same key to downstream providers (Stripe supports idempotency keys).
Further reading: IETF draft: Idempotency-Key header
Reference: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
More FastAPI recipes
- Add liveness and readiness endpoints to FastAPI
- Add OpenTelemetry tracing to FastAPI
- Add request IDs and structured logs to FastAPI
- Build a WebSocket chat endpoint in FastAPI
- Cache FastAPI responses in Redis
- Call external APIs with retries and timeouts from FastAPI
- Implement role-based access control in FastAPI
- Isolate tenants in a multi-tenant FastAPI app