FastAPI Idempotency Key 🛡️
Stripe-grade Idempotency-Key engine for FastAPI and Starlette APIs.
Guaranteed exactly-once execution, deterministic SHA-256 fingerprinting, atomic distributed locks, and pluggable backends (Memory, SQLite, Redis).
📖 Table of Contents
- The Problem: Why Idempotency Matters
- How It Works
- Key Features
- Architecture & Flow
- Installation
- Quick Start
- Storage Backends Comparison
- Configuration & API Reference
- Error Handling & HTTP Status Codes
- Contributing & Testing
- License
💥 The Problem: Why Idempotency Matters
In distributed systems, networks are inherently unreliable. When clients interact with payment gateways, order processing systems, or webhook dispatchers, network partitions, socket timeouts, or retry mechanisms can duplicate incoming mutating HTTP requests (POST, PUT, PATCH).
Without server-side idempotency safeguards:
- Double Charges: A customer clicks "Pay" twice or a mobile connection drops, causing payment processors to execute duplicate charges.
- Duplicate Orders: An e-commerce service creates multiple fulfillment tickets for a single checkout.
- Webhook Storms: Payment providers (Stripe, Adyen, PayPal) retry webhooks until an HTTP 200 is acknowledged, causing repeated side effects.
fastapi-idempotency-key implements the IETF HTTP Idempotency-Key specification and Stripe's battle-tested idempotency model. It turns your mutating endpoints into resilient, replayable operations with zero architectural friction.
⚙️ How It Works
- Request Interception: Incoming requests are checked for an
Idempotency-Keyheader (e.g.Idempotency-Key: e8a78bf5-4f40-42bf-9076-2f6cfd939634). - Fingerprint Calculation: A deterministic SHA-256 hash is computed across the HTTP method, normalized URL path, sorted query parameters, and raw request body.
- Atomic Mutual Exclusion: An atomic lock is claimed in the storage backend (Memory, SQLite WAL, or Redis Lua script).
- Three Execution Outcomes:
- First Arrival (Lock Acquired): Downstream FastAPI route executes normally. The completed HTTP status code, response headers, and response body are atomically cached with a TTL.
- Concurrent Duplicate (In Flight): If another request with the same key is currently running, the server rejects it with HTTP 409 Conflict (or optionally blocks up to
timeoutseconds to await completion). - Subsequent Duplicate (Completed): If the request was previously executed, the exact cached response is replayed with an
Idempotency-Replayed: trueheader.
- Mismatch Protection: If an existing key is reused with a different payload or query parameters, the server blocks execution and immediately responds with HTTP 422 Unprocessable Entity.
- Automatic Fault Tolerance: If your application crashes or returns a 5xx server error, the lock is automatically released so the client can safely retry without waiting for the TTL to expire.
📊 Architecture & Flow
flowchart TD
Start(["Incoming HTTP Request"]) --> CheckHeader{"Has Idempotency-Key?"}
CheckHeader -- No --> PassThrough["Execute Downstream Route Directly"]
CheckHeader -- Yes --> CalcFP["Compute SHA-256 Request Fingerprint"]
CalcFP --> TryLock{"Atomic Lock Attempt<br/>(Memory / SQLite / Redis)"}
TryLock -- "Acquired (New Key)" --> ExecHandler["Execute Route Handler"]
ExecHandler --> StatusCheck{"Response Status Code"}
StatusCheck -- "2xx / 3xx / 4xx (Cacheable)" --> CacheResp["Cache Status, Headers & Body<br/>Set Status = COMPLETED"]
StatusCheck -- "5xx Error / Exception" --> ReleaseLock["Release Lock<br/>Allow Client Retry"]
CacheResp --> ReturnOriginal["Return Original Response to Client"]
ReleaseLock --> ReturnError["Return 5xx Error Response"]
TryLock -- "Key Exists: COMPLETED" --> VerifyFP{"Fingerprint Matches?"}
VerifyFP -- "Yes" --> ReplayCached["Replay Cached Response<br/>+ Header: Idempotency-Replayed: true"]
VerifyFP -- "No (Payload Mismatch)" --> RejectMismatch["Return HTTP 422 Unprocessable Entity"]
TryLock -- "Key Exists: IN_PROGRESS" --> CheckTimeout{"timeout > 0 configured?"}
CheckTimeout -- "No / Expired" --> ReturnConflict["Return HTTP 409 Conflict<br/>(Request In Progress)"]
CheckTimeout -- "Yes (Waits for Lock)" --> AwaitComplete["Wait & Replay on Completion"]
AwaitComplete --> ReplayCached
✨ Key Features
- Strict Exactly-Once Semantics: Completely eliminates race conditions and duplicate operations under high concurrency.
- Atomic Concurrency Protection: High-concurrency protection via
asyncio.Lock(Memory), atomic transactions in WAL mode (SQLite), or single-roundtrip atomic Lua scripts (Redis). - Deterministic SHA-256 Fingerprinting: Detects request tampering, altered bodies, or mismatched query parameters.
- Zero-Loss Replay Engine: Transparently captures and replays status codes, custom headers, JSON payloads, binary blobs, and
StreamingResponseobjects. - Fail-Safe Exception Recovery: Unhandled Python exceptions or 500 server errors immediately release locks, allowing client retries without waiting for expiration.
- Flexible Integration: Apply globally as an ASGI middleware (
IdempotencyMiddleware) or selectively on individual endpoints via@idempotent. - Zero External Dependencies Required: Core functionality works out-of-the-box using pure Python and Starlette.
- 100% Type-Annotated: Full
mypy --strictcompliance andpy.typedmarker included.
📦 Installation
# Core package (in-memory backend included)
pip install fastapi-idempotency-key
# With Redis backend support
pip install "fastapi-idempotency-key[redis]"
# With SQLite backend support
pip install "fastapi-idempotency-key[sqlite]"
# With all backend drivers
pip install "fastapi-idempotency-key[all]"
🚀 Quick Start
1. Global ASGI Middleware
Protect all mutating endpoints (POST, PUT, PATCH) across your entire application in under 30 seconds:
from fastapi import FastAPI
from fastapi_idempotency_key import IdempotencyMiddleware, MemoryBackend
app = FastAPI(title="Payment API")
# Register middleware with in-memory storage (24-hour TTL)
app.add_middleware(
IdempotencyMiddleware,
backend=MemoryBackend(),
header_name="Idempotency-Key",
default_ttl=86400,
)
@app.post("/payments")
async def create_payment(payment: dict):
# Guaranteed to execute exactly once per Idempotency-Key!
return {"status": "paid", "amount": payment["amount"]}
Test it with curl:
# First request: Executes handler
curl -i -X POST http://localhost:8000/payments \
-H "Idempotency-Key: pay_unique_987" \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
# Second request with same key: Replayed immediately from cache
curl -i -X POST http://localhost:8000/payments \
-H "Idempotency-Key: pay_unique_987" \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
# -> Returns HTTP 200 with header: "Idempotency-Replayed: true"
# Tampered request with same key but different amount:
curl -i -X POST http://localhost:8000/payments \
-H "Idempotency-Key: pay_unique_987" \
-H "Content-Type: application/json" \
-d '{"amount": 250}'
# -> Returns HTTP 422 Unprocessable Entity
2. Route-Level Decorator
Target only high-risk routes (e.g. checkout, refunds) without applying idempotency globally:
from fastapi import FastAPI, Request
from fastapi_idempotency_key import idempotent, MemoryBackend
app = FastAPI()
backend = MemoryBackend()
@app.post("/checkout")
@idempotent(backend=backend, expire=300, required=True)
async def checkout(payload: dict, request: Request):
return {"order_id": "ord_123", "status": "confirmed"}
@app.post("/cart/items")
async def add_to_cart(item: dict):
# Standard endpoint: not idempotent
return {"status": "added", "item": item}
🗄️ Storage Backends Comparison
| Feature | MemoryBackend |
SQLiteBackend |
RedisBackend |
|---|---|---|---|
| Persistence | In-Memory (Volatile) | Disk / File / :memory: |
Memory / RDB / AOF |
| Multi-Process Safe | Single Process | Multi-Process (WAL Mode) | Distributed Multi-Worker |
| Locking Mechanism | asyncio.Lock |
SQLite Immediate Transactions | Atomic Redis Lua Scripts |
| Eviction / TTL | In-memory LRU + TTL Purge | SQLite Index + On-demand TTL | Native Redis Key TTL |
| Setup Overhead | Zero Dependencies | aiosqlite |
redis-py (asyncio) |
| Ideal Use Case | Testing, Local Dev, Micro-apps | Single-node servers, Embedded APIs | Distributed microservices, Kubernetes |
1. MemoryBackend
from fastapi_idempotency_key import MemoryBackend
# Keep up to 10,000 keys in memory with LRU eviction
backend = MemoryBackend(max_keys=10_000)
2. SQLiteBackend
Thread-safe and process-safe persistent storage using aiosqlite with Write-Ahead Logging (WAL) enabled:
from fastapi_idempotency_key import SQLiteBackend
# Persistent SQLite database file
backend = SQLiteBackend(database_path="idempotency.db")
3. RedisBackend
Enterprise-scale distributed storage with atomic single-flight Lua scripts:
from fastapi_idempotency_key import RedisBackend
# Connect via connection string
backend = RedisBackend(redis_url="redis://localhost:6379/0", prefix="myapp:idempotency:")
# Or pass an existing redis.asyncio.Redis instance:
import redis.asyncio as aioredis
redis_client = aioredis.from_url("redis://localhost:6379/0")
backend = RedisBackend(redis=redis_client)
🛠️ Configuration & API Reference
IdempotencyMiddleware
| Parameter | Type | Default | Description |
|---|---|---|---|
app |
ASGIApp |
Required | Downstream ASGI application. |
backend |
BaseIdempotencyBackend |
MemoryBackend() |
Storage backend instance. |
header_name |
str |
"Idempotency-Key" |
Case-insensitive header name for client keys. |
replay_header_name |
str |
"Idempotency-Replayed" |
Header set to "true" on replayed responses. |
enforce_methods |
Tuple[str, ...] |
("POST", "PATCH", "PUT") |
HTTP verbs subjected to idempotency enforcement. |
default_ttl |
int |
86400 |
Expiration time for cached responses (in seconds). |
timeout |
float |
0.0 |
Maximum seconds to wait for an in-progress request before returning 409 Conflict. |
cache_statuses |
Tuple[int, ...] |
(200, 201, 202, 204, ...) |
HTTP status codes that will be persisted and replayed. |
on_conflict_status |
int |
409 |
Status code returned on concurrent in-progress duplicate. |
on_mismatch_status |
int |
422 |
Status code returned on payload mismatch. |
required |
bool |
False |
When True, returns HTTP 400 if the header is missing. |
@idempotent Decorator
| Parameter | Type | Default | Description |
|---|---|---|---|
expire |
int |
86400 |
TTL in seconds for the cached response. |
header_name |
str |
"Idempotency-Key" |
Header name to inspect. |
replay_header_name |
str |
"Idempotency-Replayed" |
Header attached to replayed responses. |
backend |
Optional[BaseIdempotencyBackend] |
None |
Storage backend (defaults to global memory store). |
required |
bool |
False |
Raise IdempotencyKeyMissingError (HTTP 400) if absent. |
timeout |
float |
0.0 |
Seconds to wait for concurrent requests. |
cache_statuses |
Tuple[int, ...] |
(200, 201, 202, 204, ...) |
Status codes to cache. |
🚨 Error Handling & HTTP Status Codes
| HTTP Status | Condition | Example Response Body |
|---|---|---|
409 Conflict |
Another request with the same key is currently running. | {"detail": "A request with this idempotency key is currently in progress."} |
422 Unprocessable |
Key was previously used with a different body, path, or query string. | {"detail": "Idempotency key was previously used with a different request payload."} |
400 Bad Request |
required=True is enabled and client omitted the header. |
{"detail": "Idempotency-Key header is required."} |
5xx Server Error |
Upstream handler failed; lock is released to allow client retry. | Normal upstream server error representation. |
🧪 Contributing & Testing
We uphold strict quality gates with 99%+ test coverage and comprehensive static analysis.
# Clone the repository
git clone https://github.com/fastapi-idempotency-key/fastapi-idempotency-key.git
cd fastapi-idempotency-key
# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install development dependencies
pip install -e ".[all,dev]"
# Run tests with coverage
pytest --cov=fastapi_idempotency_key --cov-report=term-missing
# Run code formatters and linters
black --check fastapi_idempotency_key tests examples
flake8 fastapi_idempotency_key tests examples
mypy fastapi_idempotency_key tests examples
📄 License
This project is licensed under the terms of the MIT License.
Metadata
Release files for fastapi-idempotency-key 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fastapi_idempotency_key-0.1.0.tar.gz | 36.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastapi_idempotency_key-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 58.2 kB
Release files / fastapi_idempotency_key-0.1.0.tar.gz
| Download URL | fastapi_idempotency_key-0.1.0.tar.gz |
|---|---|
| Size | 36.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a89e251ae155c3d98e4f4d7741f6b12e203d6bf38b2a7114cd4f3286bba2c4b4
|
|
BLAKE2b-256 checksum How to use checksums |
0c9890afc8c8ab3eaaca296b80bc97c8ddb3ff60265f4176dae357aea6a695f3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / fastapi_idempotency_key-0.1.0-py3-none-any.whl
| Download URL | fastapi_idempotency_key-0.1.0-py3-none-any.whl |
|---|---|
| Size | 22.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
78a42eca62351528abeed2401078da2e7edaa8105db911aa24bd3e22a8de84df
|
|
BLAKE2b-256 checksum How to use checksums |
11bd8fa2c435e80a1e9668ffb7a6b5789811e90ed895c62986434ca8802267eb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|