FastAPI splits the request and response halves of a middleware across two different extension points, which makes idempotency slightly more awkward here than in Express or Fastify — and much easier to get subtly wrong. This runbook implements the obligations from idempotency middleware & framework integration in async Python.
Prerequisites. fastapi, redis.asyncio, and an authentication dependency that has already placed a credential on request.state. You should also have read Redis cache-based deduplication for the store semantics.
Step 1 — Cache the raw body
Starlette’s request stream is single-consumption. Reading it in a dependency drains it, and the route’s Pydantic model then receives an empty body.
from fastapi import Request
async def read_and_cache_body(request: Request) -> bytes:
raw = await request.body()
request._body = raw # restore Starlette's own cache so the route still parses
return raw
Assigning to request._body looks like reaching into internals because it is, but it is the documented behaviour of Request.body(), which populates exactly that attribute on first read. The assignment is only needed when something else has already consumed the stream; keeping it makes the dependency safe to compose in any order.
Step 2 — The dependency
import hashlib, json
from fastapi import Depends, HTTPException, Request
KEY_RE = re.compile(r"\A[A-Za-z0-9_-]{16,255}\Z")
def canonical(raw: bytes) -> bytes:
try:
doc = json.loads(raw or b"null")
except json.JSONDecodeError:
return raw # non-JSON: hash the bytes as sent
return json.dumps(doc, sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode("utf-8")
async def idempotency_guard(request: Request) -> None:
raw_key = request.headers.get("idempotency-key")
if raw_key is None:
raise HTTPException(400, {"code": "idempotency_key_required"})
if not KEY_RE.match(raw_key):
raise HTTPException(400, {"code": "idempotency_key_invalid"})
body = await read_and_cache_body(request)
fingerprint = hashlib.sha256(canonical(body)).hexdigest()
scoped = f"idem:v1:{request.state.credential_id}:{request.url.path}:{raw_key}"
try:
hit = await request.app.state.idem.register_or_get(scoped, fingerprint)
except RedisError:
raise HTTPException(503, {"code": "idempotency_store_unavailable"},
headers={"Retry-After": "2"})
if hit is None:
request.state.idem_key = scoped # middleware finalises this
return
if hit["fingerprint"] != fingerprint:
raise HTTPException(422, {"code": "idempotency_key_payload_mismatch"})
if hit["state"] == "pending":
raise HTTPException(409, {"code": "idempotency_key_in_use"},
headers={"Retry-After": "1"})
raise ReplayResponse(status=int(hit["status"]), body=hit["body"])
class ReplayResponse(Exception):
def __init__(self, status: int, body: str) -> None:
self.status, self.body = status, body
@app.exception_handler(ReplayResponse)
async def replay_handler(_request: Request, exc: ReplayResponse) -> Response:
return Response(content=exc.body, status_code=exc.status,
media_type="application/json",
headers={"Idempotent-Replay": "true"})
Raising an exception to return a stored response is unusual but correct here: a dependency can only short-circuit by raising, and a dedicated exception type keeps the replay path out of the endpoint’s signature.
Step 3 — The finalising middleware
@app.middleware("http")
async def finalise_idempotency(request: Request, call_next):
response = await call_next(request)
scoped = getattr(request.state, "idem_key", None)
if scoped is None:
return response
# StreamingResponse bodies must be drained to be stored; bounded and buffered.
chunks = [chunk async for chunk in response.body_iterator]
body = b"".join(chunks)
try:
if response.status_code >= 500 or response.status_code == 429:
await request.app.state.idem.release(scoped)
else:
await request.app.state.idem.complete(scoped, response.status_code, body)
except RedisError:
logger.error("idempotency finalise failed", extra={"key": scoped})
metrics.inc("idempotency_finalise_error_total")
response.headers["Idempotent-Replay"] = "false"
return Response(content=body, status_code=response.status_code,
headers=dict(response.headers), media_type=response.media_type)
Draining body_iterator is the price of this design and it is why streaming routes must be excluded. Register the middleware only for the router that carries idempotent endpoints, or gate on request.state.idem_key as above so unwrapped routes stream normally.
Step 4 — Wire it up
import redis.asyncio as aioredis
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
pool = aioredis.ConnectionPool.from_url(
settings.REDIS_URL, max_connections=50, socket_timeout=0.2)
app.state.idem = RedisIdempotencyStore(aioredis.Redis(connection_pool=pool))
yield
await app.state.idem.close()
app = FastAPI(lifespan=lifespan)
@app.post("/v1/charges", status_code=201,
dependencies=[Depends(authenticate), Depends(idempotency_guard)])
async def create_charge(payload: ChargeIn) -> ChargeOut:
return await charges.create(payload)
socket_timeout=0.2 is deliberate. Without it, a Redis instance that is reachable but wedged turns every request into a multi-second hang, and the deduplication layer becomes the availability problem it was meant to prevent.
Step 5 — Test under real concurrency
import asyncio, httpx, pytest
@pytest.mark.asyncio
@pytest.mark.parametrize("trial", range(50))
async def test_concurrent_duplicates(trial, app, redis, db):
key = f"01HTEST{trial:018d}"
payload = {"amount": 4500, "currency": "gbp"}
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
barrier = asyncio.Event()
async def one():
await barrier.wait() # release all 64 together
return await client.post("/v1/charges", json=payload,
headers={"Idempotency-Key": key})
tasks = [asyncio.create_task(one()) for _ in range(64)]
await asyncio.sleep(0.02)
barrier.set()
responses = await asyncio.gather(*tasks)
assert sum(r.status_code == 201 for r in responses) == 1
assert sum(r.status_code == 409 for r in responses) == 63
assert await db.fetchval("SELECT count(*) FROM charges WHERE key=$1", key) == 1
Run this against a containerised Redis, never a fake. The guarantee is a property of the real store’s atomicity, and an in-process mock provides that atomicity for free — proving nothing.
docker run --rm -d -p 6379:6379 --name idem-test redis:7-alpine
REDIS_URL=redis://localhost:6379 pytest -q tests/test_idempotency.py
docker rm -f idem-test
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
Dependency reads the body without restoring request._body, so every route receives an empty payload and Pydantic raises 422 |
Restore the cache as in step 1 and add a smoke test that posts a valid payload through the dependency and asserts a 201. |
http_responses_total{status="422"} jumping to 100% on wrapped routes immediately after deploy |
Finalisation placed in the dependency’s teardown, where the response is not available, so keys never leave pending |
Move it to the http middleware and pass the key through request.state. Assert in a test that a completed key has state=completed. |
idempotency_pending_age_seconds climbing steadily with zero idempotency_finalise_total |
A new redis.asyncio client per request, adding a TCP handshake to every call |
Create one client in lifespan and share it via app.state. Grep for aioredis.Redis( outside the lifespan function in CI. |
idempotency_registration_duration_ms p50 above 5 ms on a local Redis is the tell |
Middleware drains a StreamingResponse on an export route, so the client waits for the whole file before receiving anything |
Exclude streaming routes: register the middleware on a sub-router, or return early when request.state.idem_key is unset. |
response_first_byte_seconds on streaming routes; a value equal to total duration means it was buffered |
HTTPException raised by the guard is not stored, so a client sending a bad payload retries against validation forever |
Let the finalising middleware store 4xx responses — it already does, because exceptions still produce a Response by the time the middleware sees it. Verify with a test asserting a stored 422. |
idempotency_finalise_total{status="422"} should be non-zero |
SRE / observability checklist
idempotency_registration_duration_ms— Histogram of the Redis round trip. Alert if p99 exceeds15 ms; withsocket_timeout=0.2a wedged instance shows here first.idempotency_finalise_error_total— Counter of failed completion writes. Page above0.05%of wrapped requests.idempotency_finalise_total{status}— Counter by response status; confirms that4xxis stored and5xx/429released.idempotency_store_unavailable_total— Counter of the fail-closed503path. Any sustained value is a Redis availability incident, not a client problem.idempotency_pending_age_seconds— Gauge of the oldest pending key. Alert above120 s.idempotency_keylog field — Bound into the structured logger for the request so every line of a failed call carries it.
Related
- Idempotency Middleware & Framework Integration — the parent page with the five obligations and the framework comparison table.
- Writing Idempotency Middleware for Express and Fastify — the Node equivalent, including the Lua registration script reused here.
- Adding an Idempotency Filter to Spring Boot — the JVM equivalent with servlet content-caching wrappers.
- Using Redis SETNX for Distributed Request Deduplication — the atomic primitive underneath
register_or_get.