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