ichido 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ichido-0.1.0/LICENSE +21 -0
- ichido-0.1.0/PKG-INFO +317 -0
- ichido-0.1.0/README.md +287 -0
- ichido-0.1.0/pyproject.toml +50 -0
- ichido-0.1.0/setup.cfg +4 -0
- ichido-0.1.0/src/ichido/__init__.py +25 -0
- ichido-0.1.0/src/ichido/config.py +33 -0
- ichido-0.1.0/src/ichido/exceptions.py +50 -0
- ichido-0.1.0/src/ichido/fingerprint.py +82 -0
- ichido-0.1.0/src/ichido/middleware.py +344 -0
- ichido-0.1.0/src/ichido/storage.py +197 -0
- ichido-0.1.0/src/ichido.egg-info/PKG-INFO +317 -0
- ichido-0.1.0/src/ichido.egg-info/SOURCES.txt +18 -0
- ichido-0.1.0/src/ichido.egg-info/dependency_links.txt +1 -0
- ichido-0.1.0/src/ichido.egg-info/requires.txt +9 -0
- ichido-0.1.0/src/ichido.egg-info/top_level.txt +1 -0
- ichido-0.1.0/tests/test_concurrency.py +149 -0
- ichido-0.1.0/tests/test_fingerprint.py +100 -0
- ichido-0.1.0/tests/test_middleware.py +193 -0
- ichido-0.1.0/tests/test_storage.py +110 -0
ichido-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akhil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ichido-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: ichido
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stripe-inspired idempotency key middleware for FastAPI β exactly-once request processing.
|
|
5
|
+
Author: Akhil
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/akhil/ichido
|
|
8
|
+
Project-URL: Repository, https://github.com/akhil/ichido
|
|
9
|
+
Project-URL: Issues, https://github.com/akhil/ichido/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: fastapi>=0.100.0
|
|
23
|
+
Requires-Dist: redis>=5.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
27
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
28
|
+
Requires-Dist: fakeredis>=2.21; extra == "dev"
|
|
29
|
+
Requires-Dist: uvicorn>=0.30; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Ichido (δΈεΊ¦) β "One Time"
|
|
32
|
+
|
|
33
|
+
[](https://www.python.org/)
|
|
34
|
+
[](https://fastapi.tiangolo.com)
|
|
35
|
+
[](https://redis.io)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
|
|
38
|
+
**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.
|
|
39
|
+
|
|
40
|
+
It implements the core idempotency specifications used by leading API platforms like **Stripe** and **Razorpay**.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## π‘ Why Ichido? (Motivation & Business Value)
|
|
45
|
+
|
|
46
|
+
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?**
|
|
47
|
+
|
|
48
|
+
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).
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
[ Client ] βββββββββ POST /charge ββββββββββΊ [ Server ] (Processes payment successfully)
|
|
52
|
+
[ Client ] ββββ (Network drops / Timeout) βββ [ Server ] (Response lost)
|
|
53
|
+
|
|
54
|
+
*Client retries the request*
|
|
55
|
+
|
|
56
|
+
[ Client ] βββββββββ POST /charge ββββββββββΊ [ Server ] (Processes payment AGAIN - Double Charge!)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### The Solution: Exactly-Once Semantics
|
|
60
|
+
**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**:
|
|
61
|
+
|
|
62
|
+
- **Card Charge Protection**: Prevents duplicate financial transactions, keeping disputes and chargebacks low.
|
|
63
|
+
- **Resource Protection**: Stops redundant database writes and expensive third-party API executions (e.g., mail sending, billing endpoints).
|
|
64
|
+
- **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.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## ποΈ Architecture & State Machine
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Client Request (Idempotency-Key)
|
|
72
|
+
β
|
|
73
|
+
βΌ
|
|
74
|
+
Atomically claim key (SET NX EX)
|
|
75
|
+
/ \
|
|
76
|
+
[Lock Acquired] [Key Already Exists]
|
|
77
|
+
/ \
|
|
78
|
+
βΌ βΌ
|
|
79
|
+
State: PROCESSING Get stored record
|
|
80
|
+
β / \
|
|
81
|
+
Run endpoint logic State: PROCESSING State: COMPLETED
|
|
82
|
+
β β β
|
|
83
|
+
[Completion] Return 409 Conflict Compare fingerprint
|
|
84
|
+
/ \ (Retry-After: 1) / \
|
|
85
|
+
Success Failure Match Mismatch
|
|
86
|
+
/ \ / \
|
|
87
|
+
Update to DELETE key βΌ βΌ
|
|
88
|
+
COMPLETED (Allow retry) Replay cached Return 422 Error
|
|
89
|
+
with response response (Key reused for
|
|
90
|
+
different body)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Redis Record JSON Schema
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"status": "processing" | "completed",
|
|
99
|
+
"fingerprint": "sha256_hash_value",
|
|
100
|
+
"response_status": 200,
|
|
101
|
+
"response_headers": {
|
|
102
|
+
"content-type": "application/json",
|
|
103
|
+
"x-custom-header": "value"
|
|
104
|
+
},
|
|
105
|
+
"response_body": "eyJhIjoxLCJiIjoyfQ==" // Base64 encoded payload
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## π οΈ File Structure
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Ichido-Idempotency/
|
|
115
|
+
βββ pyproject.toml # Package config and metadata
|
|
116
|
+
βββ LICENSE # MIT license
|
|
117
|
+
βββ README.md # You are here
|
|
118
|
+
βββ src/
|
|
119
|
+
β βββ ichido/
|
|
120
|
+
β βββ __init__.py # Public exports (Middleware, Config, Store)
|
|
121
|
+
β βββ config.py # Frozen configuration dataclass
|
|
122
|
+
β βββ exceptions.py # Library internal exceptions
|
|
123
|
+
β βββ fingerprint.py # Request body canonicalizer & SHA256 hasher
|
|
124
|
+
β βββ middleware.py # Pure ASGI middleware handling request/response flows
|
|
125
|
+
β βββ storage.py # Redis connection client wrapper & record schema
|
|
126
|
+
βββ tests/
|
|
127
|
+
β βββ conftest.py # pytest setup with mock FastAPI & fakeredis client
|
|
128
|
+
β βββ test_concurrency.py # Multi-task concurrent execution tests
|
|
129
|
+
β βββ test_fingerprint.py # JSON sorting & fingerprint verification
|
|
130
|
+
β βββ test_middleware.py # Standard request lifecycle tests
|
|
131
|
+
β βββ test_storage.py # Redis store serialization tests
|
|
132
|
+
βββ examples/
|
|
133
|
+
βββ demo_app.py # Runnable local FastAPI server with fallback support
|
|
134
|
+
βββ demo_concurrent.py # Client script firing concurrent/sequential request scenarios
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## βοΈ Configuration Reference
|
|
140
|
+
|
|
141
|
+
Use `IchidoConfig` to customize the middleware's behavior:
|
|
142
|
+
|
|
143
|
+
| Parameter | Type | Default | Description |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| `header_name` | `str` | `"Idempotency-Key"` | The HTTP request header checked by the middleware. |
|
|
146
|
+
| `ttl_seconds` | `int` | `86400` (24 Hours) | Time-to-Live in seconds for the cached responses in Redis. |
|
|
147
|
+
| `enforce_methods` | `tuple[str, ...]` | `("POST", "PATCH", "PUT")` | HTTP methods that will run through the idempotency filter. GET/DELETE are naturally idempotent. |
|
|
148
|
+
| `key_prefix` | `str` | `"ichido:"` | Namespace prefix prepended to all Redis keys to avoid collisions. |
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## π Quick Start
|
|
153
|
+
|
|
154
|
+
### 1. Installation
|
|
155
|
+
|
|
156
|
+
Install the package directly in editable development mode:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
pip install -e ".[dev]"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 2. Basic Integration
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import redis.asyncio as aioredis
|
|
166
|
+
from fastapi import FastAPI
|
|
167
|
+
from ichido import IchidoMiddleware, RedisIdempotencyStore, IchidoConfig
|
|
168
|
+
|
|
169
|
+
app = FastAPI()
|
|
170
|
+
|
|
171
|
+
# 1. Initialize Redis connection pool
|
|
172
|
+
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
173
|
+
|
|
174
|
+
# 2. Setup the idempotency storage backend
|
|
175
|
+
store = RedisIdempotencyStore(redis_client=redis_client, ttl=86400)
|
|
176
|
+
|
|
177
|
+
# 3. Add the middleware to FastAPI
|
|
178
|
+
app.add_middleware(
|
|
179
|
+
IchidoMiddleware,
|
|
180
|
+
store=store,
|
|
181
|
+
config=IchidoConfig(
|
|
182
|
+
header_name="Idempotency-Key",
|
|
183
|
+
ttl_seconds=86400 # 24 Hours
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@app.post("/payments/charge")
|
|
188
|
+
async def process_payment(amount: float):
|
|
189
|
+
# This logic is guaranteed to execute exactly once for any unique Idempotency-Key header.
|
|
190
|
+
return {"status": "success", "charge_id": "ch_12345"}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## π§ͺ Testing & Live Demo
|
|
196
|
+
|
|
197
|
+
Ichido includes an automated suite and an end-to-end sandbox application.
|
|
198
|
+
|
|
199
|
+
### Running Unit Tests
|
|
200
|
+
|
|
201
|
+
To run the unit tests (which automatically mock Redis using `fakeredis`):
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
python -m pytest tests/ -v
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### Running the Live Sandbox Demo
|
|
210
|
+
|
|
211
|
+
You can run the live demo either locally (via a mock store) or connected to a **free Cloud Redis** provider (like Upstash).
|
|
212
|
+
|
|
213
|
+
#### Option A: Zero Configuration (In-Memory Mock)
|
|
214
|
+
If you don't have Redis installed, the demo app will automatically fall back to an in-memory `fakeredis` database.
|
|
215
|
+
|
|
216
|
+
1. **Start the API Server**:
|
|
217
|
+
```bash
|
|
218
|
+
uvicorn examples.demo_app:app --port 8000 --reload
|
|
219
|
+
```
|
|
220
|
+
2. **Execute the Simulator Client** in another terminal:
|
|
221
|
+
```bash
|
|
222
|
+
python examples.demo_concurrent.py
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### Option B: Connected to a Cloud Redis Server (e.g. Upstash)
|
|
226
|
+
1. Go to [Upstash](https://console.upstash.com/) and create a free serverless database.
|
|
227
|
+
2. Copy the connection string under **Redis URL** (`rediss://default:...`).
|
|
228
|
+
3. **Start the API Server** with the environment variable set:
|
|
229
|
+
```bash
|
|
230
|
+
# Windows PowerShell
|
|
231
|
+
$env:REDIS_URL="rediss://default:yourpassword@your-endpoint.upstash.io:6379"
|
|
232
|
+
uvicorn examples.demo_app:app --port 8000 --reload
|
|
233
|
+
```
|
|
234
|
+
4. **Execute the Simulator Client**:
|
|
235
|
+
```bash
|
|
236
|
+
python examples.demo_concurrent.py
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
### Expected Console Output Trace
|
|
242
|
+
|
|
243
|
+
When executing `demo_concurrent.py`, the client output demonstrates the state transitions:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
======================================================================
|
|
247
|
+
DEMO 1: CONCURRENT DUPLICATE REQUESTS (State: PROCESSING)
|
|
248
|
+
Idempotency Key: 2391c028-804f-460f-b1f2-1e3e4110b127
|
|
249
|
+
Firing 2 requests simultaneously. The server takes ~2.5s to process...
|
|
250
|
+
======================================================================
|
|
251
|
+
|
|
252
|
+
Response #1:
|
|
253
|
+
Status Code: 200
|
|
254
|
+
Headers:
|
|
255
|
+
X-Ichido-Status: executed
|
|
256
|
+
Retry-After: None
|
|
257
|
+
Body: {"status":"success","transaction_id":"tx_2fc0df1b420d","amount_charged":49.99,"currency":"USD","msg":"Payment processed successfully."}
|
|
258
|
+
|
|
259
|
+
Response #2:
|
|
260
|
+
Status Code: 409
|
|
261
|
+
Headers:
|
|
262
|
+
X-Ichido-Status: None
|
|
263
|
+
Retry-After: 1
|
|
264
|
+
Body: {"error":"request_in_progress","message":"A request with idempotency key '2391c028-804f-460f-b1f2-1e3e4110b127' is currently being processed.Please retry later."}
|
|
265
|
+
|
|
266
|
+
======================================================================
|
|
267
|
+
DEMO 2: REPLAYED REQUEST (State: COMPLETED)
|
|
268
|
+
Firing a 3rd request with the SAME key and SAME body...
|
|
269
|
+
======================================================================
|
|
270
|
+
|
|
271
|
+
Response #3 (Replayed):
|
|
272
|
+
Status Code: 200
|
|
273
|
+
Headers:
|
|
274
|
+
X-Ichido-Status: replayed
|
|
275
|
+
Body: {"status":"success","transaction_id":"tx_2fc0df1b420d","amount_charged":49.99,"currency":"USD","msg":"Payment processed successfully."}
|
|
276
|
+
|
|
277
|
+
======================================================================
|
|
278
|
+
DEMO 3: REQUEST PARAMETER MISMATCH (State: COMPLETED, Different Body)
|
|
279
|
+
Firing a 4th request with the SAME key but a DIFFERENT amount ($99.99)...
|
|
280
|
+
======================================================================
|
|
281
|
+
|
|
282
|
+
Response #4 (Mismatch):
|
|
283
|
+
Status Code: 422
|
|
284
|
+
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."}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## π‘ Interview Q&A / Architecture Defense
|
|
290
|
+
|
|
291
|
+
Be ready to explain these core distributed systems design decisions:
|
|
292
|
+
|
|
293
|
+
### 1. Why Redis instead of PostgreSQL for Idempotency?
|
|
294
|
+
- **Speed**: Idempotency checks sit in the critical hot path of payments APIs. Redis is an in-memory datastore that offers sub-millisecond lookups.
|
|
295
|
+
- **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.
|
|
296
|
+
- **Atomic Operations**: Redis executes commands single-threaded, allowing us to perform atomic `SET NX EX` locks in a single roundtrip.
|
|
297
|
+
- **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.
|
|
298
|
+
|
|
299
|
+
### 2. How is the concurrent duplicate request problem solved?
|
|
300
|
+
- When two identical requests with the same key arrive simultaneously, both attempt a Redis `SET key value NX EX TTL`.
|
|
301
|
+
- Exactly one request succeeds (receiving the lock) and proceeds to run the downstream endpoint logic, setting the state to `processing`.
|
|
302
|
+
- The second request fails to write the key, reads the current state as `processing`, and immediately aborts, returning `409 Conflict` with a `Retry-After: 1` header.
|
|
303
|
+
- **Tradeoff vs. Polling**: We return `409` instead 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.
|
|
304
|
+
|
|
305
|
+
### 3. Why did you choose pure ASGI middleware instead of Starlette's `BaseHTTPMiddleware`?
|
|
306
|
+
- Starlette's `BaseHTTPMiddleware` wraps requests in separate background threads using `anyio`. This adds substantial latency overhead, triggers memory leaks, and causes socket disconnect errors under high-concurrency request streams.
|
|
307
|
+
- 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.
|
|
308
|
+
|
|
309
|
+
### 4. What is At-Least-Once vs. Exactly-Once processing?
|
|
310
|
+
- **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).
|
|
311
|
+
- **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.
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## π License
|
|
316
|
+
|
|
317
|
+
Distributed under the MIT License. See `LICENSE` for details.
|
ichido-0.1.0/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Ichido (δΈεΊ¦) β "One Time"
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://fastapi.tiangolo.com)
|
|
5
|
+
[](https://redis.io)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**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.
|
|
9
|
+
|
|
10
|
+
It implements the core idempotency specifications used by leading API platforms like **Stripe** and **Razorpay**.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## π‘ Why Ichido? (Motivation & Business Value)
|
|
15
|
+
|
|
16
|
+
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?**
|
|
17
|
+
|
|
18
|
+
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).
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
[ Client ] βββββββββ POST /charge ββββββββββΊ [ Server ] (Processes payment successfully)
|
|
22
|
+
[ Client ] ββββ (Network drops / Timeout) βββ [ Server ] (Response lost)
|
|
23
|
+
|
|
24
|
+
*Client retries the request*
|
|
25
|
+
|
|
26
|
+
[ Client ] βββββββββ POST /charge ββββββββββΊ [ Server ] (Processes payment AGAIN - Double Charge!)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### The Solution: Exactly-Once Semantics
|
|
30
|
+
**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**:
|
|
31
|
+
|
|
32
|
+
- **Card Charge Protection**: Prevents duplicate financial transactions, keeping disputes and chargebacks low.
|
|
33
|
+
- **Resource Protection**: Stops redundant database writes and expensive third-party API executions (e.g., mail sending, billing endpoints).
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## ποΈ Architecture & State Machine
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Client Request (Idempotency-Key)
|
|
42
|
+
β
|
|
43
|
+
βΌ
|
|
44
|
+
Atomically claim key (SET NX EX)
|
|
45
|
+
/ \
|
|
46
|
+
[Lock Acquired] [Key Already Exists]
|
|
47
|
+
/ \
|
|
48
|
+
βΌ βΌ
|
|
49
|
+
State: PROCESSING Get stored record
|
|
50
|
+
β / \
|
|
51
|
+
Run endpoint logic State: PROCESSING State: COMPLETED
|
|
52
|
+
β β β
|
|
53
|
+
[Completion] Return 409 Conflict Compare fingerprint
|
|
54
|
+
/ \ (Retry-After: 1) / \
|
|
55
|
+
Success Failure Match Mismatch
|
|
56
|
+
/ \ / \
|
|
57
|
+
Update to DELETE key βΌ βΌ
|
|
58
|
+
COMPLETED (Allow retry) Replay cached Return 422 Error
|
|
59
|
+
with response response (Key reused for
|
|
60
|
+
different body)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Redis Record JSON Schema
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"status": "processing" | "completed",
|
|
69
|
+
"fingerprint": "sha256_hash_value",
|
|
70
|
+
"response_status": 200,
|
|
71
|
+
"response_headers": {
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
"x-custom-header": "value"
|
|
74
|
+
},
|
|
75
|
+
"response_body": "eyJhIjoxLCJiIjoyfQ==" // Base64 encoded payload
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## π οΈ File Structure
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Ichido-Idempotency/
|
|
85
|
+
βββ pyproject.toml # Package config and metadata
|
|
86
|
+
βββ LICENSE # MIT license
|
|
87
|
+
βββ README.md # You are here
|
|
88
|
+
βββ src/
|
|
89
|
+
β βββ ichido/
|
|
90
|
+
β βββ __init__.py # Public exports (Middleware, Config, Store)
|
|
91
|
+
β βββ config.py # Frozen configuration dataclass
|
|
92
|
+
β βββ exceptions.py # Library internal exceptions
|
|
93
|
+
β βββ fingerprint.py # Request body canonicalizer & SHA256 hasher
|
|
94
|
+
β βββ middleware.py # Pure ASGI middleware handling request/response flows
|
|
95
|
+
β βββ storage.py # Redis connection client wrapper & record schema
|
|
96
|
+
βββ tests/
|
|
97
|
+
β βββ conftest.py # pytest setup with mock FastAPI & fakeredis client
|
|
98
|
+
β βββ test_concurrency.py # Multi-task concurrent execution tests
|
|
99
|
+
β βββ test_fingerprint.py # JSON sorting & fingerprint verification
|
|
100
|
+
β βββ test_middleware.py # Standard request lifecycle tests
|
|
101
|
+
β βββ test_storage.py # Redis store serialization tests
|
|
102
|
+
βββ examples/
|
|
103
|
+
βββ demo_app.py # Runnable local FastAPI server with fallback support
|
|
104
|
+
βββ demo_concurrent.py # Client script firing concurrent/sequential request scenarios
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## βοΈ Configuration Reference
|
|
110
|
+
|
|
111
|
+
Use `IchidoConfig` to customize the middleware's behavior:
|
|
112
|
+
|
|
113
|
+
| Parameter | Type | Default | Description |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `header_name` | `str` | `"Idempotency-Key"` | The HTTP request header checked by the middleware. |
|
|
116
|
+
| `ttl_seconds` | `int` | `86400` (24 Hours) | Time-to-Live in seconds for the cached responses in Redis. |
|
|
117
|
+
| `enforce_methods` | `tuple[str, ...]` | `("POST", "PATCH", "PUT")` | HTTP methods that will run through the idempotency filter. GET/DELETE are naturally idempotent. |
|
|
118
|
+
| `key_prefix` | `str` | `"ichido:"` | Namespace prefix prepended to all Redis keys to avoid collisions. |
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## π Quick Start
|
|
123
|
+
|
|
124
|
+
### 1. Installation
|
|
125
|
+
|
|
126
|
+
Install the package directly in editable development mode:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pip install -e ".[dev]"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 2. Basic Integration
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import redis.asyncio as aioredis
|
|
136
|
+
from fastapi import FastAPI
|
|
137
|
+
from ichido import IchidoMiddleware, RedisIdempotencyStore, IchidoConfig
|
|
138
|
+
|
|
139
|
+
app = FastAPI()
|
|
140
|
+
|
|
141
|
+
# 1. Initialize Redis connection pool
|
|
142
|
+
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
143
|
+
|
|
144
|
+
# 2. Setup the idempotency storage backend
|
|
145
|
+
store = RedisIdempotencyStore(redis_client=redis_client, ttl=86400)
|
|
146
|
+
|
|
147
|
+
# 3. Add the middleware to FastAPI
|
|
148
|
+
app.add_middleware(
|
|
149
|
+
IchidoMiddleware,
|
|
150
|
+
store=store,
|
|
151
|
+
config=IchidoConfig(
|
|
152
|
+
header_name="Idempotency-Key",
|
|
153
|
+
ttl_seconds=86400 # 24 Hours
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@app.post("/payments/charge")
|
|
158
|
+
async def process_payment(amount: float):
|
|
159
|
+
# This logic is guaranteed to execute exactly once for any unique Idempotency-Key header.
|
|
160
|
+
return {"status": "success", "charge_id": "ch_12345"}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## π§ͺ Testing & Live Demo
|
|
166
|
+
|
|
167
|
+
Ichido includes an automated suite and an end-to-end sandbox application.
|
|
168
|
+
|
|
169
|
+
### Running Unit Tests
|
|
170
|
+
|
|
171
|
+
To run the unit tests (which automatically mock Redis using `fakeredis`):
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
python -m pytest tests/ -v
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### Running the Live Sandbox Demo
|
|
180
|
+
|
|
181
|
+
You can run the live demo either locally (via a mock store) or connected to a **free Cloud Redis** provider (like Upstash).
|
|
182
|
+
|
|
183
|
+
#### Option A: Zero Configuration (In-Memory Mock)
|
|
184
|
+
If you don't have Redis installed, the demo app will automatically fall back to an in-memory `fakeredis` database.
|
|
185
|
+
|
|
186
|
+
1. **Start the API Server**:
|
|
187
|
+
```bash
|
|
188
|
+
uvicorn examples.demo_app:app --port 8000 --reload
|
|
189
|
+
```
|
|
190
|
+
2. **Execute the Simulator Client** in another terminal:
|
|
191
|
+
```bash
|
|
192
|
+
python examples.demo_concurrent.py
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### Option B: Connected to a Cloud Redis Server (e.g. Upstash)
|
|
196
|
+
1. Go to [Upstash](https://console.upstash.com/) and create a free serverless database.
|
|
197
|
+
2. Copy the connection string under **Redis URL** (`rediss://default:...`).
|
|
198
|
+
3. **Start the API Server** with the environment variable set:
|
|
199
|
+
```bash
|
|
200
|
+
# Windows PowerShell
|
|
201
|
+
$env:REDIS_URL="rediss://default:yourpassword@your-endpoint.upstash.io:6379"
|
|
202
|
+
uvicorn examples.demo_app:app --port 8000 --reload
|
|
203
|
+
```
|
|
204
|
+
4. **Execute the Simulator Client**:
|
|
205
|
+
```bash
|
|
206
|
+
python examples.demo_concurrent.py
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Expected Console Output Trace
|
|
212
|
+
|
|
213
|
+
When executing `demo_concurrent.py`, the client output demonstrates the state transitions:
|
|
214
|
+
|
|
215
|
+
```text
|
|
216
|
+
======================================================================
|
|
217
|
+
DEMO 1: CONCURRENT DUPLICATE REQUESTS (State: PROCESSING)
|
|
218
|
+
Idempotency Key: 2391c028-804f-460f-b1f2-1e3e4110b127
|
|
219
|
+
Firing 2 requests simultaneously. The server takes ~2.5s to process...
|
|
220
|
+
======================================================================
|
|
221
|
+
|
|
222
|
+
Response #1:
|
|
223
|
+
Status Code: 200
|
|
224
|
+
Headers:
|
|
225
|
+
X-Ichido-Status: executed
|
|
226
|
+
Retry-After: None
|
|
227
|
+
Body: {"status":"success","transaction_id":"tx_2fc0df1b420d","amount_charged":49.99,"currency":"USD","msg":"Payment processed successfully."}
|
|
228
|
+
|
|
229
|
+
Response #2:
|
|
230
|
+
Status Code: 409
|
|
231
|
+
Headers:
|
|
232
|
+
X-Ichido-Status: None
|
|
233
|
+
Retry-After: 1
|
|
234
|
+
Body: {"error":"request_in_progress","message":"A request with idempotency key '2391c028-804f-460f-b1f2-1e3e4110b127' is currently being processed.Please retry later."}
|
|
235
|
+
|
|
236
|
+
======================================================================
|
|
237
|
+
DEMO 2: REPLAYED REQUEST (State: COMPLETED)
|
|
238
|
+
Firing a 3rd request with the SAME key and SAME body...
|
|
239
|
+
======================================================================
|
|
240
|
+
|
|
241
|
+
Response #3 (Replayed):
|
|
242
|
+
Status Code: 200
|
|
243
|
+
Headers:
|
|
244
|
+
X-Ichido-Status: replayed
|
|
245
|
+
Body: {"status":"success","transaction_id":"tx_2fc0df1b420d","amount_charged":49.99,"currency":"USD","msg":"Payment processed successfully."}
|
|
246
|
+
|
|
247
|
+
======================================================================
|
|
248
|
+
DEMO 3: REQUEST PARAMETER MISMATCH (State: COMPLETED, Different Body)
|
|
249
|
+
Firing a 4th request with the SAME key but a DIFFERENT amount ($99.99)...
|
|
250
|
+
======================================================================
|
|
251
|
+
|
|
252
|
+
Response #4 (Mismatch):
|
|
253
|
+
Status Code: 422
|
|
254
|
+
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."}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## π‘ Interview Q&A / Architecture Defense
|
|
260
|
+
|
|
261
|
+
Be ready to explain these core distributed systems design decisions:
|
|
262
|
+
|
|
263
|
+
### 1. Why Redis instead of PostgreSQL for Idempotency?
|
|
264
|
+
- **Speed**: Idempotency checks sit in the critical hot path of payments APIs. Redis is an in-memory datastore that offers sub-millisecond lookups.
|
|
265
|
+
- **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.
|
|
266
|
+
- **Atomic Operations**: Redis executes commands single-threaded, allowing us to perform atomic `SET NX EX` locks in a single roundtrip.
|
|
267
|
+
- **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.
|
|
268
|
+
|
|
269
|
+
### 2. How is the concurrent duplicate request problem solved?
|
|
270
|
+
- When two identical requests with the same key arrive simultaneously, both attempt a Redis `SET key value NX EX TTL`.
|
|
271
|
+
- Exactly one request succeeds (receiving the lock) and proceeds to run the downstream endpoint logic, setting the state to `processing`.
|
|
272
|
+
- The second request fails to write the key, reads the current state as `processing`, and immediately aborts, returning `409 Conflict` with a `Retry-After: 1` header.
|
|
273
|
+
- **Tradeoff vs. Polling**: We return `409` instead 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.
|
|
274
|
+
|
|
275
|
+
### 3. Why did you choose pure ASGI middleware instead of Starlette's `BaseHTTPMiddleware`?
|
|
276
|
+
- Starlette's `BaseHTTPMiddleware` wraps requests in separate background threads using `anyio`. This adds substantial latency overhead, triggers memory leaks, and causes socket disconnect errors under high-concurrency request streams.
|
|
277
|
+
- 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.
|
|
278
|
+
|
|
279
|
+
### 4. What is At-Least-Once vs. Exactly-Once processing?
|
|
280
|
+
- **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).
|
|
281
|
+
- **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.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## π License
|
|
286
|
+
|
|
287
|
+
Distributed under the MIT License. See `LICENSE` for details.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0,<77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ichido"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stripe-inspired idempotency key middleware for FastAPI β exactly-once request processing."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Akhil"},
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: FastAPI",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"fastapi>=0.100.0",
|
|
28
|
+
"redis>=5.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"pytest-asyncio>=0.23",
|
|
35
|
+
"httpx>=0.27",
|
|
36
|
+
"fakeredis>=2.21",
|
|
37
|
+
"uvicorn>=0.30",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/akhil/ichido"
|
|
42
|
+
Repository = "https://github.com/akhil/ichido"
|
|
43
|
+
Issues = "https://github.com/akhil/ichido/issues"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
ichido-0.1.0/setup.cfg
ADDED