idemkit 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.
Files changed (48) hide show
  1. idemkit/__init__.py +101 -0
  2. idemkit/_version.py +1 -0
  3. idemkit/adapters/__init__.py +10 -0
  4. idemkit/adapters/ai.py +723 -0
  5. idemkit/adapters/asgi.py +533 -0
  6. idemkit/adapters/queue.py +413 -0
  7. idemkit/adapters/route.py +273 -0
  8. idemkit/adapters/wsgi.py +393 -0
  9. idemkit/backends/__init__.py +30 -0
  10. idemkit/backends/base.py +116 -0
  11. idemkit/backends/dynamodb.py +489 -0
  12. idemkit/backends/memory.py +325 -0
  13. idemkit/backends/mongo.py +395 -0
  14. idemkit/backends/postgres.py +682 -0
  15. idemkit/backends/redis.py +547 -0
  16. idemkit/cli.py +165 -0
  17. idemkit/conformance/__init__.py +25 -0
  18. idemkit/conformance/backend.py +257 -0
  19. idemkit/conformance/report.py +54 -0
  20. idemkit/contrib/__init__.py +30 -0
  21. idemkit/contrib/drf.py +181 -0
  22. idemkit/contrib/fastapi.py +141 -0
  23. idemkit/contrib/kafka.py +96 -0
  24. idemkit/contrib/logging.py +61 -0
  25. idemkit/contrib/mcp.py +113 -0
  26. idemkit/contrib/prometheus.py +79 -0
  27. idemkit/contrib/pubsub.py +98 -0
  28. idemkit/contrib/rabbitmq.py +138 -0
  29. idemkit/contrib/reconciliation.py +86 -0
  30. idemkit/contrib/sqs.py +117 -0
  31. idemkit/core/__init__.py +36 -0
  32. idemkit/core/codecs.py +229 -0
  33. idemkit/core/config.py +255 -0
  34. idemkit/core/engine.py +331 -0
  35. idemkit/core/events.py +63 -0
  36. idemkit/core/exception_cache.py +88 -0
  37. idemkit/core/exceptions.py +79 -0
  38. idemkit/core/fingerprint.py +153 -0
  39. idemkit/core/policy.py +143 -0
  40. idemkit/core/runner.py +664 -0
  41. idemkit/core/state.py +95 -0
  42. idemkit/core/sync_bridge.py +115 -0
  43. idemkit/problem_details.py +119 -0
  44. idemkit/py.typed +0 -0
  45. idemkit-0.1.0.dist-info/METADATA +446 -0
  46. idemkit-0.1.0.dist-info/RECORD +48 -0
  47. idemkit-0.1.0.dist-info/WHEEL +4 -0
  48. idemkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,533 @@
1
+ """ASGI middleware for idemkit, compatible with FastAPI / Starlette / any ASGI 3 app.
2
+
3
+ Spec coverage: §4.7 (key + scope extraction), §4.4 (release on
4
+ exception / disconnect), §4.9 (response size enforcement by streamed bytes),
5
+ §4.11 (encoding verbatim replay), §6 (wire format).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from idemkit import problem_details
15
+ from idemkit.backends.base import IdempotencyBackend
16
+ from idemkit.core.config import resolve_http_config
17
+ from idemkit.core.engine import IdempotencyEngine, NeutralRequest, NeutralResponse
18
+ from idemkit.core.policy import HttpConfig
19
+
20
+ _logger = logging.getLogger(__name__)
21
+
22
+ _inmemory_warned = False
23
+
24
+
25
+ def _warn_inmemory_backend() -> None:
26
+ """Warn once per process that InMemoryBackend is not production-safe (§7.4)."""
27
+ global _inmemory_warned
28
+ if _inmemory_warned:
29
+ return
30
+ _inmemory_warned = True
31
+ _logger.warning(
32
+ "idemkit: InMemoryBackend is for development and tests only. Its state "
33
+ "lives in one process, so idempotency is NOT shared across multiple "
34
+ "workers/instances (e.g. `uvicorn --workers N`, gunicorn, k8s replicas) "
35
+ "and duplicate requests can slip through. Use RedisBackend or "
36
+ "PostgresBackend in production."
37
+ )
38
+
39
+
40
+ # ASGI types — kept generic to avoid framework deps in core.
41
+ ASGIScope = dict[str, Any]
42
+ ASGIMessage = dict[str, Any]
43
+ ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
44
+ ASGISend = Callable[[ASGIMessage], Awaitable[None]]
45
+ ASGIApp = Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]]
46
+
47
+
48
+ class IdempotencyMiddleware:
49
+ """ASGI middleware that applies idempotency per the configured backend.
50
+
51
+ Usage::
52
+
53
+ from idemkit import IdempotencyMiddleware, HttpConfig, InMemoryBackend
54
+
55
+ app.add_middleware(
56
+ IdempotencyMiddleware,
57
+ backend=InMemoryBackend(),
58
+ config=HttpConfig(scope=lambda req: req.state.user.id),
59
+ )
60
+
61
+ On the ASGI ``lifespan`` shutdown the middleware closes the backend it was
62
+ given (pool + background listener), so the one-line install does not leak.
63
+ Pass ``manage_backend=False`` if you share the backend elsewhere and close it
64
+ yourself.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ app: ASGIApp,
70
+ *,
71
+ backend: IdempotencyBackend,
72
+ config: HttpConfig | None = None,
73
+ manage_backend: bool = True,
74
+ ) -> None:
75
+ resolved = resolve_http_config(config)
76
+ self.app = app
77
+ self.config = resolved
78
+ self._backend = backend
79
+ self._manage_backend = manage_backend
80
+ self.engine = IdempotencyEngine(backend=backend, config=resolved)
81
+ if type(backend).__name__ == "InMemoryBackend":
82
+ _warn_inmemory_backend()
83
+
84
+ async def __call__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
85
+ if scope.get("type") == "lifespan" and self._manage_backend:
86
+ # Run the app's own lifespan, then close the backend on shutdown so the
87
+ # pool + subscriber task don't leak. aclose is optional on the backend
88
+ # Protocol, so call it only if present (custom backends may omit it).
89
+ try:
90
+ await self.app(scope, receive, send)
91
+ finally:
92
+ aclose = getattr(self._backend, "aclose", None)
93
+ if aclose is not None:
94
+ try:
95
+ await aclose()
96
+ except Exception:
97
+ _logger.exception("idemkit: error closing backend on shutdown")
98
+ return
99
+
100
+ if scope.get("type") != "http":
101
+ await self.app(scope, receive, send)
102
+ return
103
+
104
+ # Read and buffer the request body so both fingerprinting and the
105
+ # downstream handler can see it. Per spec §4.5, the body is decoded
106
+ # (Content-Encoding handled by the server upstream of ASGI). The buffer
107
+ # is bounded by ``max_request_body_bytes`` to prevent a large-request
108
+ # memory DoS; an oversized request is streamed through WITHOUT
109
+ # idempotency rather than buffered whole.
110
+ max_request = self.config.max_request_body_bytes
111
+ body_chunks: list[bytes] = []
112
+ body_size = 0
113
+ client_disconnected = False
114
+ oversized_request = False
115
+ trailing_more = False
116
+ more = True
117
+ while more:
118
+ message = await receive()
119
+ mtype = message["type"]
120
+ if mtype == "http.request":
121
+ chunk = message.get("body", b"") or b""
122
+ body_chunks.append(chunk)
123
+ body_size += len(chunk)
124
+ more = message.get("more_body", False)
125
+ if max_request is not None and body_size > max_request:
126
+ oversized_request = True
127
+ trailing_more = more
128
+ break
129
+ elif mtype == "http.disconnect":
130
+ client_disconnected = True
131
+ break
132
+ else:
133
+ # Unknown message; bail out of buffering and let the
134
+ # downstream app see it via a passthrough receive.
135
+ body_chunks.append(b"")
136
+ more = False
137
+ if client_disconnected:
138
+ # Client gave up before the body finished. Nothing to do.
139
+ return
140
+
141
+ method = (scope.get("method") or "GET").upper()
142
+ path = scope.get("path") or "/"
143
+ query_bytes: bytes = scope.get("query_string", b"") or b""
144
+ try:
145
+ query = query_bytes.decode("latin1")
146
+ except Exception:
147
+ query = ""
148
+
149
+ headers = _read_headers(scope.get("headers") or [])
150
+
151
+ if oversized_request:
152
+ # Too large to buffer for fingerprinting, so idempotency is bypassed.
153
+ # The require_key_for_mutations gate is a separate safety control and
154
+ # MUST still apply: don't let an oversized body smuggle a mutation
155
+ # past it.
156
+ prefix = b"".join(body_chunks)
157
+ key = self._extract_idempotency_key(scope, headers, prefix)
158
+ if (
159
+ self.config.require_key_for_mutations
160
+ and method in self.config.applicable_methods
161
+ and not key
162
+ ):
163
+ status, p_headers, p_body = problem_details.missing_key()
164
+ await _send_neutral_response(
165
+ send,
166
+ NeutralResponse(status=status, headers=p_headers, body=p_body),
167
+ )
168
+ return
169
+ _logger.warning(
170
+ "idemkit: request body exceeded max_request_body_bytes=%s; "
171
+ "bypassing idempotency and streaming the request through.",
172
+ max_request,
173
+ )
174
+ chained = _build_chained_receive(body_chunks, trailing_more, receive)
175
+ await self.app(scope, chained, send)
176
+ return
177
+
178
+ full_body = b"".join(body_chunks)
179
+
180
+ idempotency_key = self._extract_idempotency_key(scope, headers, full_body)
181
+ caller_scope = self._extract_caller_identity(scope)
182
+
183
+ request = NeutralRequest(
184
+ idempotency_key=idempotency_key,
185
+ scope=caller_scope,
186
+ method=method,
187
+ path=path,
188
+ query=query,
189
+ body=full_body,
190
+ content_type=headers.get("content-type"),
191
+ )
192
+
193
+ outcome = await self.engine.decide(request)
194
+
195
+ if outcome.kind == "replay":
196
+ assert outcome.response is not None
197
+ await _send_neutral_response(send, outcome.response)
198
+ return
199
+
200
+ if outcome.kind == "reject":
201
+ assert outcome.response is not None
202
+ await _send_neutral_response(send, outcome.response)
203
+ return
204
+
205
+ # pass_through — replay the buffered body to the downstream app and
206
+ # capture its response so we can call engine.on_complete / on_error.
207
+ receive_replay = _build_receive_replay(full_body)
208
+
209
+ if outcome.effective_key is None or outcome.claim_token is None:
210
+ # Idempotency does not apply to this request (e.g., method not in
211
+ # applicable_methods or key absent and not required). Just forward.
212
+ await self.app(scope, receive_replay, send)
213
+ return
214
+
215
+ capturer = _ResponseCapturer(
216
+ inner_send=send,
217
+ max_body_bytes=self.config.max_body_bytes,
218
+ )
219
+
220
+ try:
221
+ await self.app(scope, receive_replay, capturer.send)
222
+ except Exception:
223
+ await self.engine.on_error(outcome.effective_key, outcome.claim_token)
224
+ raise
225
+
226
+ if not capturer.completed:
227
+ # Handler did not produce a complete response (e.g., disconnect).
228
+ await self.engine.on_error(outcome.effective_key, outcome.claim_token)
229
+ return
230
+
231
+ if capturer.oversize:
232
+ # Spec §4.9: response exceeded max_body_bytes. Don't cache it —
233
+ # release the claim so a retry can re-execute. Client already
234
+ # received the full response from the streaming pass-through.
235
+ await self.engine.on_error(outcome.effective_key, outcome.claim_token)
236
+ return
237
+
238
+ captured = capturer.to_neutral()
239
+ await self.engine.on_complete(outcome.effective_key, outcome.claim_token, captured)
240
+
241
+ # ----- extraction hooks -----
242
+
243
+ def _extract_idempotency_key(
244
+ self,
245
+ scope: ASGIScope,
246
+ headers: dict[str, str],
247
+ body: bytes,
248
+ ) -> str | None:
249
+ if self.config.key is not None:
250
+ # Custom extractor sees a minimal request-like object via scope.
251
+ request_proxy = _RequestProxy(scope, headers, body)
252
+ try:
253
+ return self.config.key(request_proxy)
254
+ except Exception:
255
+ _logger.exception("idemkit: key raised; treating as no key")
256
+ return None
257
+ return _unwrap_sf_string(headers.get("idempotency-key"))
258
+
259
+ def _extract_caller_identity(self, scope: ASGIScope) -> str | None:
260
+ if self.config.scope is None:
261
+ return None # only reachable in single-tenant mode
262
+ request_proxy = _RequestProxy(scope, _read_headers(scope.get("headers") or []), b"")
263
+ try:
264
+ value = self.config.scope(request_proxy)
265
+ except Exception:
266
+ # The extractor raised — commonly because it reached for something the
267
+ # middleware proxy doesn't have (e.g. `req.state` / `req.client`; the
268
+ # proxy exposes `req.headers` / `req.scope` / `req.body` only). We do
269
+ # NOT bucket the request as anonymous: a configured-but-failing
270
+ # extractor fails closed (the engine returns 500, §4.6), because
271
+ # silently sharing a namespace is the cross-tenant bug we prevent.
272
+ _logger.exception(
273
+ "idemkit: scope extractor raised; this request will be "
274
+ "refused (500). The middleware passes extractors a lightweight "
275
+ "proxy with req.headers / req.scope / req.body — not a Starlette "
276
+ "Request, so req.state / req.client are unavailable here. Read "
277
+ "identity from a header or req.scope, or use the @Idempotency "
278
+ "decorator (which gets the real Request)."
279
+ )
280
+ return None
281
+ return value or None
282
+
283
+
284
+ class _CaseInsensitiveHeaders(dict[str, str]):
285
+ """A header dict with case-insensitive lookups.
286
+
287
+ HTTP header names are case-insensitive, and extractors written against a
288
+ Starlette ``Request`` expect ``req.headers.get("X-User-Id")`` to work
289
+ regardless of case. Keys are stored lowercased; lookups lowercase the query.
290
+ """
291
+
292
+ def __getitem__(self, key: str) -> str:
293
+ return super().__getitem__(key.lower())
294
+
295
+ def get(self, key: str, default: str | None = None) -> str | None: # type: ignore[override]
296
+ return super().get(key.lower(), default)
297
+
298
+ def __contains__(self, key: object) -> bool:
299
+ return super().__contains__(key.lower() if isinstance(key, str) else key)
300
+
301
+
302
+ class _RequestProxy:
303
+ """Minimal request-like object exposed to user-defined extractors.
304
+
305
+ Extractors receive this for raw ASGI. It exposes:
306
+
307
+ * ``headers`` — case-insensitive ``dict``-like of request headers;
308
+ * ``scope`` — the raw ASGI scope (e.g. ``scope["client"]`` for the peer
309
+ address, ``scope["state"]`` for data set by upstream middleware);
310
+ * ``body`` — the raw request body bytes (passed to ``key`` only).
311
+ """
312
+
313
+ __slots__ = ("body", "headers", "scope")
314
+
315
+ def __init__(self, scope: ASGIScope, headers: dict[str, str], body: bytes) -> None:
316
+ self.scope = scope
317
+ self.headers = headers
318
+ self.body = body
319
+
320
+
321
+ def _unwrap_sf_string(value: str | None) -> str | None:
322
+ """Accept both quoted and unquoted Idempotency-Key forms (§6.1).
323
+
324
+ The IETF draft specifies an RFC 8941 Structured Field String (quoted), but
325
+ most clients send the value bare. Strip the surrounding quotes (and unescape
326
+ ``\\"`` / ``\\\\``) so ``"abc"`` and ``abc`` map to the same key.
327
+ """
328
+ if value is None:
329
+ return None
330
+ v = value.strip()
331
+ if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
332
+ out: list[str] = []
333
+ escaped = False
334
+ for ch in v[1:-1]:
335
+ if escaped:
336
+ out.append(ch)
337
+ escaped = False
338
+ elif ch == "\\":
339
+ escaped = True
340
+ else:
341
+ out.append(ch)
342
+ return "".join(out)
343
+ return value
344
+
345
+
346
+ def _read_headers(raw: list[tuple[bytes, bytes]]) -> dict[str, str]:
347
+ """ASGI headers list → case-insensitive lowercase dict (last wins)."""
348
+ out: _CaseInsensitiveHeaders = _CaseInsensitiveHeaders()
349
+ for name, value in raw:
350
+ try:
351
+ n = name.decode("latin1").lower()
352
+ v = value.decode("latin1")
353
+ except Exception:
354
+ continue
355
+ out[n] = v
356
+ return out
357
+
358
+
359
+ def _build_receive_replay(body: bytes) -> ASGIReceive:
360
+ """Return a receive callable that replays the buffered body once."""
361
+ state = {"sent": False}
362
+
363
+ async def receive() -> ASGIMessage:
364
+ if not state["sent"]:
365
+ state["sent"] = True
366
+ return {"type": "http.request", "body": body, "more_body": False}
367
+ # After body is delivered, the downstream app should not call receive
368
+ # again, but if it does (some frameworks), report disconnect.
369
+ return {"type": "http.disconnect"}
370
+
371
+ return receive
372
+
373
+
374
+ def _build_chained_receive(
375
+ buffered: list[bytes], more_remaining: bool, original: ASGIReceive
376
+ ) -> ASGIReceive:
377
+ """Receive callable for the oversized-request bypass path.
378
+
379
+ Replays the buffered prefix as one ``http.request`` message, then (if the
380
+ body wasn't fully read) delegates to the original ``receive`` for the
381
+ remaining messages. This bounds idemkit's memory to the cap while letting
382
+ the handler consume the full request.
383
+ """
384
+ state = {"sent": False}
385
+
386
+ async def receive() -> ASGIMessage:
387
+ if not state["sent"]:
388
+ state["sent"] = True
389
+ return {
390
+ "type": "http.request",
391
+ "body": b"".join(buffered),
392
+ "more_body": more_remaining,
393
+ }
394
+ if more_remaining:
395
+ return await original()
396
+ return {"type": "http.disconnect"}
397
+
398
+ return receive
399
+
400
+
401
+ async def _send_neutral_response(send: ASGISend, response: NeutralResponse) -> None:
402
+ headers_list = [
403
+ (name.encode("latin1"), value.encode("latin1")) for name, value in response.headers.items()
404
+ ]
405
+ await send(
406
+ {
407
+ "type": "http.response.start",
408
+ "status": response.status,
409
+ "headers": headers_list,
410
+ }
411
+ )
412
+ await send(
413
+ {
414
+ "type": "http.response.body",
415
+ "body": response.body,
416
+ "more_body": False,
417
+ }
418
+ )
419
+
420
+
421
+ class _ResponseCapturer:
422
+ """Wraps the outer ASGI ``send`` to capture status, headers, and body.
423
+
424
+ Caching decision is made at the first body message, by its ``more_body`` flag:
425
+
426
+ * **Single-shot** (``more_body=False``): the whole body is in this one
427
+ message. If it fits in ``max_body_bytes`` it is cached and sent verbatim;
428
+ if not, the start message is sent with an added
429
+ ``Idempotency-Replay-Unavailable: size-exceeded`` header and the body is
430
+ passed through uncached (spec §4.9 / §6.2). Enforcement counts the actual
431
+ bytes in hand, never the ``Content-Length`` header.
432
+ * **Incremental / streaming** (``more_body=True``): buffering would delay
433
+ delivery to the client, so the response is NOT buffered. The start message
434
+ goes out with ``Idempotency-Replay-Unavailable: streaming`` and every chunk
435
+ streams straight through, uncached (spec §4.9 streaming bypass).
436
+
437
+ The start message is otherwise forwarded VERBATIM, preserving duplicate
438
+ headers like multiple ``Set-Cookie`` on the path to the first client. A
439
+ separate dict view of the headers is built for storage (where duplicates do
440
+ not survive anyway because the allow/deny filter drops Set-Cookie etc.).
441
+ """
442
+
443
+ def __init__(self, inner_send: ASGISend, max_body_bytes: int) -> None:
444
+ self._send = inner_send
445
+ self._max = max_body_bytes
446
+ self.status: int = 0
447
+ self.headers: dict[str, str] = {}
448
+ self._start_message: ASGIMessage | None = None
449
+ self._start_sent: bool = False
450
+ self._first_body_seen: bool = False
451
+ self._body_chunks: list[bytes] = []
452
+ self.oversize: bool = False
453
+ self.completed: bool = False
454
+
455
+ @property
456
+ def body(self) -> bytes:
457
+ if self.oversize:
458
+ return b""
459
+ return b"".join(self._body_chunks)
460
+
461
+ def to_neutral(self) -> NeutralResponse:
462
+ return NeutralResponse(
463
+ status=self.status,
464
+ headers=dict(self.headers),
465
+ body=self.body,
466
+ )
467
+
468
+ async def _flush_start(self, *, bypass_reason: str | None = None) -> None:
469
+ if self._start_sent or self._start_message is None:
470
+ return
471
+ message = self._start_message
472
+ if bypass_reason is not None:
473
+ headers = list(message.get("headers", []))
474
+ headers.append((b"idempotency-replay-unavailable", bypass_reason.encode("latin1")))
475
+ message = {**message, "headers": headers}
476
+ self._start_sent = True
477
+ await self._send(message)
478
+
479
+ async def send(self, message: ASGIMessage) -> None:
480
+ mtype = message["type"]
481
+ if mtype == "http.response.start":
482
+ self.status = int(message["status"])
483
+ # Build a dict view for storage (duplicates not significant here
484
+ # because Set-Cookie / WWW-Authenticate are denied by §4.10).
485
+ self.headers = {
486
+ k.decode("latin1").lower(): v.decode("latin1")
487
+ for k, v in message.get("headers", [])
488
+ }
489
+ # Hold the start message just long enough to learn, at the first body
490
+ # message, whether the response is cacheable (the bypass header, if
491
+ # any, has to precede the body).
492
+ self._start_message = message
493
+ return
494
+ if mtype != "http.response.body":
495
+ await self._send(message)
496
+ return
497
+
498
+ chunk = message.get("body", b"") or b""
499
+ more = message.get("more_body", False)
500
+
501
+ if self.oversize:
502
+ # Already streaming through (size-exceeded or streaming bypass).
503
+ await self._send(message)
504
+ if not more:
505
+ self.completed = True
506
+ return
507
+
508
+ if not self._first_body_seen:
509
+ self._first_body_seen = True
510
+ if more:
511
+ # Incremental response: don't buffer (it would stall delivery).
512
+ self.oversize = True
513
+ await self._flush_start(bypass_reason="streaming")
514
+ await self._send(message)
515
+ return
516
+ # Single-shot: the whole body is this chunk. Enforce the cap on the
517
+ # bytes actually in hand (not Content-Length).
518
+ if len(chunk) > self._max:
519
+ self.oversize = True
520
+ await self._flush_start(bypass_reason="size-exceeded")
521
+ await self._send(message)
522
+ self.completed = True
523
+ return
524
+ self._body_chunks.append(chunk)
525
+ await self._flush_start()
526
+ await self._send(message)
527
+ self.completed = True
528
+ return
529
+
530
+ # Defensive: a single-shot response shouldn't emit further body messages.
531
+ await self._send(message)
532
+ if not more:
533
+ self.completed = True