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/adapters/wsgi.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""WSGI middleware for idemkit — Flask, Django (sync views), Pyramid, Bottle, any
|
|
2
|
+
WSGI app.
|
|
3
|
+
|
|
4
|
+
The idemkit core is async (it awaits the backend), and WSGI is synchronous, so
|
|
5
|
+
this middleware drives the same HTTP engine the ASGI middleware uses through the
|
|
6
|
+
shared background loop in :mod:`idemkit.core.sync_bridge` — exactly like
|
|
7
|
+
``idempotent_sync`` and ``IdempotentConsumer.dispatch_sync``. Behaviour matches
|
|
8
|
+
the ASGI middleware: same config, same fingerprinting, same replay/reject wire
|
|
9
|
+
format, same size/streaming bypass, same fail-closed scope rule.
|
|
10
|
+
|
|
11
|
+
Usage (Flask)::
|
|
12
|
+
|
|
13
|
+
from flask import Flask
|
|
14
|
+
from idemkit import HttpConfig, WSGIIdempotencyMiddleware, RedisBackend
|
|
15
|
+
|
|
16
|
+
app = Flask(__name__)
|
|
17
|
+
app.wsgi_app = WSGIIdempotencyMiddleware(
|
|
18
|
+
app.wsgi_app,
|
|
19
|
+
backend=RedisBackend.from_url("redis://localhost:6379"),
|
|
20
|
+
config=HttpConfig(scope=lambda req: req.headers["x-user-id"]),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
Django: wrap ``application`` in ``wsgi.py`` the same way. Extractors (``scope`` /
|
|
24
|
+
``key``) receive a lightweight request proxy exposing ``req.headers`` (case
|
|
25
|
+
-insensitive), ``req.environ`` (the raw WSGI environ), and ``req.body``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import logging
|
|
32
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from idemkit import problem_details
|
|
36
|
+
from idemkit.adapters.asgi import _CaseInsensitiveHeaders, _unwrap_sf_string
|
|
37
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
38
|
+
from idemkit.core.config import resolve_http_config
|
|
39
|
+
from idemkit.core.engine import IdempotencyEngine, NeutralRequest, NeutralResponse
|
|
40
|
+
from idemkit.core.policy import HttpConfig
|
|
41
|
+
from idemkit.core.sync_bridge import register_closable, run_sync
|
|
42
|
+
|
|
43
|
+
_logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
_inmemory_warned = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _warn_inmemory_backend() -> None:
|
|
49
|
+
global _inmemory_warned
|
|
50
|
+
if _inmemory_warned:
|
|
51
|
+
return
|
|
52
|
+
_inmemory_warned = True
|
|
53
|
+
_logger.warning(
|
|
54
|
+
"idemkit: InMemoryBackend is for development and tests only. Its state "
|
|
55
|
+
"lives in one process, so idempotency is NOT shared across multiple "
|
|
56
|
+
"workers (gunicorn/uWSGI workers, multiple pods) and duplicate requests "
|
|
57
|
+
"can slip through. Use RedisBackend or PostgresBackend in production."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# WSGI types, kept loose to avoid a hard dependency on a typing shim.
|
|
62
|
+
WSGIEnviron = dict[str, Any]
|
|
63
|
+
StartResponse = Callable[..., Any]
|
|
64
|
+
WSGIApp = Callable[[WSGIEnviron, StartResponse], Iterable[bytes]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WSGIIdempotencyMiddleware:
|
|
68
|
+
"""WSGI middleware applying idempotency per the configured backend.
|
|
69
|
+
|
|
70
|
+
Wrap your WSGI application (``app.wsgi_app`` in Flask, ``application`` in a
|
|
71
|
+
Django ``wsgi.py``). Accepts the same keyword arguments as the ASGI
|
|
72
|
+
:class:`~idemkit.IdempotencyMiddleware`, configured with an ``HttpConfig``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
app: WSGIApp,
|
|
78
|
+
*,
|
|
79
|
+
backend: IdempotencyBackend,
|
|
80
|
+
config: HttpConfig | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
resolved = resolve_http_config(config)
|
|
83
|
+
self.app = app
|
|
84
|
+
self.config = resolved
|
|
85
|
+
self.backend = backend
|
|
86
|
+
self.engine = IdempotencyEngine(backend=backend, config=resolved)
|
|
87
|
+
# The Redis/Postgres pool binds to the sync-bridge background loop; track
|
|
88
|
+
# it so it closes cleanly at process exit (no "Event loop is closed").
|
|
89
|
+
register_closable(backend)
|
|
90
|
+
if type(backend).__name__ == "InMemoryBackend":
|
|
91
|
+
_warn_inmemory_backend()
|
|
92
|
+
|
|
93
|
+
def __call__(self, environ: WSGIEnviron, start_response: StartResponse) -> Iterable[bytes]:
|
|
94
|
+
method = (environ.get("REQUEST_METHOD") or "GET").upper()
|
|
95
|
+
path = environ.get("PATH_INFO") or "/"
|
|
96
|
+
query = environ.get("QUERY_STRING") or ""
|
|
97
|
+
content_type = environ.get("CONTENT_TYPE")
|
|
98
|
+
headers = _headers_from_environ(environ)
|
|
99
|
+
|
|
100
|
+
max_request = self.config.max_request_body_bytes
|
|
101
|
+
stream = environ.get("wsgi.input") or io.BytesIO(b"")
|
|
102
|
+
body, oversized, tail = _read_request_body(stream, max_request)
|
|
103
|
+
|
|
104
|
+
if oversized:
|
|
105
|
+
# Too large to buffer for fingerprinting → bypass idempotency. The
|
|
106
|
+
# require_key_for_mutations gate is a separate safety control and MUST
|
|
107
|
+
# still apply (don't let a big body smuggle a mutation past it).
|
|
108
|
+
key = self._extract_key(environ, headers, body)
|
|
109
|
+
if (
|
|
110
|
+
self.config.require_key_for_mutations
|
|
111
|
+
and method in self.config.applicable_methods
|
|
112
|
+
and not key
|
|
113
|
+
):
|
|
114
|
+
return _send(start_response, _problem(problem_details.missing_key()))
|
|
115
|
+
_logger.warning(
|
|
116
|
+
"idemkit: request body exceeded max_request_body_bytes=%s; "
|
|
117
|
+
"bypassing idempotency and streaming the request through.",
|
|
118
|
+
max_request,
|
|
119
|
+
)
|
|
120
|
+
environ["wsgi.input"] = _PrefixedInput(body, stream, tail)
|
|
121
|
+
return self.app(environ, start_response)
|
|
122
|
+
|
|
123
|
+
# Body fully buffered — hand the downstream app a fresh stream over it.
|
|
124
|
+
environ["wsgi.input"] = io.BytesIO(body)
|
|
125
|
+
environ["CONTENT_LENGTH"] = str(len(body))
|
|
126
|
+
|
|
127
|
+
idempotency_key = self._extract_key(environ, headers, body)
|
|
128
|
+
caller_scope = self._extract_scope(environ, headers)
|
|
129
|
+
|
|
130
|
+
request = NeutralRequest(
|
|
131
|
+
idempotency_key=idempotency_key,
|
|
132
|
+
scope=caller_scope,
|
|
133
|
+
method=method,
|
|
134
|
+
path=path,
|
|
135
|
+
query=query,
|
|
136
|
+
body=body,
|
|
137
|
+
content_type=content_type,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
outcome = run_sync(self.engine.decide(request))
|
|
141
|
+
|
|
142
|
+
if outcome.kind in ("replay", "reject"):
|
|
143
|
+
assert outcome.response is not None
|
|
144
|
+
return _send(start_response, outcome.response)
|
|
145
|
+
|
|
146
|
+
if outcome.effective_key is None or outcome.claim_token is None:
|
|
147
|
+
# Idempotency does not apply (method not in scope, or no key and not
|
|
148
|
+
# required). Forward as-is.
|
|
149
|
+
return self.app(environ, start_response)
|
|
150
|
+
|
|
151
|
+
return self._run_and_capture(
|
|
152
|
+
environ, start_response, outcome.effective_key, outcome.claim_token
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ----- pass-through with capture -----
|
|
156
|
+
|
|
157
|
+
def _run_and_capture(
|
|
158
|
+
self,
|
|
159
|
+
environ: WSGIEnviron,
|
|
160
|
+
start_response: StartResponse,
|
|
161
|
+
effective_key: str,
|
|
162
|
+
claim_token: str,
|
|
163
|
+
) -> Iterable[bytes]:
|
|
164
|
+
captured: dict[str, Any] = {"status": None, "headers": None}
|
|
165
|
+
|
|
166
|
+
def capturing_start_response(
|
|
167
|
+
status: str, response_headers: list[tuple[str, str]], exc_info: Any = None
|
|
168
|
+
) -> Callable[[bytes], Any]:
|
|
169
|
+
if exc_info is not None:
|
|
170
|
+
try:
|
|
171
|
+
raise exc_info[1].with_traceback(exc_info[2])
|
|
172
|
+
finally:
|
|
173
|
+
exc_info = None
|
|
174
|
+
captured["status"] = status
|
|
175
|
+
captured["headers"] = response_headers
|
|
176
|
+
return lambda _data: None
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
result = self.app(environ, capturing_start_response)
|
|
180
|
+
except Exception:
|
|
181
|
+
run_sync(self.engine.on_error(effective_key, claim_token))
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
body_chunks: list[bytes] = []
|
|
185
|
+
size = 0
|
|
186
|
+
max_body = self.config.max_body_bytes
|
|
187
|
+
it: Iterator[bytes] = iter(result)
|
|
188
|
+
try:
|
|
189
|
+
for chunk in it:
|
|
190
|
+
body_chunks.append(chunk)
|
|
191
|
+
size += len(chunk)
|
|
192
|
+
if size > max_body:
|
|
193
|
+
# Spec §4.9: too big to cache. Stream through uncached with a
|
|
194
|
+
# bypass header and release the claim so a retry re-executes.
|
|
195
|
+
_close(result)
|
|
196
|
+
run_sync(self.engine.on_error(effective_key, claim_token))
|
|
197
|
+
status, hdrs = _parse_captured(captured)
|
|
198
|
+
hdrs = [*hdrs, ("Idempotency-Replay-Unavailable", "size-exceeded")]
|
|
199
|
+
start_response(status, hdrs)
|
|
200
|
+
return _chain(body_chunks, it, result)
|
|
201
|
+
except Exception:
|
|
202
|
+
_close(result)
|
|
203
|
+
run_sync(self.engine.on_error(effective_key, claim_token))
|
|
204
|
+
raise
|
|
205
|
+
_close(result)
|
|
206
|
+
|
|
207
|
+
status, hdrs = _parse_captured(captured)
|
|
208
|
+
status_code = int(status.split(" ", 1)[0])
|
|
209
|
+
body = b"".join(body_chunks)
|
|
210
|
+
run_sync(
|
|
211
|
+
self.engine.on_complete(
|
|
212
|
+
effective_key,
|
|
213
|
+
claim_token,
|
|
214
|
+
NeutralResponse(status=status_code, headers=_headers_to_dict(hdrs), body=body),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
start_response(status, hdrs)
|
|
218
|
+
return [body]
|
|
219
|
+
|
|
220
|
+
# ----- extraction -----
|
|
221
|
+
|
|
222
|
+
def _extract_key(
|
|
223
|
+
self, environ: WSGIEnviron, headers: dict[str, str], body: bytes
|
|
224
|
+
) -> str | None:
|
|
225
|
+
if self.config.key is not None:
|
|
226
|
+
try:
|
|
227
|
+
return self.config.key(_WsgiRequestProxy(environ, headers, body))
|
|
228
|
+
except Exception:
|
|
229
|
+
_logger.exception("idemkit: key raised; treating as no key")
|
|
230
|
+
return None
|
|
231
|
+
return _unwrap_sf_string(headers.get("idempotency-key"))
|
|
232
|
+
|
|
233
|
+
def _extract_scope(self, environ: WSGIEnviron, headers: dict[str, str]) -> str | None:
|
|
234
|
+
if self.config.scope is None:
|
|
235
|
+
return None # single-tenant mode
|
|
236
|
+
try:
|
|
237
|
+
value = self.config.scope(_WsgiRequestProxy(environ, headers, b""))
|
|
238
|
+
except Exception:
|
|
239
|
+
# A configured-but-failing extractor fails closed (engine returns 500),
|
|
240
|
+
# never silently shares a namespace (the cross-tenant bug §4.6 prevents).
|
|
241
|
+
_logger.exception(
|
|
242
|
+
"idemkit: scope extractor raised; this request will be refused "
|
|
243
|
+
"(500). The WSGI proxy exposes req.headers / req.environ / req.body."
|
|
244
|
+
)
|
|
245
|
+
return None
|
|
246
|
+
return value or None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _WsgiRequestProxy:
|
|
250
|
+
"""Request-like object handed to user ``scope`` / ``key`` extractors.
|
|
251
|
+
|
|
252
|
+
Exposes ``headers`` (case-insensitive), ``environ`` (raw WSGI environ), and
|
|
253
|
+
``body`` (request bytes; passed to ``key`` only).
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
__slots__ = ("body", "environ", "headers")
|
|
257
|
+
|
|
258
|
+
def __init__(self, environ: WSGIEnviron, headers: dict[str, str], body: bytes) -> None:
|
|
259
|
+
self.environ = environ
|
|
260
|
+
self.headers = headers
|
|
261
|
+
self.body = body
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class _PrefixedInput:
|
|
265
|
+
"""A read-only stream that yields an already-read prefix, then the rest.
|
|
266
|
+
|
|
267
|
+
Used on the oversized-request bypass so the downstream app still sees the
|
|
268
|
+
whole body while idemkit's buffer stays bounded.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def __init__(self, prefix: bytes, original: Any, tail: bytes) -> None:
|
|
272
|
+
self._buf = prefix + tail
|
|
273
|
+
self._pos = 0
|
|
274
|
+
self._original = original
|
|
275
|
+
|
|
276
|
+
def read(self, size: int = -1) -> bytes:
|
|
277
|
+
if size is None or size < 0:
|
|
278
|
+
rest = self._buf[self._pos :]
|
|
279
|
+
self._pos = len(self._buf)
|
|
280
|
+
try:
|
|
281
|
+
rest += self._original.read()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
return rest
|
|
285
|
+
out = self._buf[self._pos : self._pos + size]
|
|
286
|
+
self._pos += len(out)
|
|
287
|
+
if len(out) < size:
|
|
288
|
+
out += self._original.read(size - len(out))
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
def readline(self, *args: Any) -> bytes:
|
|
292
|
+
# Minimal readline over the buffered prefix; falls back to the original.
|
|
293
|
+
nl = self._buf.find(b"\n", self._pos)
|
|
294
|
+
if nl != -1:
|
|
295
|
+
out = self._buf[self._pos : nl + 1]
|
|
296
|
+
self._pos = nl + 1
|
|
297
|
+
return out
|
|
298
|
+
rest = self._buf[self._pos :]
|
|
299
|
+
self._pos = len(self._buf)
|
|
300
|
+
tail: bytes = self._original.readline(*args)
|
|
301
|
+
return rest + tail
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _read_request_body(stream: Any, max_bytes: int | None) -> tuple[bytes, bool, bytes]:
|
|
305
|
+
"""Read the request body up to ``max_bytes`` (None = unbounded).
|
|
306
|
+
|
|
307
|
+
Returns ``(body, oversized, tail)``. When oversized, ``body`` holds exactly
|
|
308
|
+
``max_bytes`` and ``tail`` holds the extra byte(s) already read past the cap
|
|
309
|
+
(so no data is lost when chaining to the downstream app).
|
|
310
|
+
"""
|
|
311
|
+
if max_bytes is None:
|
|
312
|
+
try:
|
|
313
|
+
data = stream.read()
|
|
314
|
+
except Exception:
|
|
315
|
+
data = b""
|
|
316
|
+
return (data or b""), False, b""
|
|
317
|
+
|
|
318
|
+
chunks: list[bytes] = []
|
|
319
|
+
remaining = max_bytes + 1 # one past the cap to detect an oversized body
|
|
320
|
+
while remaining > 0:
|
|
321
|
+
chunk = stream.read(remaining)
|
|
322
|
+
if not chunk:
|
|
323
|
+
break
|
|
324
|
+
chunks.append(chunk)
|
|
325
|
+
remaining -= len(chunk)
|
|
326
|
+
data = b"".join(chunks)
|
|
327
|
+
if len(data) > max_bytes:
|
|
328
|
+
return data[:max_bytes], True, data[max_bytes:]
|
|
329
|
+
return data, False, b""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _headers_from_environ(environ: WSGIEnviron) -> _CaseInsensitiveHeaders:
|
|
333
|
+
"""WSGI environ → case-insensitive lowercase header dict."""
|
|
334
|
+
out: _CaseInsensitiveHeaders = _CaseInsensitiveHeaders()
|
|
335
|
+
for key, value in environ.items():
|
|
336
|
+
if key.startswith("HTTP_"):
|
|
337
|
+
name = key[5:].replace("_", "-").lower()
|
|
338
|
+
out[name] = value
|
|
339
|
+
elif key == "CONTENT_TYPE":
|
|
340
|
+
out["content-type"] = value
|
|
341
|
+
elif key == "CONTENT_LENGTH":
|
|
342
|
+
out["content-length"] = value
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _headers_to_dict(headers: list[tuple[str, str]]) -> dict[str, str]:
|
|
347
|
+
return {name.lower(): value for name, value in headers}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _parse_captured(captured: dict[str, Any]) -> tuple[str, list[tuple[str, str]]]:
|
|
351
|
+
status = captured["status"]
|
|
352
|
+
headers = captured["headers"]
|
|
353
|
+
if status is None or headers is None:
|
|
354
|
+
raise RuntimeError("idemkit: the downstream WSGI app did not call start_response.")
|
|
355
|
+
return str(status), list(headers)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _problem(problem: tuple[int, dict[str, str], bytes]) -> NeutralResponse:
|
|
359
|
+
status, headers, body = problem
|
|
360
|
+
return NeutralResponse(status=status, headers=headers, body=body)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _send(start_response: StartResponse, response: NeutralResponse) -> list[bytes]:
|
|
364
|
+
status_line = f"{response.status} {_reason(response.status)}"
|
|
365
|
+
header_items = list(response.headers.items())
|
|
366
|
+
start_response(status_line, header_items)
|
|
367
|
+
return [response.body]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _chain(buffered: list[bytes], rest: Iterator[bytes], closable: Any) -> Iterator[bytes]:
|
|
371
|
+
yield from buffered
|
|
372
|
+
try:
|
|
373
|
+
yield from rest
|
|
374
|
+
finally:
|
|
375
|
+
_close(closable)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _close(obj: Any) -> None:
|
|
379
|
+
close = getattr(obj, "close", None)
|
|
380
|
+
if callable(close):
|
|
381
|
+
try:
|
|
382
|
+
close()
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _reason(status: int) -> str:
|
|
388
|
+
try:
|
|
389
|
+
from http import HTTPStatus
|
|
390
|
+
|
|
391
|
+
return HTTPStatus(status).phrase
|
|
392
|
+
except Exception:
|
|
393
|
+
return ""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""idemkit backend implementations.
|
|
2
|
+
|
|
3
|
+
Production-ready in v0.1: ``InMemoryBackend`` (dev/test), ``RedisBackend``,
|
|
4
|
+
``PostgresBackend``. Custom backends implement the ``IdempotencyBackend``
|
|
5
|
+
Protocol from ``idemkit.backends.base``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
9
|
+
from idemkit.backends.memory import InMemoryBackend, MemoryBackendFull
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"IdempotencyBackend",
|
|
13
|
+
"InMemoryBackend",
|
|
14
|
+
"MemoryBackendFull",
|
|
15
|
+
"PostgresBackend",
|
|
16
|
+
"RedisBackend",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str): # type: ignore[no-untyped-def]
|
|
21
|
+
"""Lazy imports — Redis/Postgres deps are optional."""
|
|
22
|
+
if name == "RedisBackend":
|
|
23
|
+
from idemkit.backends.redis import RedisBackend
|
|
24
|
+
|
|
25
|
+
return RedisBackend
|
|
26
|
+
if name == "PostgresBackend":
|
|
27
|
+
from idemkit.backends.postgres import PostgresBackend
|
|
28
|
+
|
|
29
|
+
return PostgresBackend
|
|
30
|
+
raise AttributeError(f"module 'idemkit.backends' has no attribute {name!r}")
|
idemkit/backends/base.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Backend Protocol per spec §4.1.
|
|
2
|
+
|
|
3
|
+
Backend implementations included: ``InMemoryBackend`` (dev/test only),
|
|
4
|
+
``RedisBackend``, ``PostgresBackend``, ``MongoBackend``, ``DynamoBackend``.
|
|
5
|
+
|
|
6
|
+
All methods are async. Implementations MUST satisfy spec §4.1 atomicity:
|
|
7
|
+
``claim`` is a single-shot atomic insert; ``complete`` and ``release`` are
|
|
8
|
+
conditional state transitions that check ``claim_token`` to detect cases
|
|
9
|
+
where another process reclaimed an expired lease.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from idemkit.core.state import ClaimResult, StoredRecord
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IdempotencyBackend(Protocol):
|
|
20
|
+
"""The single storage interface idemkit uses.
|
|
21
|
+
|
|
22
|
+
Anyone can write a new backend by implementing this Protocol. See
|
|
23
|
+
``InMemoryBackend`` for the reference shape.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def claim(
|
|
27
|
+
self,
|
|
28
|
+
effective_key: str,
|
|
29
|
+
fingerprint: str,
|
|
30
|
+
fingerprint_version: int,
|
|
31
|
+
lease_ttl_seconds: float,
|
|
32
|
+
) -> ClaimResult:
|
|
33
|
+
"""Attempt to atomically claim ``effective_key``.
|
|
34
|
+
|
|
35
|
+
Outcomes (spec §4.1):
|
|
36
|
+
|
|
37
|
+
* ``NEW_CLAIMED`` — the record is now in storage with our claim_token.
|
|
38
|
+
* ``LEASE_RECLAIMED`` — an existing CLAIMED record had an expired
|
|
39
|
+
lease and we successfully reclaimed it; treated as NEW by the
|
|
40
|
+
caller, but emits a different observability event.
|
|
41
|
+
* ``ALREADY_CLAIMED`` — another process holds a live claim.
|
|
42
|
+
* ``ALREADY_COMPLETED`` — a COMPLETED record exists.
|
|
43
|
+
|
|
44
|
+
On the NEW / RECLAIMED paths, ``ClaimResult.our_claim_token`` is the
|
|
45
|
+
value to present in subsequent ``complete`` / ``release`` calls.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
async def complete(
|
|
50
|
+
self,
|
|
51
|
+
effective_key: str,
|
|
52
|
+
claim_token: str,
|
|
53
|
+
response_status: int,
|
|
54
|
+
response_headers: dict[str, str],
|
|
55
|
+
response_body: bytes,
|
|
56
|
+
expires_after_seconds: float,
|
|
57
|
+
) -> bool:
|
|
58
|
+
"""Conditional transition CLAIMED → COMPLETED, spec §4.1.
|
|
59
|
+
|
|
60
|
+
Returns ``True`` if the transition was applied. Returns ``False`` if
|
|
61
|
+
the claim was reclaimed by another process while the handler was
|
|
62
|
+
running — the caller MUST then emit ``idempotency.lease_reclaimed_loss``
|
|
63
|
+
and discard the response from this execution.
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
async def release(self, effective_key: str, claim_token: str) -> bool:
|
|
68
|
+
"""Conditional release of a CLAIMED record, spec §4.1.
|
|
69
|
+
|
|
70
|
+
Returns ``True`` if released. Returns ``False`` if our claim was
|
|
71
|
+
already reclaimed by another process.
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
async def renew(
|
|
76
|
+
self,
|
|
77
|
+
effective_key: str,
|
|
78
|
+
claim_token: str,
|
|
79
|
+
lease_ttl_seconds: float,
|
|
80
|
+
) -> bool:
|
|
81
|
+
"""Extend a live claim's lease, spec §5.3.1 (lease renewal / heartbeat).
|
|
82
|
+
|
|
83
|
+
Conditional on ownership, exactly like ``complete`` / ``release``: the
|
|
84
|
+
lease is pushed out to ``now + lease_ttl_seconds`` (by the storage
|
|
85
|
+
clock) ONLY if the record is still ``CLAIMED`` and ``claim_token``
|
|
86
|
+
matches. Returns ``True`` on success.
|
|
87
|
+
|
|
88
|
+
Returns ``False`` when the renewal could not be applied — the record is
|
|
89
|
+
gone, already ``COMPLETED``, or was reclaimed by another executor (a new
|
|
90
|
+
``claim_token``). A ``False`` is the fencing signal a heartbeat uses to
|
|
91
|
+
stop: it means this executor no longer holds the claim, so it MUST NOT
|
|
92
|
+
keep running as if it did.
|
|
93
|
+
|
|
94
|
+
This is what lets a long handler keep a SHORT lease: the lease tracks
|
|
95
|
+
actual progress instead of a guessed upper bound, so a crash lapses the
|
|
96
|
+
lease promptly while a slow-but-alive handler holds on. See §5.3.1 for
|
|
97
|
+
why renewal is required off-Lambda (no hard deadline to derive from).
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
async def wait_for_completion(
|
|
102
|
+
self,
|
|
103
|
+
effective_key: str,
|
|
104
|
+
timeout_seconds: float,
|
|
105
|
+
) -> StoredRecord | None:
|
|
106
|
+
"""Wait for an in-flight claim to complete, spec §4.3.
|
|
107
|
+
|
|
108
|
+
MUST implement subscribe-before-read ordering: register for
|
|
109
|
+
notifications BEFORE re-reading state, to avoid lost wakeups when
|
|
110
|
+
the publisher fires between read and subscribe.
|
|
111
|
+
|
|
112
|
+
Returns the COMPLETED record if it arrived within ``timeout_seconds``.
|
|
113
|
+
Returns ``None`` on timeout, on release without completion, or when
|
|
114
|
+
the in-flight claim's lease expired before completion.
|
|
115
|
+
"""
|
|
116
|
+
...
|