Skip to content

Idempotency keys

Idempotency keys Client to Your app: POST /payments, key k1. Your app: charges the card. Your app to Cache: keep the 201 under k1. Your app to Client: 201, lost: timeout. Client to Your app: retry, the same key k1. Cache to Your app: the kept 201. Your app to Client: 201, Idempotent-Replayed. A retry gets the first response: the card is charged once. Client Your app Cache POST /payments, key k1 charges the card keep the 201 under k1 201, lost: timeout retry, the same key k1 the kept 201 201, Idempotent-Replayed A retry gets the first response: the card is charged once
What it's for: a client that retries after a timeout or a dropped connection gets the first response again, instead of doing it twice.

A payments API where a client that retries after a timeout or a dropped connection gets the first response again, instead of charging the card twice. IdempotencyMiddleware keeps each response under the client's Idempotency-Key, and require_idempotency_key makes POST /payments refuse requests without one. The responses are kept in the cache installed on the app, signed with its signing_key.

Run it

From a clone of the repository:

make deps                 # .venv, with every extra
source .venv/bin/activate
uvicorn examples.idempotency.main:app

Stored responses are signed: set CACHE_SIGNING_KEY (secrets.token_urlsafe(32)), the same in every process, or a retry that reaches another process charges again. The example has two customers, alice and bob, whose tokens are alice-token and bob-token.

Try it

A payment, with a new key:

$ curl -i -X POST http://127.0.0.1:8000/payments -H "Authorization: Bearer alice-token" \
    -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
    -H "Content-Type: application/json" \
    -d '{"amount": 1250, "currency": "EUR", "description": "Order 1042"}'
HTTP/1.1 201 Created

{"amount":1250,"currency":"EUR","description":"Order 1042","id":"ch_182671f5910f3ece","customer":"alice","created_at":"2026-10-26T10:57:18.321689Z"}

The same request again, as a client retries it, gets the same response without charging again:

$ curl -i -X POST http://127.0.0.1:8000/payments -H "Authorization: Bearer alice-token" \
    -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
    -H "Content-Type: application/json" \
    -d '{"amount": 1250, "currency": "EUR", "description": "Order 1042"}'
HTTP/1.1 201 Created
idempotent-replayed: true

{"amount":1250,"currency":"EUR","description":"Order 1042","id":"ch_182671f5910f3ece","customer":"alice","created_at":"2026-10-26T10:57:18.321689Z"}

$ curl http://127.0.0.1:8000/payments -H "Authorization: Bearer alice-token"
[{"amount":1250,"currency":"EUR","description":"Order 1042","id":"ch_182671f5910f3ece","customer":"alice","created_at":"2026-10-26T10:57:18.321689Z"}]

The same key with another body is a bug in the client, not a retry:

$ curl -i -X POST http://127.0.0.1:8000/payments -H "Authorization: Bearer alice-token" \
    -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
    -H "Content-Type: application/json" \
    -d '{"amount": 99000, "currency": "EUR", "description": "Order 1042"}'
HTTP/1.1 422 Unprocessable Content

{"detail": "This Idempotency-Key was used with a different request"}

Keys are per customer: Bob sending Alice's key gets a payment of his own, not hers:

$ curl -i -X POST http://127.0.0.1:8000/payments -H "Authorization: Bearer bob-token" \
    -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
    -H "Content-Type: application/json" \
    -d '{"amount": 1250, "currency": "EUR", "description": "Order 1042"}'
HTTP/1.1 201 Created

{"amount":1250,"currency":"EUR","description":"Order 1042","id":"ch_f9647245e9b65cf9","customer":"bob","created_at":"2026-10-26T10:57:19.339093Z"}

A payment without a key is refused:

$ curl -i -X POST http://127.0.0.1:8000/payments -H "Authorization: Bearer alice-token" \
    -H "Content-Type: application/json" \
    -d '{"amount": 1250, "currency": "EUR", "description": "Order 1042"}'
HTTP/1.1 400 Bad Request

{"detail":"This request needs an Idempotency-Key header"}

A retry that arrives while the first request is still charging the card (it takes a second) gets a 409 with Retry-After: 1 and {"detail": "A request with this Idempotency-Key is still being processed"}, and the card is charged once:

$ for attempt in first retry; do \
    curl -s -o /dev/null -w "$attempt: %{http_code} after %{time_total}s\n" \
      -X POST http://127.0.0.1:8000/payments -H "Authorization: Bearer alice-token" \
      -H "Idempotency-Key: 5f0c1a8e-2b7d-4c39-9e61-0d4f8a3b7c25" \
      -H "Content-Type: application/json" \
      -d '{"amount": 4800, "currency": "EUR", "description": "Order 1043"}' & \
    sleep 0.2; \
  done; wait
retry: 409 after 0.000958s
first: 201 after 1.002062s

What to look at

  • Keys per customer, not per token: identifier=customer_id makes the middleware look keys up among the requests of the same customer. The default, the Authorization header, suits API keys that never change; with access tokens that expire, a retry sent with a refreshed token would charge the card again.
  • The key is required where it matters: the middleware handles every POST and PATCH that carries an Idempotency-Key, and require_idempotency_key answers 400 to a payment without one. It runs after the authentication, so a request without a token gets a 401 first.
  • What's kept: the responses the endpoint gives, below 500. Not those refused before anything ran (a 401, a 422 for an invalid body): the client fixes the request and sends it with the same key. A retry must otherwise send the same body, byte for byte: it's what the 422 for "another request" compares.
  • Server errors run again: a 500 isn't kept, so a retry runs the payment again. That's why the key also goes to the provider (f"{customer}:{key}"): if the request fails after the charge, the provider's own idempotency keeps the card from being charged twice.
  • Signed responses: the cache's signing_key keeps whoever can write to Redis from planting a response for a retry. When the responses carry personal data, encryption_key encrypts them too.
  • Shared state: InMemoryBackend keeps the responses and locks in one process. With Redis or Valkey a retry finds the first response, and the 409, whichever process it reaches.

The code

examples/idempotency/main.py
"""A payments API where a retried request never charges a card twice, thanks to idempotency keys.

    uvicorn examples.idempotency.main:app

Clients send a new `Idempotency-Key` (a random UUID) with each new payment, and the same key when
they retry it after a timeout or a dropped connection:

- A retry gets the first response again, with `Idempotent-Replayed: true`: the card is charged
  once.
- A retry that arrives while the first request still runs gets a 409 with `Retry-After: 1`.
- The same key with another body gets a 422: that's a bug in the client, not a retry.
- Keys are per customer: nobody gets another customer's payment by sending their key.
- `POST /payments` without a key gets a 400.

The responses and the locks live in the cache's backend: InMemoryBackend here, so that it runs
without a server. Production uses Redis or Valkey, so that a retry finds the first response
whichever process it reaches.
"""

import asyncio
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Annotated, Literal

import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.requests import HTTPConnection

from fastapi_alcazaba import Cache
from fastapi_alcazaba.backends.inmemory import InMemoryBackend
from fastapi_alcazaba.idempotency import IdempotencyMiddleware, require_idempotency_key

# Stands in for your customers and their access tokens (a database, or a JWT check).
TOKENS = {"alice-token": "alice", "bob-token": "bob"}


def throwaway_secret() -> SecretStr:
    """A random secret, for trying the example: it changes at every start and differs between
    processes."""
    return SecretStr(secrets.token_urlsafe(32))


class Settings(BaseSettings):
    """The configuration, from environment variables or a .env file."""

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    # Signs the stored responses, so that whoever can write to Redis can't plant one for a
    # retry. Every process needs the same key, or a retry that reaches another process runs
    # again: set CACHE_SIGNING_KEY (secrets.token_urlsafe(32)). With personal data in the
    # responses, pass encryption_key= instead: they're encrypted too.
    cache_signing_key: SecretStr = Field(default_factory=throwaway_secret)


settings = Settings()


class NewPayment(BaseModel):
    amount: int = Field(gt=0, le=1_000_000, description="In cents")
    currency: Literal["EUR", "USD"]
    description: str = Field(default="", max_length=200)


class Payment(NewPayment):
    id: str
    customer: str
    created_at: datetime


@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
    # Production: RedisBackend(redis.asyncio.from_url(...)), with the URL in the settings, shared
    # by every process. Payments are a list in memory here, a table in your database.
    Cache(InMemoryBackend(), prefix="payments", signing_key=settings.cache_signing_key).install(
        application
    )
    application.state.payments = []
    yield


app = FastAPI(lifespan=lifespan)


def customer_id(connection: HTTPConnection) -> str:
    """Stands in for your authentication: the customer whose bearer token the request carries,
    "" if none."""
    scheme, _, token = connection.headers.get("authorization", "").partition(" ")
    return TOKENS.get(token, "") if scheme.lower() == "bearer" else ""


# Keys are looked up among the requests of the same customer. The default, the Authorization
# header, suits API keys that never change; with access tokens that expire, a retry sent with a
# refreshed token would charge the card again.
app.add_middleware(IdempotencyMiddleware, identifier=customer_id)


def current_customer(request: Request) -> str:
    if not (customer := customer_id(request)):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Bearer"})
    return customer


def get_payments(request: Request) -> list[Payment]:
    payments: list[Payment] = request.app.state.payments
    return payments


Customer = Annotated[str, Depends(current_customer)]
# A 400 for requests without the header: a payment must never run without one.
IdempotencyKey = Annotated[str, Depends(require_idempotency_key)]
Payments = Annotated[list[Payment], Depends(get_payments)]


# pylint: disable-next=unused-argument  # a stand-in: the provider's client sends them all
async def charge_card(customer: str, payment: NewPayment, idempotency_key: str) -> str:
    """Stands in for the payment provider: charges the card, and returns the charge's id. The
    key goes to the provider too, which charges a key once however often it's asked."""
    await asyncio.sleep(1)  # the round trip to the card network
    return f"ch_{secrets.token_hex(8)}"


@app.post("/payments", status_code=status.HTTP_201_CREATED)
async def pay(
    payment: NewPayment,
    customer: Customer,
    key: IdempotencyKey,
    payments: Payments,
) -> Payment:
    """Runs once per Idempotency-Key: retries get this response again."""
    # Server errors aren't kept, so a retry runs again. If this request fails after the charge,
    # the provider's own idempotency keys (Stripe's, Adyen's...) keep the card from being charged
    # twice: pass it this one, per customer like the middleware's.
    charge = await charge_card(customer, payment, idempotency_key=f"{customer}:{key}")
    receipt = Payment(
        id=charge, customer=customer, created_at=datetime.now(UTC), **payment.model_dump()
    )
    payments.append(receipt)
    return receipt


@app.get("/payments")
async def list_payments(customer: Customer, payments: Payments) -> list[Payment]:
    return [payment for payment in payments if payment.customer == customer]


if __name__ == "__main__":
    uvicorn.run("main:app", reload=True)