Idempotency keys¶
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_idmakes the middleware look keys up among the requests of the same customer. The default, theAuthorizationheader, 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, andrequire_idempotency_keyanswers 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_keykeeps whoever can write to Redis from planting a response for a retry. When the responses carry personal data,encryption_keyencrypts 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¶
"""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)