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.
- idemkit/__init__.py +101 -0
- idemkit/_version.py +1 -0
- idemkit/adapters/__init__.py +10 -0
- idemkit/adapters/ai.py +723 -0
- idemkit/adapters/asgi.py +533 -0
- idemkit/adapters/queue.py +413 -0
- idemkit/adapters/route.py +273 -0
- idemkit/adapters/wsgi.py +393 -0
- idemkit/backends/__init__.py +30 -0
- idemkit/backends/base.py +116 -0
- idemkit/backends/dynamodb.py +489 -0
- idemkit/backends/memory.py +325 -0
- idemkit/backends/mongo.py +395 -0
- idemkit/backends/postgres.py +682 -0
- idemkit/backends/redis.py +547 -0
- idemkit/cli.py +165 -0
- idemkit/conformance/__init__.py +25 -0
- idemkit/conformance/backend.py +257 -0
- idemkit/conformance/report.py +54 -0
- idemkit/contrib/__init__.py +30 -0
- idemkit/contrib/drf.py +181 -0
- idemkit/contrib/fastapi.py +141 -0
- idemkit/contrib/kafka.py +96 -0
- idemkit/contrib/logging.py +61 -0
- idemkit/contrib/mcp.py +113 -0
- idemkit/contrib/prometheus.py +79 -0
- idemkit/contrib/pubsub.py +98 -0
- idemkit/contrib/rabbitmq.py +138 -0
- idemkit/contrib/reconciliation.py +86 -0
- idemkit/contrib/sqs.py +117 -0
- idemkit/core/__init__.py +36 -0
- idemkit/core/codecs.py +229 -0
- idemkit/core/config.py +255 -0
- idemkit/core/engine.py +331 -0
- idemkit/core/events.py +63 -0
- idemkit/core/exception_cache.py +88 -0
- idemkit/core/exceptions.py +79 -0
- idemkit/core/fingerprint.py +153 -0
- idemkit/core/policy.py +143 -0
- idemkit/core/runner.py +664 -0
- idemkit/core/state.py +95 -0
- idemkit/core/sync_bridge.py +115 -0
- idemkit/problem_details.py +119 -0
- idemkit/py.typed +0 -0
- idemkit-0.1.0.dist-info/METADATA +446 -0
- idemkit-0.1.0.dist-info/RECORD +48 -0
- idemkit-0.1.0.dist-info/WHEEL +4 -0
- 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
|