ichido 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ Ichido (一度) — "One Time"
3
+
4
+ Stripe-inspired idempotency key middleware for FastAPI.
5
+ Guarantees exactly-once processing for unsafe HTTP methods.
6
+
7
+ Usage:
8
+ from ichido import IchidoMiddleware, IchidoConfig, RedisIdempotencyStore
9
+
10
+ app = FastAPI()
11
+ store = RedisIdempotencyStore(redis_client)
12
+ app.add_middleware(IchidoMiddleware, store=store)
13
+ """
14
+
15
+ from ichido.config import IchidoConfig
16
+ from ichido.middleware import IchidoMiddleware
17
+ from ichido.storage import RedisIdempotencyStore
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "IchidoMiddleware",
23
+ "IchidoConfig",
24
+ "RedisIdempotencyStore",
25
+ ]
ichido/config.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ Configuration for the Ichido idempotency middleware.
3
+
4
+ Design decision: Using a dataclass (not Pydantic BaseModel) because this is a
5
+ library — we don't want to force a Pydantic dependency on users who might be
6
+ using a different validation library. dataclass is stdlib, zero cost.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class IchidoConfig:
14
+ """
15
+ Configuration for IchidoMiddleware.
16
+
17
+ Attributes:
18
+ header_name: HTTP header name for the idempotency key.
19
+ Default matches Stripe's convention.
20
+ ttl_seconds: How long to keep idempotency records in Redis.
21
+ 24 hours matches Stripe's default. After expiry, the same key
22
+ can be reused for a new request.
23
+ enforce_methods: HTTP methods that require idempotency key processing.
24
+ GET, DELETE, HEAD are naturally idempotent — no need to enforce.
25
+ POST is the primary target. PATCH and PUT are included because
26
+ in payment APIs, these often have side effects (e.g., updating
27
+ a charge amount).
28
+ """
29
+
30
+ header_name: str = "Idempotency-Key"
31
+ ttl_seconds: int = 86400 # 24 hours
32
+ enforce_methods: tuple[str, ...] = ("POST", "PATCH", "PUT")
33
+ key_prefix: str = "ichido:"
ichido/exceptions.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ Custom exceptions for Ichido.
3
+
4
+ These are internal exceptions used by the middleware to communicate state
5
+ between the storage layer and the middleware dispatch logic. They are NOT
6
+ exposed to the end user — the middleware catches them and converts to
7
+ appropriate HTTP responses.
8
+ """
9
+
10
+
11
+ class IchidoError(Exception):
12
+ """Base exception for all Ichido errors."""
13
+
14
+ pass
15
+
16
+
17
+ class FingerprintMismatchError(IchidoError):
18
+ """
19
+ Raised when an idempotency key is reused with a different request body.
20
+
21
+ This is a real bug class: a client accidentally reuses a key meant for
22
+ a different operation. Returning the cached response would be dangerous
23
+ (e.g., replaying a $10 charge response for a $1000 charge request).
24
+
25
+ The middleware converts this to a 422 Unprocessable Entity.
26
+ """
27
+
28
+ def __init__(self, key: str) -> None:
29
+ self.key = key
30
+ super().__init__(
31
+ f"Idempotency key '{key}' was already used with different "
32
+ f"request parameters. Each key must be used with identical requests."
33
+ )
34
+
35
+
36
+ class RequestInProgressError(IchidoError):
37
+ """
38
+ Raised when a request with the same idempotency key is already being processed.
39
+
40
+ This is the concurrent-duplicate case — two identical requests arrive
41
+ before either finishes. The middleware converts this to a 409 Conflict
42
+ with a Retry-After header.
43
+ """
44
+
45
+ def __init__(self, key: str) -> None:
46
+ self.key = key
47
+ super().__init__(
48
+ f"A request with idempotency key '{key}' is currently being "
49
+ f"processed. Retry after the original request completes."
50
+ )
ichido/fingerprint.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ Request fingerprinting for Ichido.
3
+
4
+ The fingerprint uniquely identifies a request's *intent* — the combination of
5
+ HTTP method, URL path, and request body. This is used to detect when a client
6
+ reuses an idempotency key with a *different* request, which is a bug.
7
+
8
+ Design decisions:
9
+ - We include method + path in the fingerprint so that the same key used on
10
+ two different endpoints is detected as a mismatch (e.g., POST /charge vs
11
+ POST /refund with the same key).
12
+ - We canonicalize JSON bodies (sorted keys, no extra whitespace) because
13
+ JSON serialization order is not guaranteed. {"a":1,"b":2} and {"b":2,"a":1}
14
+ are semantically identical and must produce the same fingerprint.
15
+ - Non-JSON bodies are hashed as raw bytes — this is correct for form data,
16
+ binary uploads, etc.
17
+ - We use SHA-256 because it's fast, collision-resistant, and available in
18
+ Python's stdlib. We don't need cryptographic strength here — we just need
19
+ to detect accidental mismatches, not adversarial collisions.
20
+ """
21
+
22
+ import hashlib
23
+ import json
24
+
25
+
26
+ def canonicalize_body(body: bytes) -> bytes:
27
+ """
28
+ Canonicalize request body for consistent fingerprinting.
29
+
30
+ For JSON bodies: parse, sort keys recursively, re-serialize with no
31
+ extra whitespace. This ensures that {"a":1,"b":2} and {"b":2,"a":1}
32
+ produce the same fingerprint.
33
+
34
+ For non-JSON bodies: return as-is (raw bytes). This handles form data,
35
+ binary uploads, plain text, etc.
36
+
37
+ Args:
38
+ body: Raw request body bytes.
39
+
40
+ Returns:
41
+ Canonicalized body bytes.
42
+ """
43
+ if not body:
44
+ return b""
45
+
46
+ try:
47
+ parsed = json.loads(body)
48
+ # sort_keys=True handles nested dicts recursively
49
+ # separators=(',', ':') removes all optional whitespace
50
+ return json.dumps(parsed, sort_keys=True, separators=(",", ":")).encode("utf-8")
51
+ except (json.JSONDecodeError, UnicodeDecodeError):
52
+ # Not JSON — use raw bytes
53
+ return body
54
+
55
+
56
+ def compute_fingerprint(method: str, path: str, body: bytes) -> str:
57
+ """
58
+ Compute a SHA-256 fingerprint of a request's identity.
59
+
60
+ The fingerprint captures what the request *does* — method + path + body.
61
+ Two requests with the same fingerprint are doing the same thing and can
62
+ safely share an idempotency key. Different fingerprints mean the key
63
+ is being reused for a different operation (a bug).
64
+
65
+ Args:
66
+ method: HTTP method (e.g., "POST").
67
+ path: URL path (e.g., "/payments/charge").
68
+ body: Raw request body bytes.
69
+
70
+ Returns:
71
+ Hex-encoded SHA-256 hash string.
72
+ """
73
+ canonical_body = canonicalize_body(body)
74
+
75
+ # Build the hash input: "POST:/payments/charge:" + body_bytes
76
+ # The colons are delimiters to prevent ambiguity (e.g., method "POS"
77
+ # with path "T/foo" vs method "POST" with path "/foo").
78
+ hasher = hashlib.sha256()
79
+ hasher.update(f"{method}:{path}:".encode("utf-8"))
80
+ hasher.update(canonical_body)
81
+
82
+ return hasher.hexdigest()
ichido/middleware.py ADDED
@@ -0,0 +1,344 @@
1
+ """
2
+ Ichido ASGI middleware — the core idempotency logic.
3
+
4
+ This is a pure ASGI middleware (not BaseHTTPMiddleware) for two reasons:
5
+ 1. Performance — BaseHTTPMiddleware spawns an anyio task per request and has
6
+ known issues under load ("Unexpected message received" errors).
7
+ 2. Control — we need to read the request body, potentially short-circuit the
8
+ response, and capture the downstream response body. Pure ASGI gives us
9
+ direct access to the receive/send channels.
10
+
11
+ The middleware implements this state machine:
12
+
13
+ [No Key] ──SET NX──▶ [PROCESSING] ──handler done──▶ [COMPLETED]
14
+ │ │
15
+ 2nd request 2nd request
16
+ │ │
17
+ 409 Conflict Replay response
18
+
19
+ On handler failure: DELETE key (allow retry).
20
+ On fingerprint mismatch: 422 Unprocessable Entity.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import logging
27
+ from typing import Callable
28
+
29
+ from starlette.requests import Request
30
+ from starlette.responses import JSONResponse, Response
31
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
32
+
33
+ from ichido.config import IchidoConfig
34
+ from ichido.fingerprint import compute_fingerprint
35
+ from ichido.storage import RedisIdempotencyStore
36
+
37
+ logger = logging.getLogger("ichido")
38
+
39
+
40
+ class IchidoMiddleware:
41
+ """
42
+ ASGI middleware that enforces idempotency key semantics.
43
+
44
+ Usage:
45
+ from fastapi import FastAPI
46
+ from ichido import IchidoMiddleware, RedisIdempotencyStore
47
+ import redis.asyncio as aioredis
48
+
49
+ app = FastAPI()
50
+ redis_client = aioredis.from_url("redis://localhost:6379")
51
+ store = RedisIdempotencyStore(redis_client)
52
+ app.add_middleware(IchidoMiddleware, store=store)
53
+
54
+ Args:
55
+ app: The ASGI application to wrap.
56
+ store: A RedisIdempotencyStore instance for persistence.
57
+ config: Optional IchidoConfig for customization.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ app: ASGIApp,
63
+ store: RedisIdempotencyStore,
64
+ config: IchidoConfig | None = None,
65
+ ) -> None:
66
+ self.app = app
67
+ self.store = store
68
+ self.config = config or IchidoConfig()
69
+
70
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
71
+ """ASGI entry point — dispatch HTTP requests, pass through everything else."""
72
+ # Only process HTTP requests. WebSocket, lifespan, etc. pass through.
73
+ if scope["type"] != "http":
74
+ await self.app(scope, receive, send)
75
+ return
76
+
77
+ request = Request(scope)
78
+ method = request.method.upper()
79
+
80
+ # Only enforce on unsafe methods (POST, PATCH, PUT by default)
81
+ if method not in self.config.enforce_methods:
82
+ await self.app(scope, receive, send)
83
+ return
84
+
85
+ # Extract idempotency key from header
86
+ idempotency_key = request.headers.get(self.config.header_name)
87
+
88
+ # No key provided — pass through without idempotency enforcement.
89
+ # Some APIs require the key; we chose to make it optional because
90
+ # forcing it would break clients that don't know about idempotency.
91
+ # You could make this strict by returning 400 here instead.
92
+ if not idempotency_key:
93
+ await self.app(scope, receive, send)
94
+ return
95
+
96
+ # --- Read the full request body ---
97
+ # ASGI request bodies are streams. We consume the full body here
98
+ # so we can compute the fingerprint, then replay it to the handler.
99
+ body = await self._read_body(receive)
100
+
101
+ # Compute request fingerprint
102
+ fingerprint = compute_fingerprint(method, request.url.path, body)
103
+
104
+ # --- State machine: try to acquire the key ---
105
+ acquired = await self.store.try_acquire(idempotency_key, fingerprint)
106
+
107
+ if acquired:
108
+ # We own this key — process the request and cache the response
109
+ await self._process_and_cache(
110
+ scope, body, send, idempotency_key, fingerprint
111
+ )
112
+ else:
113
+ # Key already exists — check state
114
+ await self._handle_existing_key(
115
+ scope, send, idempotency_key, fingerprint
116
+ )
117
+
118
+ async def _read_body(self, receive: Receive) -> bytes:
119
+ """
120
+ Consume the full request body from the ASGI receive channel.
121
+
122
+ ASGI delivers the body in chunks via receive() calls. Each chunk
123
+ has a "body" field and a "more_body" flag. We accumulate all chunks.
124
+ """
125
+ chunks: list[bytes] = []
126
+ while True:
127
+ message = await receive()
128
+ chunks.append(message.get("body", b""))
129
+ if not message.get("more_body", False):
130
+ break
131
+ return b"".join(chunks)
132
+
133
+ async def _process_and_cache(
134
+ self,
135
+ scope: Scope,
136
+ body: bytes,
137
+ send: Send,
138
+ idempotency_key: str,
139
+ fingerprint: str,
140
+ ) -> None:
141
+ """
142
+ Forward the request to the handler, capture the response, cache it.
143
+
144
+ If the handler raises an exception, we DELETE the key from Redis
145
+ so the client can retry. This is critical — without it, the key
146
+ would be stuck in "processing" state until TTL expiry.
147
+ """
148
+ # Create a replay receive function — we consumed the original stream,
149
+ # so the downstream handler would get an empty body without this.
150
+ body_sent = False
151
+
152
+ async def replay_receive() -> Message:
153
+ nonlocal body_sent
154
+ if not body_sent:
155
+ body_sent = True
156
+ return {"type": "http.request", "body": body, "more_body": False}
157
+ # After the body has been sent, return a disconnect message
158
+ # if receive is called again (handles edge cases)
159
+ return {"type": "http.disconnect"}
160
+
161
+ # Capture the response as the handler sends it
162
+ response_started = False
163
+ response_status: int = 500
164
+ response_headers: list[tuple[bytes, bytes]] = []
165
+ response_body_parts: list[bytes] = []
166
+
167
+ async def capture_send(message: Message) -> None:
168
+ nonlocal response_started, response_status, response_headers
169
+
170
+ if message["type"] == "http.response.start":
171
+ response_started = True
172
+ response_status = message["status"]
173
+ response_headers = list(message.get("headers", []))
174
+
175
+ # Inject a header to indicate first-time processing
176
+ # (not a replay). This is useful for debugging.
177
+ response_headers.append(
178
+ (b"x-ichido-status", b"executed")
179
+ )
180
+ message = {
181
+ **message,
182
+ "headers": response_headers,
183
+ }
184
+
185
+ elif message["type"] == "http.response.body":
186
+ response_body_parts.append(message.get("body", b""))
187
+
188
+ # Forward to the actual client
189
+ await send(message)
190
+
191
+ try:
192
+ await self.app(scope, replay_receive, capture_send)
193
+ except Exception:
194
+ # Handler crashed — delete the key so the client can retry
195
+ logger.warning(
196
+ "Handler raised exception for idempotency key '%s'. "
197
+ "Deleting key to allow retry.",
198
+ idempotency_key,
199
+ )
200
+ await self.store.delete_key(idempotency_key)
201
+ raise # Re-raise so FastAPI's exception handlers can deal with it
202
+
203
+ # Cache the response only if handler succeeded (2xx or 4xx client errors).
204
+ # We cache all responses, not just 2xx, because the client might retry
205
+ # after a 400 Bad Request and should get the same 400 back (deterministic).
206
+ full_body = b"".join(response_body_parts)
207
+ headers_dict = {
208
+ k.decode("utf-8"): v.decode("utf-8") for k, v in response_headers
209
+ }
210
+
211
+ await self.store.mark_completed(
212
+ idempotency_key=idempotency_key,
213
+ fingerprint=fingerprint,
214
+ response_status=response_status,
215
+ response_headers=headers_dict,
216
+ response_body=base64.b64encode(full_body).decode("ascii"),
217
+ )
218
+
219
+ logger.info(
220
+ "Cached response for idempotency key '%s' (status=%d)",
221
+ idempotency_key,
222
+ response_status,
223
+ )
224
+
225
+ async def _handle_existing_key(
226
+ self,
227
+ scope: Scope,
228
+ send: Send,
229
+ idempotency_key: str,
230
+ fingerprint: str,
231
+ ) -> None:
232
+ """
233
+ Handle a request whose idempotency key already exists in the store.
234
+
235
+ Three possible outcomes:
236
+ 1. Key is "completed" + fingerprint matches → replay cached response.
237
+ 2. Key is "completed" + fingerprint differs → 422 (key reused with different request).
238
+ 3. Key is "processing" → 409 Conflict (concurrent duplicate).
239
+ """
240
+ record = await self.store.get_record(idempotency_key)
241
+
242
+ if record is None:
243
+ # Race condition: key existed when we tried SET NX, but was deleted
244
+ # (TTL expired or handler errored) before we could GET it.
245
+ # Return 409 and let the client retry — they'll get a fresh slot.
246
+ response = JSONResponse(
247
+ status_code=409,
248
+ content={
249
+ "error": "conflict",
250
+ "message": "Idempotency key state is inconsistent. Please retry.",
251
+ },
252
+ headers={"Retry-After": "1"},
253
+ )
254
+ await response(scope, _noop_receive, send)
255
+ return
256
+
257
+ # --- Check fingerprint first (applies to both processing and completed) ---
258
+ if record.fingerprint != fingerprint:
259
+ logger.warning(
260
+ "Fingerprint mismatch for idempotency key '%s'. "
261
+ "Key is being reused with a different request body.",
262
+ idempotency_key,
263
+ )
264
+ response = JSONResponse(
265
+ status_code=422,
266
+ content={
267
+ "error": "fingerprint_mismatch",
268
+ "message": (
269
+ f"Idempotency key '{idempotency_key}' was already used "
270
+ f"with different request parameters. Each key must be "
271
+ f"used with identical requests."
272
+ ),
273
+ },
274
+ )
275
+ await response(scope, _noop_receive, send)
276
+ return
277
+
278
+ # --- Key is still processing (concurrent duplicate) ---
279
+ if record.status == "processing":
280
+ logger.info(
281
+ "Concurrent duplicate detected for idempotency key '%s'. "
282
+ "Returning 409 Conflict.",
283
+ idempotency_key,
284
+ )
285
+ response = JSONResponse(
286
+ status_code=409,
287
+ content={
288
+ "error": "request_in_progress",
289
+ "message": (
290
+ f"A request with idempotency key '{idempotency_key}' "
291
+ f"is currently being processed. Please retry later."
292
+ ),
293
+ },
294
+ headers={"Retry-After": "1"},
295
+ )
296
+ await response(scope, _noop_receive, send)
297
+ return
298
+
299
+ # --- Key is completed — replay the cached response ---
300
+ if record.status == "completed":
301
+ logger.info(
302
+ "Replaying cached response for idempotency key '%s' (status=%d)",
303
+ idempotency_key,
304
+ record.response_status,
305
+ )
306
+
307
+ response_body = base64.b64decode(record.response_body)
308
+
309
+ # Rebuild the headers, adding our replay marker
310
+ headers: dict[str, str] = dict(record.response_headers or {})
311
+ headers["x-ichido-status"] = "replayed"
312
+
313
+ response = Response(
314
+ content=response_body,
315
+ status_code=record.response_status or 200,
316
+ headers=headers,
317
+ )
318
+ await response(scope, _noop_receive, send)
319
+ return
320
+
321
+ # Unknown status — should never happen, but be defensive
322
+ logger.error(
323
+ "Unknown status '%s' for idempotency key '%s'",
324
+ record.status,
325
+ idempotency_key,
326
+ )
327
+ response = JSONResponse(
328
+ status_code=500,
329
+ content={
330
+ "error": "internal_error",
331
+ "message": "Idempotency key record is in an unexpected state.",
332
+ },
333
+ )
334
+ await response(scope, _noop_receive, send)
335
+
336
+
337
+ async def _noop_receive() -> Message:
338
+ """
339
+ A no-op receive function for sending responses without a real receive channel.
340
+
341
+ Used when we're short-circuiting the request (replaying cached response
342
+ or returning an error) and don't need to read any request body.
343
+ """
344
+ return {"type": "http.disconnect"}
ichido/storage.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ Redis-backed storage for idempotency records.
3
+
4
+ This module abstracts all Redis interactions behind a clean interface.
5
+ The key design principle: every state transition must be atomic.
6
+
7
+ Why an abstraction layer instead of calling Redis directly in the middleware?
8
+ 1. Testability — we can swap in fakeredis for unit tests.
9
+ 2. Future-proofing — adding a Postgres backend later just means implementing
10
+ the same interface.
11
+ 3. Separation of concerns — the middleware handles HTTP logic, the store
12
+ handles persistence logic.
13
+
14
+ Redis key lifecycle:
15
+ SET NX EX → key created with "processing" state and TTL
16
+ SET EX → key updated to "completed" with response data (TTL refreshed)
17
+ DEL → key removed on handler failure (allows retry)
18
+ [TTL expires] → key auto-cleaned by Redis
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ import redis.asyncio as aioredis
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class IdempotencyRecord:
32
+ """
33
+ An idempotency record stored in Redis.
34
+
35
+ Attributes:
36
+ status: Either "processing" (handler in-flight) or "completed" (response cached).
37
+ fingerprint: SHA-256 hash of the original request (method + path + body).
38
+ response_status: HTTP status code of the cached response (None while processing).
39
+ response_headers: HTTP headers of the cached response (None while processing).
40
+ response_body: Raw response body bytes, base64-encoded in Redis (None while processing).
41
+ """
42
+
43
+ status: str
44
+ fingerprint: str
45
+ response_status: int | None = None
46
+ response_headers: dict[str, str] | None = None
47
+ response_body: str | None = None # base64-encoded
48
+
49
+ def to_json(self) -> str:
50
+ """Serialize to JSON for Redis storage."""
51
+ return json.dumps(
52
+ {
53
+ "status": self.status,
54
+ "fingerprint": self.fingerprint,
55
+ "response_status": self.response_status,
56
+ "response_headers": self.response_headers,
57
+ "response_body": self.response_body,
58
+ }
59
+ )
60
+
61
+ @classmethod
62
+ def from_json(cls, raw: str) -> IdempotencyRecord:
63
+ """Deserialize from Redis JSON string."""
64
+ data = json.loads(raw)
65
+ return cls(
66
+ status=data["status"],
67
+ fingerprint=data["fingerprint"],
68
+ response_status=data.get("response_status"),
69
+ response_headers=data.get("response_headers"),
70
+ response_body=data.get("response_body"),
71
+ )
72
+
73
+
74
+ class RedisIdempotencyStore:
75
+ """
76
+ Redis-backed store for idempotency records.
77
+
78
+ Thread-safety: redis-py's async client is safe for concurrent use
79
+ from multiple asyncio tasks — each command is an independent round-trip.
80
+
81
+ Args:
82
+ redis_client: An async Redis client instance.
83
+ prefix: Key prefix for namespacing (default: "ichido:").
84
+ ttl: Time-to-live in seconds for idempotency records (default: 24h).
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ redis_client: aioredis.Redis,
90
+ prefix: str = "ichido:",
91
+ ttl: int = 86400,
92
+ ) -> None:
93
+ self.redis = redis_client
94
+ self.prefix = prefix
95
+ self.ttl = ttl
96
+
97
+ def _make_key(self, idempotency_key: str) -> str:
98
+ """Build the full Redis key with prefix."""
99
+ return f"{self.prefix}{idempotency_key}"
100
+
101
+ async def try_acquire(self, idempotency_key: str, fingerprint: str) -> bool:
102
+ """
103
+ Atomically try to claim an idempotency key.
104
+
105
+ Uses Redis SET with NX (only if not exists) and EX (with TTL) in a
106
+ single atomic command. This is the core concurrency primitive:
107
+
108
+ - If the key doesn't exist: creates it with status="processing" → returns True.
109
+ - If the key already exists: does nothing → returns False.
110
+
111
+ No race condition is possible between two concurrent calls because
112
+ SET NX is atomic at the Redis server level.
113
+
114
+ Args:
115
+ idempotency_key: The client-provided idempotency key.
116
+ fingerprint: SHA-256 fingerprint of the request.
117
+
118
+ Returns:
119
+ True if the key was newly created (caller should process the request).
120
+ False if the key already existed (caller should check state and act).
121
+ """
122
+ record = IdempotencyRecord(status="processing", fingerprint=fingerprint)
123
+
124
+ result = await self.redis.set(
125
+ name=self._make_key(idempotency_key),
126
+ value=record.to_json(),
127
+ nx=True, # Only set if Not eXists
128
+ ex=self.ttl, # Auto-expire after TTL
129
+ )
130
+
131
+ # redis-py returns True if SET succeeded, None if key already existed
132
+ return result is not None
133
+
134
+ async def get_record(self, idempotency_key: str) -> IdempotencyRecord | None:
135
+ """
136
+ Retrieve the idempotency record for a key.
137
+
138
+ Args:
139
+ idempotency_key: The client-provided idempotency key.
140
+
141
+ Returns:
142
+ The IdempotencyRecord if found, None if the key doesn't exist
143
+ (or has expired).
144
+ """
145
+ raw = await self.redis.get(self._make_key(idempotency_key))
146
+ if raw is None:
147
+ return None
148
+ return IdempotencyRecord.from_json(raw)
149
+
150
+ async def mark_completed(
151
+ self,
152
+ idempotency_key: str,
153
+ fingerprint: str,
154
+ response_status: int,
155
+ response_headers: dict[str, str],
156
+ response_body: str,
157
+ ) -> None:
158
+ """
159
+ Update a key's record to "completed" with the cached response.
160
+
161
+ This overwrites the "processing" record. We use SET with EX (not NX)
162
+ to refresh the TTL — the 24-hour window starts from when the response
163
+ was stored, not when the request was received.
164
+
165
+ Args:
166
+ idempotency_key: The client-provided idempotency key.
167
+ fingerprint: The original request fingerprint (preserved).
168
+ response_status: HTTP status code to cache.
169
+ response_headers: HTTP headers to cache.
170
+ response_body: Response body to cache (base64-encoded).
171
+ """
172
+ record = IdempotencyRecord(
173
+ status="completed",
174
+ fingerprint=fingerprint,
175
+ response_status=response_status,
176
+ response_headers=response_headers,
177
+ response_body=response_body,
178
+ )
179
+
180
+ await self.redis.set(
181
+ name=self._make_key(idempotency_key),
182
+ value=record.to_json(),
183
+ ex=self.ttl, # Refresh TTL
184
+ )
185
+
186
+ async def delete_key(self, idempotency_key: str) -> None:
187
+ """
188
+ Remove an idempotency key from the store.
189
+
190
+ Called when the handler raises an exception — we delete the key so
191
+ the client can safely retry. If we didn't delete it, the key would
192
+ be stuck in "processing" state until TTL expiry, blocking retries.
193
+
194
+ Args:
195
+ idempotency_key: The client-provided idempotency key.
196
+ """
197
+ await self.redis.delete(self._make_key(idempotency_key))
@@ -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.
@@ -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
+ [![Python Version](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://www.python.org/)
34
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-009688.svg)](https://fastapi.tiangolo.com)
35
+ [![Redis](https://img.shields.io/badge/Redis-5.0%2B-red.svg)](https://redis.io)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
@@ -0,0 +1,11 @@
1
+ ichido/__init__.py,sha256=Xl1hRwgGpH4HM9g6sR4FsLsujwSUV5SanM114Rhik04,611
2
+ ichido/config.py,sha256=W33_H1P5CnCIYpGm3zxm0wfqzv6syiCDzuowE9lnFrs,1266
3
+ ichido/exceptions.py,sha256=fLckw6WwjoqaVipGwuVZ9khyi2Nsxb2tB6oeqeZyVuo,1635
4
+ ichido/fingerprint.py,sha256=UCQYaUjzCweBs8YGYeAUaUQNna-N3Ligkd8XK2ednL4,2978
5
+ ichido/middleware.py,sha256=fPtdMN61yloEse2zv6eTFw17m2kljwieYWqdMiwBDqY,13047
6
+ ichido/storage.py,sha256=njOfZrC5y7h2jQI9p2NGmhbE_8BEwDXJAW6l_IUh6aY,7026
7
+ ichido-0.1.0.dist-info/LICENSE,sha256=FZJj4Jg7ad6E9QqB4H5n1Wy2dvtddwh2Bg5ja08DUFg,1062
8
+ ichido-0.1.0.dist-info/METADATA,sha256=OT0P3xZKwEJAnh6ZpjC4YiwZ766STvgueBP2mQ3t8FE,15297
9
+ ichido-0.1.0.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
10
+ ichido-0.1.0.dist-info/top_level.txt,sha256=hC5WMlHxYFpWm6QVz2jQ3QFxDF4H2p8bRTJZlxGraIE,7
11
+ ichido-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ ichido