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,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}")
@@ -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
+ ...