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 +25 -0
- ichido/config.py +33 -0
- ichido/exceptions.py +50 -0
- ichido/fingerprint.py +82 -0
- ichido/middleware.py +344 -0
- ichido/storage.py +197 -0
- ichido-0.1.0.dist-info/LICENSE +21 -0
- ichido-0.1.0.dist-info/METADATA +317 -0
- ichido-0.1.0.dist-info/RECORD +11 -0
- ichido-0.1.0.dist-info/WHEEL +5 -0
- ichido-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](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.
|
|
@@ -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 @@
|
|
|
1
|
+
ichido
|