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
idemkit/core/state.py ADDED
@@ -0,0 +1,95 @@
1
+ """Core state types for idemkit, mapping directly to spec §4.1 and §4.15.
2
+
3
+ These types are the contract any backend implementation MUST honor. The
4
+ spec lives at ``spec/README.md``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ class State(str, enum.Enum):
14
+ """Record state, spec §4.1."""
15
+
16
+ CLAIMED = "CLAIMED"
17
+ COMPLETED = "COMPLETED"
18
+
19
+
20
+ class Decision(str, enum.Enum):
21
+ """Engine outcome for one request, emitted as ``idempotency.<value>``.
22
+
23
+ Mirrors the observability event types in spec §4.15.
24
+ """
25
+
26
+ NEW = "new"
27
+ REPLAYED = "replayed"
28
+ IN_FLIGHT_WAIT = "in_flight_wait"
29
+ CONFLICT = "conflict"
30
+ PAYLOAD_MISMATCH = "payload_mismatch"
31
+ LEASE_RECLAIMED = "lease_reclaimed"
32
+ LEASE_RECLAIMED_LOSS = "lease_reclaimed_loss"
33
+ STORAGE_ERROR = "storage_error"
34
+ #: fail_open only: storage was down, so this operation ran WITHOUT idempotency
35
+ #: protection. Alert on this rate to size the blast radius of a fail_open outage.
36
+ RAN_UNPROTECTED = "ran_unprotected"
37
+ #: The lease lapsed mid-run (renewal could not be confirmed) and the handler was
38
+ #: cancelled. A rising rate means handlers outlive their lease (partition hazard).
39
+ LEASE_LOST = "lease_lost"
40
+ #: The side effect ran but the backend failed to record the result, so it was not
41
+ #: cached. A later retry re-runs it (effectively-once degrades to at-least-once).
42
+ COMPLETE_FAILED = "complete_failed"
43
+ CORRUPT_RECORD = "corrupt_record"
44
+
45
+
46
+ class ClaimResultType(str, enum.Enum):
47
+ """Outcome of one ``claim`` attempt against a backend, spec §4.1."""
48
+
49
+ NEW_CLAIMED = "new_claimed"
50
+ ALREADY_CLAIMED = "already_claimed"
51
+ ALREADY_COMPLETED = "already_completed"
52
+ LEASE_RECLAIMED = "lease_reclaimed"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class StoredRecord:
57
+ """A single backend record per spec §4.1.
58
+
59
+ A two-key (claim + result) layout is forbidden; this single record is the
60
+ only object stored per effective_key.
61
+
62
+ ``lease_until`` is set at claim time. ``completed_at`` and the
63
+ ``response_*`` fields are set on the conditional ``COMPLETED`` transition.
64
+ """
65
+
66
+ effective_key: str
67
+ state: State
68
+ fingerprint: str
69
+ fingerprint_version: int
70
+ claim_token: str
71
+ claimed_at: float # monotonic seconds (in-memory) or wall seconds (distributed)
72
+ lease_until: float
73
+ completed_at: float | None = None
74
+ response_status: int | None = None
75
+ response_headers: dict[str, str] = field(default_factory=dict)
76
+ response_body: bytes = b""
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ClaimResult:
81
+ """What ``IdempotencyBackend.claim`` returns.
82
+
83
+ ``our_claim_token`` is non-None only when this caller acquired the claim
84
+ (NEW_CLAIMED or LEASE_RECLAIMED). The token MUST be passed to subsequent
85
+ ``complete`` / ``release`` calls so the backend can verify ownership.
86
+
87
+ ``recovered_from_corrupt`` is set when the fresh claim was issued because
88
+ an existing stored record failed to deserialize (§4.12). The engine emits
89
+ ``idempotency.corrupt_record`` instead of ``idempotency.new`` in that case.
90
+ """
91
+
92
+ result: ClaimResultType
93
+ record: StoredRecord
94
+ our_claim_token: str | None = None
95
+ recovered_from_corrupt: bool = False
@@ -0,0 +1,115 @@
1
+ """Drive idemkit's async core from synchronous callers.
2
+
3
+ The whole library is async (the core awaits the backend), but a large slice of
4
+ Python side-effect code is synchronous — Celery tasks, Flask/Django views, plain
5
+ threaded SQS/Kafka workers. The synchronous facades (``idempotent_sync`` and
6
+ ``IdempotentConsumer.dispatch_sync``) run that code on a single, shared background
7
+ event loop kept alive in a daemon thread.
8
+
9
+ Why one persistent loop rather than ``asyncio.run`` per call: a backend's
10
+ connection pool (Redis, PostgreSQL) binds to the loop it is first used on. Spinning
11
+ up a fresh loop per call would rebind — or orphan — the pool every time. A single
12
+ long-lived loop keeps the pool valid across calls, exactly as an async program
13
+ would.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import atexit
20
+ import threading
21
+ from collections.abc import Coroutine
22
+ from typing import Any, TypeVar
23
+
24
+ _T = TypeVar("_T")
25
+
26
+
27
+ class _BackgroundLoop:
28
+ """A daemon thread running a dedicated asyncio loop."""
29
+
30
+ def __init__(self) -> None:
31
+ self._loop = asyncio.new_event_loop()
32
+ self._thread = threading.Thread(target=self._run, name="idemkit-sync-loop", daemon=True)
33
+ self._thread.start()
34
+
35
+ def _run(self) -> None:
36
+ asyncio.set_event_loop(self._loop)
37
+ self._loop.run_forever()
38
+
39
+ def run(self, coro: Coroutine[Any, Any, _T]) -> _T:
40
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
41
+ return future.result()
42
+
43
+ def close(self) -> None:
44
+ if not self._loop.is_closed():
45
+ self._loop.call_soon_threadsafe(self._loop.stop)
46
+ self._thread.join(timeout=5)
47
+ self._loop.close()
48
+
49
+
50
+ _lock = threading.Lock()
51
+ _instance: _BackgroundLoop | None = None
52
+ _closables: list[Any] = []
53
+
54
+
55
+ def register_closable(obj: Any) -> None:
56
+ """Track a backend used through the sync facade so it can be closed cleanly
57
+ on the background loop at process exit.
58
+
59
+ Without this, a Redis/Postgres pool bound to the background loop is GC'd after
60
+ the loop is gone, printing a spurious ``RuntimeError: Event loop is closed`` at
61
+ shutdown. Closing it on its own loop first avoids that. Only objects exposing
62
+ ``aclose`` are tracked; duplicates (by identity) are ignored.
63
+ """
64
+ if hasattr(obj, "aclose") and all(obj is not c for c in _closables):
65
+ _closables.append(obj)
66
+
67
+
68
+ def run_sync(coro: Coroutine[Any, Any, _T]) -> _T:
69
+ """Run an idemkit core coroutine to completion from a synchronous caller.
70
+
71
+ Lazily starts the shared background loop on first use. Refuses to run from
72
+ inside an already-running event loop (that would block the caller's loop): in
73
+ async code, use the async API (``idempotent`` / ``dispatch``) directly.
74
+ """
75
+ try:
76
+ asyncio.get_running_loop()
77
+ except RuntimeError:
78
+ pass # good: no running loop, we're in synchronous code
79
+ else:
80
+ coro.close()
81
+ raise RuntimeError(
82
+ "idemkit: the synchronous API (idempotent_sync / dispatch_sync) "
83
+ "was called from inside a running event loop, which would block it. "
84
+ "Use the async API (idempotent / dispatch / await) instead."
85
+ )
86
+
87
+ global _instance
88
+ if _instance is None:
89
+ with _lock:
90
+ if _instance is None:
91
+ _instance = _BackgroundLoop()
92
+ return _instance.run(coro)
93
+
94
+
95
+ async def _aclose_all() -> None:
96
+ for obj in _closables:
97
+ try:
98
+ await obj.aclose()
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ @atexit.register
104
+ def _shutdown() -> None:
105
+ global _instance
106
+ if _instance is not None:
107
+ # Close tracked backends ON the background loop (while it's still alive),
108
+ # then stop the loop — avoids the GC-after-loop-closed teardown noise.
109
+ try:
110
+ _instance.run(_aclose_all())
111
+ except Exception:
112
+ pass
113
+ _instance.close()
114
+ _instance = None
115
+ _closables.clear()
@@ -0,0 +1,119 @@
1
+ """RFC 9457 Problem Details helpers per spec §6.3.
2
+
3
+ URI namespace ``urn:idemkit:*`` is OUR namespace. The IETF-namespace URIs
4
+ ``urn:ietf:idempotency:*`` are proposed to the WG but not yet registered;
5
+ we do not use them in our own implementation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ CONTENT_TYPE = "application/problem+json"
13
+
14
+ PROBLEM_TYPES = {
15
+ "missing-key": "urn:idemkit:missing-key",
16
+ "payload-mismatch": "urn:idemkit:payload-mismatch",
17
+ "in-progress": "urn:idemkit:in-progress",
18
+ "storage-error": "urn:idemkit:storage-error",
19
+ "identity-unavailable": "urn:idemkit:identity-unavailable",
20
+ "expired": "urn:idemkit:expired",
21
+ }
22
+
23
+
24
+ def problem_response(
25
+ *,
26
+ type_key: str,
27
+ status: int,
28
+ title: str,
29
+ detail: str,
30
+ ) -> tuple[int, dict[str, str], bytes]:
31
+ """Build an RFC 9457 Problem Details response.
32
+
33
+ Returns ``(status, headers, body_bytes)``.
34
+ """
35
+ payload: dict[str, object] = {
36
+ "type": PROBLEM_TYPES[type_key],
37
+ "title": title,
38
+ "status": status,
39
+ "detail": detail,
40
+ }
41
+ body = json.dumps(payload).encode("utf-8")
42
+ headers = {
43
+ "content-type": CONTENT_TYPE,
44
+ "content-length": str(len(body)),
45
+ }
46
+ return status, headers, body
47
+
48
+
49
+ def missing_key() -> tuple[int, dict[str, str], bytes]:
50
+ return problem_response(
51
+ type_key="missing-key",
52
+ status=400,
53
+ title="Idempotency key required",
54
+ detail="This endpoint requires an Idempotency-Key header.",
55
+ )
56
+
57
+
58
+ def key_too_long(max_bytes: int = 255) -> tuple[int, dict[str, str], bytes]:
59
+ # §6.1: oversized key → 400 with the same type URI as a missing key.
60
+ return problem_response(
61
+ type_key="missing-key",
62
+ status=400,
63
+ title="Idempotency key too long",
64
+ detail=(
65
+ f"The Idempotency-Key header exceeds the {max_bytes}-byte limit. "
66
+ "Use a shorter key (a UUID is a good choice)."
67
+ ),
68
+ )
69
+
70
+
71
+ def payload_mismatch(stripe_compat: bool) -> tuple[int, dict[str, str], bytes]:
72
+ return problem_response(
73
+ type_key="payload-mismatch",
74
+ status=409 if stripe_compat else 422,
75
+ title="Idempotency key payload mismatch",
76
+ detail=(
77
+ "The idempotency key was previously used with a different request payload. "
78
+ "To retry safely, send the same payload as before or use a new idempotency key."
79
+ ),
80
+ )
81
+
82
+
83
+ def in_progress(stripe_compat: bool, retry_after_seconds: int) -> tuple[int, dict[str, str], bytes]:
84
+ status, headers, body = problem_response(
85
+ type_key="in-progress",
86
+ status=409 if stripe_compat else 423,
87
+ title="Idempotency key in progress",
88
+ detail=(
89
+ "Another request with the same idempotency key is currently being processed. "
90
+ "Wait and retry."
91
+ ),
92
+ )
93
+ headers["retry-after"] = str(max(1, retry_after_seconds))
94
+ return status, headers, body
95
+
96
+
97
+ def identity_unavailable() -> tuple[int, dict[str, str], bytes]:
98
+ return problem_response(
99
+ type_key="identity-unavailable",
100
+ status=500,
101
+ title="Caller identity unavailable",
102
+ detail=(
103
+ "The server could not determine a caller identity for this request. "
104
+ "Idempotency was refused to preserve cross-tenant isolation. "
105
+ "This indicates a server-side configuration issue with the "
106
+ "scope extractor."
107
+ ),
108
+ )
109
+
110
+
111
+ def storage_error(retry_after_seconds: int = 5) -> tuple[int, dict[str, str], bytes]:
112
+ status, headers, body = problem_response(
113
+ type_key="storage-error",
114
+ status=503,
115
+ title="Idempotency storage unavailable",
116
+ detail="The idempotency backend is temporarily unavailable. Retry.",
117
+ )
118
+ headers["retry-after"] = str(max(1, retry_after_seconds))
119
+ return status, headers, body
idemkit/py.typed ADDED
File without changes