Stripe-inspired idempotency key middleware for FastAPI — exactly-once request processing.
Project description
Ichido (一度) — "One Time"
Ichido is a drop-in, production-grade idempotency key middleware for FastAPI, backed by Redis. It guarantees exactly-once processing on unsafe HTTP endpoints (like payments, charge actions, or refunds) by ensuring retry requests with the same idempotency key are safely replayed or gracefully throttled.
It implements the core idempotency specifications used by leading API platforms like Stripe and Razorpay.
💡 Why Ichido? (Motivation & Business Value)
In distributed systems, the network is fundamentally unreliable. When a client makes an API request (e.g., to charge a credit card) and the network drops before the server can return a response, the client is left in an ambiguous state: Did the payment succeed or fail?
To recover, the client must retry the request. However, naive retries on non-idempotent endpoints (like POST /charges) lead to the double-charging problem (charging a user twice for a single order).
[ Client ] ───────── POST /charge ─────────► [ Server ] (Processes payment successfully)
[ Client ] ◄─── (Network drops / Timeout) ─── [ Server ] (Response lost)
*Client retries the request*
[ Client ] ───────── POST /charge ─────────► [ Server ] (Processes payment AGAIN - Double Charge!)
The Solution: Exactly-Once Semantics
Ichido intercepts retry requests before they execute any business logic. By deduplicating requests on the server side using a unique client-generated header token (Idempotency-Key), it achieves effectively-exactly-once execution:
- Card Charge Protection: Prevents duplicate financial transactions, keeping disputes and chargebacks low.
- Resource Protection: Stops redundant database writes and expensive third-party API executions (e.g., mail sending, billing endpoints).
- Robust Client Retry Loops: Allows client software to safely retry requests aggressively with exponential backoff, without developers having to implement complex transaction checks manually at the application layer.
🏗️ Architecture & State Machine
Client Request (Idempotency-Key)
│
▼
Atomically claim key (SET NX EX)
/ \
[Lock Acquired] [Key Already Exists]
/ \
▼ ▼
State: PROCESSING Get stored record
│ / \
Run endpoint logic State: PROCESSING State: COMPLETED
│ │ │
[Completion] Return 409 Conflict Compare fingerprint
/ \ (Retry-After: 1) / \
Success Failure Match Mismatch
/ \ / \
Update to DELETE key ▼ ▼
COMPLETED (Allow retry) Replay cached Return 422 Error
with response response (Key reused for
different body)
Redis Record JSON Schema
Each record is serialized to JSON and stored with a namespace prefix (ichido:). When in the completed state, the response body is stored using base64 encoding to support binary and text payloads cleanly.
{
"status": "processing" | "completed",
"fingerprint": "sha256_hash_value",
"response_status": 200,
"response_headers": {
"content-type": "application/json",
"x-custom-header": "value"
},
"response_body": "eyJhIjoxLCJiIjoyfQ==" // Base64 encoded payload
}
🛠️ File Structure
Ichido-Idempotency/
├── pyproject.toml # Package config and metadata
├── LICENSE # MIT license
├── README.md # You are here
├── src/
│ └── ichido/
│ ├── __init__.py # Public exports (Middleware, Config, Store)
│ ├── config.py # Frozen configuration dataclass
│ ├── exceptions.py # Library internal exceptions
│ ├── fingerprint.py # Request body canonicalizer & SHA256 hasher
│ ├── middleware.py # Pure ASGI middleware handling request/response flows
│ └── storage.py # Redis connection client wrapper & record schema
├── tests/
│ ├── conftest.py # pytest setup with mock FastAPI & fakeredis client
│ ├── test_concurrency.py # Multi-task concurrent execution tests
│ ├── test_fingerprint.py # JSON sorting & fingerprint verification
│ ├── test_middleware.py # Standard request lifecycle tests
│ └── test_storage.py # Redis store serialization tests
└── examples/
├── demo_app.py # Runnable local FastAPI server with fallback support
└── demo_concurrent.py # Client script firing concurrent/sequential request scenarios
⚙️ Configuration Reference
Use IchidoConfig to customize the middleware's behavior:
| Parameter | Type | Default | Description |
|---|---|---|---|
header_name |
str |
"Idempotency-Key" |
The HTTP request header checked by the middleware. |
ttl_seconds |
int |
86400 (24 Hours) |
Time-to-Live in seconds for the cached responses in Redis. |
enforce_methods |
tuple[str, ...] |
("POST", "PATCH", "PUT") |
HTTP methods that will run through the idempotency filter. GET/DELETE are naturally idempotent. |
key_prefix |
str |
"ichido:" |
Namespace prefix prepended to all Redis keys to avoid collisions. |
🚀 Quick Start
1. Installation
Install the package directly in editable development mode:
pip install -e ".[dev]"
2. Basic Integration
import redis.asyncio as aioredis
from fastapi import FastAPI
from ichido import IchidoMiddleware, RedisIdempotencyStore, IchidoConfig
app = FastAPI()
# 1. Initialize Redis connection pool
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
# 2. Setup the idempotency storage backend
store = RedisIdempotencyStore(redis_client=redis_client, ttl=86400)
# 3. Add the middleware to FastAPI
app.add_middleware(
IchidoMiddleware,
store=store,
config=IchidoConfig(
header_name="Idempotency-Key",
ttl_seconds=86400 # 24 Hours
)
)
@app.post("/payments/charge")
async def process_payment(amount: float):
# This logic is guaranteed to execute exactly once for any unique Idempotency-Key header.
return {"status": "success", "charge_id": "ch_12345"}
🧪 Testing & Live Demo
Ichido includes an automated suite and an end-to-end sandbox application.
Running Unit Tests
To run the unit tests (which automatically mock Redis using fakeredis):
python -m pytest tests/ -v
Running the Live Sandbox Demo
You can run the live demo either locally (via a mock store) or connected to a free Cloud Redis provider (like Upstash).
Option A: Zero Configuration (In-Memory Mock)
If you don't have Redis installed, the demo app will automatically fall back to an in-memory fakeredis database.
- Start the API Server:
uvicorn examples.demo_app:app --port 8000 --reload
- Execute the Simulator Client in another terminal:
python examples.demo_concurrent.py
Option B: Connected to a Cloud Redis Server (e.g. Upstash)
- Go to Upstash and create a free serverless database.
- Copy the connection string under Redis URL (
rediss://default:...). - Start the API Server with the environment variable set:
# Windows PowerShell $env:REDIS_URL="rediss://default:yourpassword@your-endpoint.upstash.io:6379" uvicorn examples.demo_app:app --port 8000 --reload
- Execute the Simulator Client:
python examples.demo_concurrent.py
Expected Console Output Trace
When executing demo_concurrent.py, the client output demonstrates the state transitions:
======================================================================
DEMO 1: CONCURRENT DUPLICATE REQUESTS (State: PROCESSING)
Idempotency Key: 2391c028-804f-460f-b1f2-1e3e4110b127
Firing 2 requests simultaneously. The server takes ~2.5s to process...
======================================================================
Response #1:
Status Code: 200
Headers:
X-Ichido-Status: executed
Retry-After: None
Body: {"status":"success","transaction_id":"tx_2fc0df1b420d","amount_charged":49.99,"currency":"USD","msg":"Payment processed successfully."}
Response #2:
Status Code: 409
Headers:
X-Ichido-Status: None
Retry-After: 1
Body: {"error":"request_in_progress","message":"A request with idempotency key '2391c028-804f-460f-b1f2-1e3e4110b127' is currently being processed.Please retry later."}
======================================================================
DEMO 2: REPLAYED REQUEST (State: COMPLETED)
Firing a 3rd request with the SAME key and SAME body...
======================================================================
Response #3 (Replayed):
Status Code: 200
Headers:
X-Ichido-Status: replayed
Body: {"status":"success","transaction_id":"tx_2fc0df1b420d","amount_charged":49.99,"currency":"USD","msg":"Payment processed successfully."}
======================================================================
DEMO 3: REQUEST PARAMETER MISMATCH (State: COMPLETED, Different Body)
Firing a 4th request with the SAME key but a DIFFERENT amount ($99.99)...
======================================================================
Response #4 (Mismatch):
Status Code: 422
Body: {"error":"fingerprint_mismatch","message":"Idempotency key '2391c028-804f-460f-b1f2-1e3e4110b127' was already used with different request parameters. Each key must be used with identical requests."}
💡 Interview Q&A / Architecture Defense
Be ready to explain these core distributed systems design decisions:
1. Why Redis instead of PostgreSQL for Idempotency?
- Speed: Idempotency checks sit in the critical hot path of payments APIs. Redis is an in-memory datastore that offers sub-millisecond lookups.
- Auto Expiration: Redis provides built-in Time-to-Live (TTL) key expiration natively. Relational databases require custom cron tasks, worker scripts, or database trigger cleanups to delete outdated idempotency keys.
- Atomic Operations: Redis executes commands single-threaded, allowing us to perform atomic
SET NX EXlocks in a single roundtrip. - Tradeoff: Redis is not durable. If a node crashes, in-flight keys are lost. In a massive scale production payments app, you would use Redis for the fast concurrency lock and short-term caching, backed by a persistent relational database (Postgres) as the source-of-truth for completed transactions.
2. How is the concurrent duplicate request problem solved?
- When two identical requests with the same key arrive simultaneously, both attempt a Redis
SET key value NX EX TTL. - Exactly one request succeeds (receiving the lock) and proceeds to run the downstream endpoint logic, setting the state to
processing. - The second request fails to write the key, reads the current state as
processing, and immediately aborts, returning409 Conflictwith aRetry-After: 1header. - Tradeoff vs. Polling: We return
409instead of holding the second connection open and polling. Holding connections open consumes web server threads/workers. Under load, this could exhaust the worker pool and lead to a server-wide denial of service. Pushing the retry logic to the client is significantly more resilient.
3. Why did you choose pure ASGI middleware instead of Starlette's BaseHTTPMiddleware?
- Starlette's
BaseHTTPMiddlewarewraps requests in separate background threads usinganyio. This adds substantial latency overhead, triggers memory leaks, and causes socket disconnect errors under high-concurrency request streams. - Pure ASGI middleware directly implements
__call__(scope, receive, send). This gives us raw access to ASGI request streams and response chunks without threading overhead, which is critical for high-throughput gateway services.
4. What is At-Least-Once vs. Exactly-Once processing?
- At-Least-Once: The network is unreliable. If a request times out, the client retries. Without server-side deduplication, this results in duplicate side effects (e.g. charging a card twice).
- Exactly-Once: Combining client-side retries (At-Least-Once delivery) with server-side deduplication (using idempotency keys) achieves Exactly-Once execution. The action occurs exactly once, even if the request was sent multiple times.
📄 License
Distributed under the MIT License. See LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ichido-0.1.0.tar.gz.
File metadata
- Download URL: ichido-0.1.0.tar.gz
- Upload date:
- Size: 25.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
996c88fd28c165356b77ebe985945ae1ab97cd8015488286fc1084d0a4d7cba9
|
|
| MD5 |
458a0f7a3491b81b49204694c1d7fb2e
|
|
| BLAKE2b-256 |
290cc39c7bd64f574743e173b12e14a6d1e54297c68521b850b3e34feeeddf3f
|
File details
Details for the file ichido-0.1.0-py3-none-any.whl.
File metadata
- Download URL: ichido-0.1.0-py3-none-any.whl
- Upload date:
- Size: 17.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fbb4136b1f2421ca6b787700615227aae83a766bc54e606076d08f41003b404
|
|
| MD5 |
ba4f27a81aa94d2e38942126edde9f24
|
|
| BLAKE2b-256 |
8b5339e26c86ddba4a2990d323fb118675e06cd5f91cd44b31e893391b991a07
|