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/ai.py
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
"""Method-level idempotency for keyless callers, per spec §8.
|
|
2
|
+
|
|
3
|
+
``idempotent`` wraps one side-effectful function so a caller that re-invokes it
|
|
4
|
+
with the same arguments runs the real side effect once and replays the cached
|
|
5
|
+
result. It is a drop-in decorator, not a workflow engine (the §8.1 line idemkit
|
|
6
|
+
will not cross). The callers it is for are the ones that *don't supply an
|
|
7
|
+
idempotency key*:
|
|
8
|
+
|
|
9
|
+
* an LLM agent emitting the same tool call after a retry, a re-planning loop, or
|
|
10
|
+
in two parallel branches (the leading case; OpenAI / Anthropic / MCP) — and a
|
|
11
|
+
provider's per-turn ``tool_call_id`` is the wrong key because it changes every
|
|
12
|
+
turn;
|
|
13
|
+
* a background job / cron / Celery task that can run again after a failure;
|
|
14
|
+
* an internal call (another service, a worker, a CLI) with no idempotency
|
|
15
|
+
convention.
|
|
16
|
+
|
|
17
|
+
Because there is no caller-supplied key, the operation's identity is its
|
|
18
|
+
**arguments**, and the decorator is loud about getting that right (§8.2):
|
|
19
|
+
|
|
20
|
+
* **``key_fields=[...]`` is the default and the right choice.** Name the argument
|
|
21
|
+
fields that define "the same operation". Volatile fields you never list
|
|
22
|
+
(``request_id`` and friends) simply don't affect the key — cleaner than a
|
|
23
|
+
deny-list.
|
|
24
|
+
* **An explicit ``idempotency_key=...`` at call time overrides ``key_fields``** and
|
|
25
|
+
is authoritative — but reserve it for a value that is STABLE across retries (an
|
|
26
|
+
order id), never a per-turn provider id. idemkit warns when a volatile-looking
|
|
27
|
+
value lands in the key, by any path.
|
|
28
|
+
* **Deriving from all args is the fallback**, with a strict-mode rail: a
|
|
29
|
+
volatile-looking field in the key triggers a warning, because mis-keying here
|
|
30
|
+
silently causes a duplicate side effect or a lost call.
|
|
31
|
+
|
|
32
|
+
Deriving a key from arguments asserts an intent the caller never stated — two
|
|
33
|
+
genuinely-distinct calls with identical arguments collapse into one — so name
|
|
34
|
+
``key_fields`` deliberately (§8.2 #1).
|
|
35
|
+
|
|
36
|
+
Side-effect classification is the caller's: wrap a side-effectful function, leave a
|
|
37
|
+
pure one undecorated (§8.2 #4). idemkit caches the FIRST result for a key; it does
|
|
38
|
+
not make a nondeterministic function deterministic (§8.2 #6).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import functools
|
|
45
|
+
import hashlib
|
|
46
|
+
import inspect
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import re
|
|
50
|
+
import typing
|
|
51
|
+
import unicodedata
|
|
52
|
+
import warnings
|
|
53
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
54
|
+
from typing import Any, Literal
|
|
55
|
+
|
|
56
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
57
|
+
from idemkit.core.codecs import (
|
|
58
|
+
CustomResultCodec,
|
|
59
|
+
DataclassResultCodec,
|
|
60
|
+
JsonResultCodec,
|
|
61
|
+
PickleResultCodec,
|
|
62
|
+
PydanticResultCodec,
|
|
63
|
+
ResultCodec,
|
|
64
|
+
)
|
|
65
|
+
from idemkit.core.events import EventEmitter, EventHandler
|
|
66
|
+
from idemkit.core.exception_cache import (
|
|
67
|
+
encode_exception,
|
|
68
|
+
is_cached_exception,
|
|
69
|
+
rebuild_exception,
|
|
70
|
+
)
|
|
71
|
+
from idemkit.core.exceptions import (
|
|
72
|
+
ConfigurationError,
|
|
73
|
+
IdempotencyConflict,
|
|
74
|
+
IdempotencyKeyMissing,
|
|
75
|
+
PayloadMismatch,
|
|
76
|
+
StorageUnavailable,
|
|
77
|
+
)
|
|
78
|
+
from idemkit.core.fingerprint import compose_key
|
|
79
|
+
from idemkit.core.policy import MethodConfig
|
|
80
|
+
from idemkit.core.runner import CoreOutcome, IdempotentCore
|
|
81
|
+
from idemkit.core.sync_bridge import register_closable, run_sync
|
|
82
|
+
|
|
83
|
+
_logger = logging.getLogger(__name__)
|
|
84
|
+
|
|
85
|
+
# A tool call's identity is its (tool, version, args/explicit-key, caller); the
|
|
86
|
+
# fingerprint plays no role on this surface, so a constant value means the core's
|
|
87
|
+
# fingerprint check never trips (mirrors the queue surface, §5.1).
|
|
88
|
+
AI_FINGERPRINT = "idemkit-ai-tool-v1"
|
|
89
|
+
|
|
90
|
+
# Single-tenant fallback when no scope is configured (warned once at
|
|
91
|
+
# decoration). A configured extractor that then yields nothing fails closed.
|
|
92
|
+
_ANON_IDENTITY = "_idemkit_ai_anonymous"
|
|
93
|
+
|
|
94
|
+
# Either arg-derived — ``lambda args: args["session_id"]`` — or ambient (zero-arg,
|
|
95
|
+
# reads a contextvar). The arity is detected at decoration; see _caller_identity.
|
|
96
|
+
CallerIdentity = Callable[..., "str | None"]
|
|
97
|
+
NormalizeArgs = Callable[[Mapping[str, Any]], Mapping[str, Any]]
|
|
98
|
+
# Validation fingerprint (the method-surface analogue of HTTP's body_fingerprint,
|
|
99
|
+
# §5.1): bound arguments -> bytes that must MATCH on a key hit. A different result
|
|
100
|
+
# for the same key is a PayloadMismatch, not a wrong replay. The bytes are not part
|
|
101
|
+
# of the key, so a mismatch errors rather than creating a new dedupe bucket.
|
|
102
|
+
ValidationFingerprint = Callable[[Mapping[str, Any]], bytes]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_method_options(config: MethodConfig | None) -> dict[str, Any]:
|
|
106
|
+
"""Read every option from the ``MethodConfig`` (or its defaults)."""
|
|
107
|
+
c = config or MethodConfig()
|
|
108
|
+
return {
|
|
109
|
+
"version": c.version,
|
|
110
|
+
"scope": c.scope,
|
|
111
|
+
"key_fields": c.key_fields,
|
|
112
|
+
"validation_fingerprint": c.validation_fingerprint,
|
|
113
|
+
"normalize_args": c.normalize_args,
|
|
114
|
+
"result_codec": c.result_codec,
|
|
115
|
+
"strict_keys": c.strict_keys,
|
|
116
|
+
"require_key": c.require_key,
|
|
117
|
+
"cache_exceptions": c.cache_exceptions,
|
|
118
|
+
"lease_ttl_seconds": c.lease_ttl_seconds if c.lease_ttl_seconds is not None else 60.0,
|
|
119
|
+
"wait_timeout_seconds": (
|
|
120
|
+
c.wait_timeout_seconds if c.wait_timeout_seconds is not None else 10.0
|
|
121
|
+
),
|
|
122
|
+
"on_storage_error": c.on_storage_error,
|
|
123
|
+
"expires_after_seconds": c.expires_after_seconds,
|
|
124
|
+
"use_local_cache": c.use_local_cache,
|
|
125
|
+
"local_cache_max_items": c.local_cache_max_items,
|
|
126
|
+
"event_handlers": list(c.event_handlers),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def idempotent(
|
|
131
|
+
*,
|
|
132
|
+
backend: IdempotencyBackend,
|
|
133
|
+
config: MethodConfig | None = None,
|
|
134
|
+
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
|
|
135
|
+
"""Decorate a side-effectful async function so identical calls dedupe (§8.3).
|
|
136
|
+
|
|
137
|
+
All options are plain keywords. ``version`` invalidates cached results when the
|
|
138
|
+
function's behavior changes. ``result_codec`` is ``"json"`` (default),
|
|
139
|
+
``"dataclass"`` / ``"pydantic"``, a ``(to_dict, from_dict)`` pair, or any
|
|
140
|
+
``ResultCodec``. ``lease_ttl_seconds`` is renewed while the function runs (§5.3.1).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
opts = _resolve_method_options(config)
|
|
144
|
+
|
|
145
|
+
def decorator(
|
|
146
|
+
fn: Callable[..., Awaitable[Any]],
|
|
147
|
+
) -> Callable[..., Awaitable[Any]]:
|
|
148
|
+
engine = _ToolEngine(fn, backend=backend, **opts)
|
|
149
|
+
|
|
150
|
+
@functools.wraps(fn)
|
|
151
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
return await engine.call(args, kwargs)
|
|
153
|
+
|
|
154
|
+
# Exposed for tests that set up a record by hand (spec §9.4 testability).
|
|
155
|
+
wrapper.idemkit_engine = engine # type: ignore[attr-defined]
|
|
156
|
+
return wrapper
|
|
157
|
+
|
|
158
|
+
return decorator
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def idempotent_sync(
|
|
162
|
+
*,
|
|
163
|
+
backend: IdempotencyBackend,
|
|
164
|
+
config: MethodConfig | None = None,
|
|
165
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
166
|
+
"""Synchronous variant of :func:`idempotent` for non-async codebases.
|
|
167
|
+
|
|
168
|
+
Decorate a **synchronous** side-effectful function (a Celery task, a
|
|
169
|
+
Flask/Django view helper, a plain script) and call it like a normal function —
|
|
170
|
+
no event loop required. The same dedup core runs on a shared background loop;
|
|
171
|
+
the body runs on a worker thread. Every option matches ``idempotent``.
|
|
172
|
+
|
|
173
|
+
Do not call the returned function from inside a running event loop — use the
|
|
174
|
+
async :func:`idempotent` there instead.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
opts = _resolve_method_options(config)
|
|
178
|
+
|
|
179
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
180
|
+
engine = _ToolEngine(fn, backend=backend, runs_blocking=True, **opts)
|
|
181
|
+
register_closable(backend)
|
|
182
|
+
|
|
183
|
+
@functools.wraps(fn)
|
|
184
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
185
|
+
return run_sync(engine.call(args, kwargs))
|
|
186
|
+
|
|
187
|
+
wrapper.idemkit_engine = engine # type: ignore[attr-defined]
|
|
188
|
+
return wrapper
|
|
189
|
+
|
|
190
|
+
return decorator
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _ToolEngine:
|
|
194
|
+
"""Per-decorated-function state and the call flow."""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
fn: Callable[..., Awaitable[Any]],
|
|
199
|
+
*,
|
|
200
|
+
backend: IdempotencyBackend,
|
|
201
|
+
version: str,
|
|
202
|
+
scope: CallerIdentity | None,
|
|
203
|
+
key_fields: list[str] | None,
|
|
204
|
+
validation_fingerprint: ValidationFingerprint | None,
|
|
205
|
+
normalize_args: NormalizeArgs | None,
|
|
206
|
+
result_codec: Any,
|
|
207
|
+
lease_ttl_seconds: float,
|
|
208
|
+
wait_timeout_seconds: float,
|
|
209
|
+
on_storage_error: Literal["fail_closed", "fail_open"],
|
|
210
|
+
strict_keys: bool,
|
|
211
|
+
require_key: bool,
|
|
212
|
+
cache_exceptions: tuple[type[BaseException], ...],
|
|
213
|
+
expires_after_seconds: float,
|
|
214
|
+
use_local_cache: bool,
|
|
215
|
+
local_cache_max_items: int,
|
|
216
|
+
event_handlers: list[EventHandler] | None,
|
|
217
|
+
runs_blocking: bool = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
self.fn = fn
|
|
220
|
+
self.tool_name = getattr(fn, "__name__", "tool")
|
|
221
|
+
self.version = str(version)
|
|
222
|
+
self._runs_blocking = runs_blocking
|
|
223
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
224
|
+
if not runs_blocking and not is_async:
|
|
225
|
+
# The async surface awaits the tool and the wrapper is itself an
|
|
226
|
+
# `async def`. A plain `def` would be wrapped in a coroutine the caller
|
|
227
|
+
# never awaits — the side effect would silently never run (review
|
|
228
|
+
# P2-1). Fail loudly; point at the sync decorator and the to_thread
|
|
229
|
+
# escape hatch.
|
|
230
|
+
raise ConfigurationError(
|
|
231
|
+
f"idemkit: idempotent requires an 'async def' tool, but "
|
|
232
|
+
f"{self.tool_name!r} is a regular (synchronous) function. For a "
|
|
233
|
+
"synchronous codebase use idempotent_sync; inside an async "
|
|
234
|
+
"tool wrap the blocking call: "
|
|
235
|
+
"`async def tool(...): return await asyncio.to_thread(blocking, ...)`."
|
|
236
|
+
)
|
|
237
|
+
if runs_blocking and is_async:
|
|
238
|
+
# The sync facade runs the tool on a worker thread, which would not
|
|
239
|
+
# await a coroutine. An async tool belongs on the async decorator.
|
|
240
|
+
raise ConfigurationError(
|
|
241
|
+
f"idemkit: idempotent_sync wraps a synchronous tool, but "
|
|
242
|
+
f"{self.tool_name!r} is 'async def'. Use idempotent for it."
|
|
243
|
+
)
|
|
244
|
+
self.signature = inspect.signature(fn)
|
|
245
|
+
if "idempotency_key" in self.signature.parameters:
|
|
246
|
+
# The call-time `idempotency_key=` kwarg is reserved for the explicit
|
|
247
|
+
# key (§8.2); a tool parameter of the same name would be silently
|
|
248
|
+
# hijacked. Fail loudly at decoration rather than at the first call.
|
|
249
|
+
raise ConfigurationError(
|
|
250
|
+
f"idemkit: tool {self.tool_name!r} declares a parameter named "
|
|
251
|
+
"'idempotency_key', which idempotent reserves for the "
|
|
252
|
+
"explicit key. Rename the tool's parameter."
|
|
253
|
+
)
|
|
254
|
+
if key_fields is not None:
|
|
255
|
+
# A key field that isn't an actual parameter is almost always a typo,
|
|
256
|
+
# and it is the WORST kind: every call hashes that field to None, so
|
|
257
|
+
# distinct operations silently collapse to one key and replay each
|
|
258
|
+
# other's result. Catch it at decoration unless the tool takes
|
|
259
|
+
# **kwargs (where we genuinely can't know the field set).
|
|
260
|
+
params = self.signature.parameters
|
|
261
|
+
takes_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
262
|
+
unknown = [f for f in key_fields if f.split(".", 1)[0] not in params]
|
|
263
|
+
if unknown and not takes_var_kw:
|
|
264
|
+
raise ConfigurationError(
|
|
265
|
+
f"idemkit: key_fields {unknown} are not parameters of tool "
|
|
266
|
+
f"{self.tool_name!r} (its parameters are {list(params)}). This "
|
|
267
|
+
"is almost certainly a typo — it would silently collapse "
|
|
268
|
+
"distinct calls onto one key. Fix the names, or use an "
|
|
269
|
+
"explicit idempotency_key / normalize_args."
|
|
270
|
+
)
|
|
271
|
+
if strict_keys:
|
|
272
|
+
# A volatile-looking field named *in* key_fields is the same
|
|
273
|
+
# footgun as one slipping in via the derive-from-all-args path
|
|
274
|
+
# (review P2-2): retries that carry a new value won't dedupe. We
|
|
275
|
+
# only have field names at decoration time, so warn by name here
|
|
276
|
+
# (the value-based check still runs per call on the no-selector
|
|
277
|
+
# path). Listing it deliberately is legitimate, hence a warning,
|
|
278
|
+
# not an error.
|
|
279
|
+
volatile = [f for f in key_fields if _name_looks_volatile(f.rsplit(".", 1)[-1])]
|
|
280
|
+
if volatile:
|
|
281
|
+
warnings.warn(
|
|
282
|
+
f"idemkit: tool {self.tool_name!r} lists volatile-looking "
|
|
283
|
+
f"field(s) {volatile} in key_fields. If the value changes "
|
|
284
|
+
"between an honest retry and its original call, the two "
|
|
285
|
+
"WON'T dedupe. Use the stable fields only, or pass an "
|
|
286
|
+
"explicit idempotency_key when that field IS the key. (§8.2)",
|
|
287
|
+
stacklevel=4,
|
|
288
|
+
)
|
|
289
|
+
self.scope = scope
|
|
290
|
+
# scope may be either ambient (zero-arg, e.g. reads a
|
|
291
|
+
# contextvars.ContextVar) or arg-derived (one positional, receives the
|
|
292
|
+
# bound arguments). Detect which so the ambient form doesn't force a
|
|
293
|
+
# scope field like ``session_id`` into the tool's LLM-visible signature
|
|
294
|
+
# (review C-2). Default to passing the args when arity can't be read.
|
|
295
|
+
self._identity_wants_args = _accepts_one_positional(scope)
|
|
296
|
+
self.key_fields = key_fields
|
|
297
|
+
self.validation_fingerprint = validation_fingerprint
|
|
298
|
+
self.normalize_args = normalize_args
|
|
299
|
+
self.strict_keys = strict_keys
|
|
300
|
+
self.require_key = require_key
|
|
301
|
+
self.cache_exceptions = cache_exceptions
|
|
302
|
+
self._warned_volatile: set[str] = set()
|
|
303
|
+
self._warned_volatile_explicit = False
|
|
304
|
+
self.codec: ResultCodec[Any, Any] = _resolve_codec(
|
|
305
|
+
result_codec, _resolve_return_type(fn, self.signature)
|
|
306
|
+
)
|
|
307
|
+
self.core = IdempotentCore(
|
|
308
|
+
backend,
|
|
309
|
+
emitter=EventEmitter(event_handlers or []),
|
|
310
|
+
backend_name=type(backend).__name__,
|
|
311
|
+
surface="ai_tool",
|
|
312
|
+
on_storage_error=on_storage_error,
|
|
313
|
+
lease_ttl_seconds=lease_ttl_seconds,
|
|
314
|
+
wait_timeout_seconds=wait_timeout_seconds,
|
|
315
|
+
expires_after_seconds=expires_after_seconds,
|
|
316
|
+
use_local_cache=use_local_cache,
|
|
317
|
+
local_cache_max_items=local_cache_max_items,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if scope is None:
|
|
321
|
+
warnings.warn(
|
|
322
|
+
f"idemkit: tool {self.tool_name!r} has no scope, so all "
|
|
323
|
+
"callers share one idempotency namespace (single-tenant). For "
|
|
324
|
+
"per-agent / per-session scope, pass scope. See §5.1.",
|
|
325
|
+
stacklevel=4,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def effective_key(self, *args: Any, **kwargs: Any) -> str:
|
|
329
|
+
"""Compute the effective key for a hypothetical call — for tests."""
|
|
330
|
+
explicit_key = kwargs.pop("idempotency_key", None)
|
|
331
|
+
arguments = self._bind(args, kwargs)
|
|
332
|
+
caller_id = self._caller_identity(arguments)
|
|
333
|
+
return self._effective_key(explicit_key, arguments, caller_id)
|
|
334
|
+
|
|
335
|
+
async def call(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
336
|
+
explicit_key = kwargs.pop("idempotency_key", None)
|
|
337
|
+
arguments = self._bind(args, kwargs)
|
|
338
|
+
caller_id = self._caller_identity(arguments)
|
|
339
|
+
effective_key = self._effective_key(explicit_key, arguments, caller_id)
|
|
340
|
+
|
|
341
|
+
decision = await self.core.decide(effective_key, self._fingerprint(arguments))
|
|
342
|
+
outcome = decision.outcome
|
|
343
|
+
|
|
344
|
+
if outcome is CoreOutcome.REPLAY:
|
|
345
|
+
assert decision.stored is not None
|
|
346
|
+
if is_cached_exception(decision.stored):
|
|
347
|
+
# A prior run failed with a declared-deterministic exception; the
|
|
348
|
+
# duplicate re-raises it instead of re-running the side effect.
|
|
349
|
+
raise rebuild_exception(decision.stored)
|
|
350
|
+
return self.codec.decode(decision.stored)
|
|
351
|
+
if outcome is CoreOutcome.MISMATCH:
|
|
352
|
+
# Only reachable if the same key was stored under a different
|
|
353
|
+
# fingerprint, which the constant AI fingerprint precludes — guard
|
|
354
|
+
# anyway rather than replay the wrong result.
|
|
355
|
+
raise PayloadMismatch(
|
|
356
|
+
f"idemkit: tool {self.tool_name!r} key collided with a different "
|
|
357
|
+
"stored payload; refusing to replay."
|
|
358
|
+
)
|
|
359
|
+
if outcome is CoreOutcome.CONFLICT:
|
|
360
|
+
raise IdempotencyConflict(
|
|
361
|
+
f"idemkit: tool {self.tool_name!r} is already in progress for this "
|
|
362
|
+
f"key ({effective_key[:12]}…) and did not finish within the wait "
|
|
363
|
+
"timeout. Retry with bounded backoff; do not loop forever."
|
|
364
|
+
)
|
|
365
|
+
if outcome is CoreOutcome.STORAGE_REFUSE:
|
|
366
|
+
raise StorageUnavailable(
|
|
367
|
+
f"idemkit: storage unavailable for tool {self.tool_name!r} and "
|
|
368
|
+
"on_storage_error=fail_closed. Retry after a short backoff."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
token = decision.claim_token
|
|
372
|
+
unprotected = outcome is CoreOutcome.STORAGE_PASS
|
|
373
|
+
|
|
374
|
+
async def raw() -> Any:
|
|
375
|
+
if self._runs_blocking:
|
|
376
|
+
# Synchronous tool: run it on a worker thread so it doesn't block
|
|
377
|
+
# the event loop (and so the heartbeat can renew the lease while
|
|
378
|
+
# it runs).
|
|
379
|
+
return await asyncio.to_thread(self.fn, *args, **kwargs)
|
|
380
|
+
return await self.fn(*args, **kwargs)
|
|
381
|
+
|
|
382
|
+
if token is None or unprotected:
|
|
383
|
+
# fail_open: storage is down, so run this once without dedup.
|
|
384
|
+
return await raw()
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
value, lease_lost = await self.core.execute_with_heartbeat(effective_key, token, raw)
|
|
388
|
+
except BaseException as exc:
|
|
389
|
+
if self.cache_exceptions and isinstance(exc, self.cache_exceptions):
|
|
390
|
+
# A declared-deterministic failure: cache it and replay it on a
|
|
391
|
+
# duplicate, instead of releasing (which would re-run the tool).
|
|
392
|
+
try:
|
|
393
|
+
await self.core.complete(effective_key, token, encode_exception(exc))
|
|
394
|
+
except Exception:
|
|
395
|
+
# Couldn't record it — fall back to release so a retry re-runs
|
|
396
|
+
# rather than wedging the key (the lease is the safety net).
|
|
397
|
+
await self.core.release(effective_key, token)
|
|
398
|
+
raise
|
|
399
|
+
# The tool itself raised (or was cancelled). Release the claim so a
|
|
400
|
+
# retry re-runs it once (spec §8.4 tool-crash-recovery). This is the
|
|
401
|
+
# crash path, distinct from the unserializable-result path below
|
|
402
|
+
# which deliberately keeps the claim.
|
|
403
|
+
await self.core.release(effective_key, token)
|
|
404
|
+
raise
|
|
405
|
+
if lease_lost:
|
|
406
|
+
# The lease lapsed mid-call (renewal couldn't be confirmed) and the
|
|
407
|
+
# handler was cancelled. Surface it; a retry re-runs the tool once.
|
|
408
|
+
raise IdempotencyConflict(
|
|
409
|
+
f"idemkit: tool {self.tool_name!r} lost its lease mid-call "
|
|
410
|
+
"(renewal failed) and was cancelled. Retry — it will re-run."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
stored = self.codec.encode(value)
|
|
415
|
+
except Exception:
|
|
416
|
+
# Fail closed (§5.4): the result can't be serialized. Do NOT cache,
|
|
417
|
+
# and do NOT release the claim — releasing would let an immediate
|
|
418
|
+
# retry re-run the side effect. Surface the error so the operator
|
|
419
|
+
# fixes the codec; the lease expires on its own as the safety net.
|
|
420
|
+
_logger.error(
|
|
421
|
+
"idemkit: tool %r produced a result the codec could not serialize. "
|
|
422
|
+
"NOT caching and NOT releasing the claim, so a retry won't silently "
|
|
423
|
+
"re-run the side effect. Fix result_codec or return a serializable "
|
|
424
|
+
"value (§5.4).",
|
|
425
|
+
self.tool_name,
|
|
426
|
+
)
|
|
427
|
+
raise
|
|
428
|
+
|
|
429
|
+
await self.core.complete(effective_key, token, stored)
|
|
430
|
+
return value
|
|
431
|
+
|
|
432
|
+
# ----- key derivation -----
|
|
433
|
+
|
|
434
|
+
def _bind(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
435
|
+
bound = self.signature.bind(*args, **kwargs)
|
|
436
|
+
bound.apply_defaults()
|
|
437
|
+
return dict(bound.arguments)
|
|
438
|
+
|
|
439
|
+
def _caller_identity(self, arguments: Mapping[str, Any]) -> str:
|
|
440
|
+
if self.scope is None:
|
|
441
|
+
return _ANON_IDENTITY
|
|
442
|
+
try:
|
|
443
|
+
# Ambient identity (zero-arg, e.g. reads a contextvar) is called with
|
|
444
|
+
# no arguments; the arg-derived form receives the bound arguments.
|
|
445
|
+
value = self.scope(arguments) if self._identity_wants_args else self.scope()
|
|
446
|
+
except Exception as e:
|
|
447
|
+
# The extractor receives the bound arguments as a dict; the most
|
|
448
|
+
# common failure is indexing a key that isn't a parameter (a typo).
|
|
449
|
+
# Turn the raw KeyError/AttributeError into an actionable message.
|
|
450
|
+
raise ConfigurationError(
|
|
451
|
+
f"idemkit: scope for tool {self.tool_name!r} raised "
|
|
452
|
+
f"{type(e).__name__}: {e}. It receives the call's bound arguments "
|
|
453
|
+
f"as a dict (available keys: {list(arguments)}); select a field by "
|
|
454
|
+
"name, e.g. lambda args: args['session_id']. For ambient scope, "
|
|
455
|
+
"use a zero-arg callable that reads a contextvars.ContextVar."
|
|
456
|
+
) from e
|
|
457
|
+
if not value:
|
|
458
|
+
# A configured extractor that yields nothing is a runtime ambiguity,
|
|
459
|
+
# not a declared single-tenant choice — fail closed (§5.1).
|
|
460
|
+
raise ConfigurationError(
|
|
461
|
+
f"idemkit: scope for tool {self.tool_name!r} returned an "
|
|
462
|
+
"empty value. Return a stable, non-empty identity, or drop "
|
|
463
|
+
"scope to run single-tenant."
|
|
464
|
+
)
|
|
465
|
+
return str(value)
|
|
466
|
+
|
|
467
|
+
def _fingerprint(self, arguments: Mapping[str, Any]) -> str:
|
|
468
|
+
"""The payload fingerprint compared on a key hit (spec §5.1). With no
|
|
469
|
+
``validation_fingerprint`` it is a constant, so the core's check never
|
|
470
|
+
trips; with it, a key hit whose fingerprint bytes differ is a payload
|
|
471
|
+
mismatch (PayloadMismatch), not a wrong replay."""
|
|
472
|
+
if self.validation_fingerprint is None:
|
|
473
|
+
return AI_FINGERPRINT
|
|
474
|
+
return hashlib.sha256(self.validation_fingerprint(arguments)).hexdigest()
|
|
475
|
+
|
|
476
|
+
def _effective_key(
|
|
477
|
+
self,
|
|
478
|
+
explicit_key: Any,
|
|
479
|
+
arguments: Mapping[str, Any],
|
|
480
|
+
caller_id: str,
|
|
481
|
+
) -> str:
|
|
482
|
+
if explicit_key is not None:
|
|
483
|
+
key_component = str(explicit_key)
|
|
484
|
+
self._warn_on_volatile_explicit(key_component)
|
|
485
|
+
else:
|
|
486
|
+
key_component = self._derive_args_hash(arguments)
|
|
487
|
+
return compose_key([self.tool_name, self.version, key_component, caller_id])
|
|
488
|
+
|
|
489
|
+
def _warn_on_volatile_explicit(self, key_value: str) -> None:
|
|
490
|
+
# The single sharpest AI-surface footgun (review C-1): a provider's
|
|
491
|
+
# per-turn tool_call_id (``call_…``, ``toolu_…``, ``run_…``, ``fc_…``) or
|
|
492
|
+
# a bare UUID looks like the right "explicit key" — but OpenAI/Anthropic
|
|
493
|
+
# mint a NEW one each turn, so using it makes an honest retry MISS the
|
|
494
|
+
# dedupe and run the side effect twice. The stable key for a tool call is
|
|
495
|
+
# its arguments (key_fields), not the call id. Warn once.
|
|
496
|
+
if not self.strict_keys or self._warned_volatile_explicit:
|
|
497
|
+
return
|
|
498
|
+
if _value_looks_like_call_id(key_value):
|
|
499
|
+
self._warned_volatile_explicit = True
|
|
500
|
+
warnings.warn(
|
|
501
|
+
f"idemkit: tool {self.tool_name!r} got an explicit idempotency_key "
|
|
502
|
+
f"({key_value[:16]}…) that looks like a per-turn provider call id or "
|
|
503
|
+
"a UUID. These change on every retry/re-plan, so identical calls "
|
|
504
|
+
"WON'T dedupe and the side effect runs again. Dedupe on the call's "
|
|
505
|
+
"arguments with key_fields=[...] instead; reserve idempotency_key "
|
|
506
|
+
"for a value that is STABLE across retries of the same operation. (§8.2)",
|
|
507
|
+
stacklevel=2,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _derive_args_hash(self, arguments: Mapping[str, Any]) -> str:
|
|
511
|
+
if self.key_fields is not None:
|
|
512
|
+
# A field may be a dotted path into a nested dict or object
|
|
513
|
+
# (e.g. "order.id", "customer.address.zip"), resolved against both
|
|
514
|
+
# Mapping keys and object attributes.
|
|
515
|
+
selected: Mapping[str, Any] = {
|
|
516
|
+
field: _resolve_path(arguments, field) for field in self.key_fields
|
|
517
|
+
}
|
|
518
|
+
elif self.normalize_args is not None:
|
|
519
|
+
selected = self.normalize_args(arguments)
|
|
520
|
+
else:
|
|
521
|
+
if self.require_key:
|
|
522
|
+
# The caller opted into strict enforcement: refuse the
|
|
523
|
+
# derive-from-all-arguments fallback instead of warning.
|
|
524
|
+
raise IdempotencyKeyMissing(
|
|
525
|
+
f"idemkit: tool {self.tool_name!r} has require_key=True but no key "
|
|
526
|
+
"strategy for this call. Set key_fields=[...] (or normalize_args), "
|
|
527
|
+
"or pass an explicit idempotency_key. idemkit refuses to derive the "
|
|
528
|
+
"key from all arguments when require_key is on."
|
|
529
|
+
)
|
|
530
|
+
selected = dict(arguments)
|
|
531
|
+
self._warn_on_volatile(selected)
|
|
532
|
+
return _canonical_hash(selected)
|
|
533
|
+
|
|
534
|
+
def _warn_on_volatile(self, selected: Mapping[str, Any]) -> None:
|
|
535
|
+
if not self.strict_keys:
|
|
536
|
+
return
|
|
537
|
+
for name, value in selected.items():
|
|
538
|
+
if name in self._warned_volatile:
|
|
539
|
+
continue
|
|
540
|
+
if _looks_volatile(name, value):
|
|
541
|
+
self._warned_volatile.add(name)
|
|
542
|
+
warnings.warn(
|
|
543
|
+
f"idemkit: tool {self.tool_name!r} is deriving its key from "
|
|
544
|
+
f"all arguments, and {name!r} looks volatile (an id, "
|
|
545
|
+
"timestamp, nonce, or UUID/ISO-8601 value). A volatile field "
|
|
546
|
+
"in the key means retries with a new value DON'T dedupe. Name "
|
|
547
|
+
"the stable fields with key_fields=[...], or pass an explicit "
|
|
548
|
+
"idempotency_key. (§8.2)",
|
|
549
|
+
stacklevel=2,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _resolve_codec(spec: Any, return_annotation: Any) -> ResultCodec[Any, Any]:
|
|
554
|
+
if spec is None or spec == "json":
|
|
555
|
+
return JsonResultCodec()
|
|
556
|
+
if spec == "dataclass":
|
|
557
|
+
return DataclassResultCodec(_require_annotation(return_annotation, "dataclass"))
|
|
558
|
+
if spec == "pydantic":
|
|
559
|
+
return PydanticResultCodec(_require_annotation(return_annotation, "pydantic"))
|
|
560
|
+
if spec == "pickle":
|
|
561
|
+
return PickleResultCodec()
|
|
562
|
+
if isinstance(spec, str):
|
|
563
|
+
raise ConfigurationError(
|
|
564
|
+
f"idemkit: unknown result_codec {spec!r}. Use 'json', 'dataclass', "
|
|
565
|
+
"'pydantic', 'pickle', a (to_dict, from_dict) tuple, or a ResultCodec."
|
|
566
|
+
)
|
|
567
|
+
if isinstance(spec, tuple) and len(spec) == 2:
|
|
568
|
+
return CustomResultCodec(spec[0], spec[1])
|
|
569
|
+
# Assume a ResultCodec instance (duck-typed: has encode/decode).
|
|
570
|
+
if hasattr(spec, "encode") and hasattr(spec, "decode"):
|
|
571
|
+
return spec # type: ignore[no-any-return]
|
|
572
|
+
raise ConfigurationError(f"idemkit: result_codec {spec!r} is not a recognized codec spec.")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _resolve_return_type(fn: Callable[..., Awaitable[Any]], signature: inspect.Signature) -> Any:
|
|
576
|
+
"""The decorated function's resolved return type, for the dataclass/pydantic codecs.
|
|
577
|
+
|
|
578
|
+
With ``from __future__ import annotations`` (PEP 563) every annotation is a
|
|
579
|
+
string, so ``get_type_hints`` is needed to turn it back into the class.
|
|
580
|
+
Falls back to the raw signature annotation when resolution fails (e.g. a
|
|
581
|
+
type defined in a local scope, where the string can't be looked up) so the
|
|
582
|
+
error message in ``_require_annotation`` stays useful.
|
|
583
|
+
"""
|
|
584
|
+
try:
|
|
585
|
+
hints = typing.get_type_hints(fn)
|
|
586
|
+
except Exception:
|
|
587
|
+
return signature.return_annotation
|
|
588
|
+
return hints.get("return", signature.return_annotation)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _require_annotation(return_annotation: Any, codec_name: str) -> type:
|
|
592
|
+
if return_annotation is inspect.Signature.empty:
|
|
593
|
+
raise ConfigurationError(
|
|
594
|
+
f"idemkit: result_codec={codec_name!r} infers the type from the tool's "
|
|
595
|
+
"return annotation, but the tool has none. Add a return annotation, or "
|
|
596
|
+
"use result_codec='json' / a custom codec."
|
|
597
|
+
)
|
|
598
|
+
if isinstance(return_annotation, str):
|
|
599
|
+
# get_type_hints couldn't turn the annotation string into a class —
|
|
600
|
+
# typically a type defined in a local scope (it can't be looked up), or
|
|
601
|
+
# `from __future__ import annotations` with a type that isn't importable
|
|
602
|
+
# at module level.
|
|
603
|
+
raise ConfigurationError(
|
|
604
|
+
f"idemkit: result_codec={codec_name!r} couldn't resolve the return "
|
|
605
|
+
f"type {return_annotation!r} to a class. Define the return type at "
|
|
606
|
+
"module level (not inside a function), or pass an explicit codec — a "
|
|
607
|
+
"ResultCodec instance, or a (to_dict, from_dict) pair."
|
|
608
|
+
)
|
|
609
|
+
return return_annotation # type: ignore[no-any-return]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _resolve_path(arguments: Mapping[str, Any], path: str) -> Any:
|
|
613
|
+
"""Resolve a (possibly dotted) key-field path against the bound arguments.
|
|
614
|
+
|
|
615
|
+
``"order.id"`` walks ``arguments["order"]`` then its ``["id"]`` (on a Mapping)
|
|
616
|
+
or ``.id`` (on an object). A missing segment yields ``None`` (the same as a
|
|
617
|
+
missing top-level argument), so a typo collapses to a stable, visible value
|
|
618
|
+
rather than raising mid-call.
|
|
619
|
+
"""
|
|
620
|
+
root, _, rest = path.partition(".")
|
|
621
|
+
value: Any = arguments.get(root)
|
|
622
|
+
if not rest:
|
|
623
|
+
return value
|
|
624
|
+
for segment in rest.split("."):
|
|
625
|
+
if value is None:
|
|
626
|
+
return None
|
|
627
|
+
value = value.get(segment) if isinstance(value, Mapping) else getattr(value, segment, None)
|
|
628
|
+
return value
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _nfc(value: Any) -> Any:
|
|
632
|
+
"""Recursively NFC-normalize strings so canonically-equal text hashes equal
|
|
633
|
+
(``"café"`` typed as NFC vs NFD must dedupe). Non-strings pass through."""
|
|
634
|
+
if isinstance(value, str):
|
|
635
|
+
return unicodedata.normalize("NFC", value)
|
|
636
|
+
if isinstance(value, Mapping):
|
|
637
|
+
return {k: _nfc(v) for k, v in value.items()}
|
|
638
|
+
if isinstance(value, (list, tuple)):
|
|
639
|
+
return [_nfc(v) for v in value]
|
|
640
|
+
return value
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _canonical_hash(selected: Mapping[str, Any]) -> str:
|
|
644
|
+
"""SHA-256 of the canonical JSON of the selected key fields (§8.2).
|
|
645
|
+
|
|
646
|
+
Strings are NFC-normalized so canonically-equal text hashes equal. Numbers keep
|
|
647
|
+
JSON's own repr, so an int and an equal float (``1`` vs ``1.0``) are DIFFERENT
|
|
648
|
+
keys: keep key-field types stable across retries. A value that isn't
|
|
649
|
+
JSON-serializable fails loudly rather than producing an unstable repr-based key.
|
|
650
|
+
"""
|
|
651
|
+
try:
|
|
652
|
+
canonical = json.dumps(
|
|
653
|
+
_nfc(selected), sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
654
|
+
)
|
|
655
|
+
except (TypeError, ValueError) as e:
|
|
656
|
+
raise ConfigurationError(
|
|
657
|
+
f"idemkit: key fields are not JSON-serializable ({e}). Select "
|
|
658
|
+
"serializable fields with key_fields=[...], pass an explicit "
|
|
659
|
+
"idempotency_key, or supply a normalize_args that returns scalars."
|
|
660
|
+
) from e
|
|
661
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
_VOLATILE_NAMES = frozenset(
|
|
665
|
+
{"request_id", "requestid", "nonce", "timestamp", "ts", "idempotency_key"}
|
|
666
|
+
)
|
|
667
|
+
_UUID_RE = re.compile(
|
|
668
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
669
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
670
|
+
)
|
|
671
|
+
_ISO8601_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _accepts_one_positional(fn: Callable[..., Any] | None) -> bool:
|
|
675
|
+
"""Whether ``fn`` takes the bound-arguments mapping (arg-derived identity) vs.
|
|
676
|
+
being a zero-arg ambient callable (e.g. reads a contextvar).
|
|
677
|
+
|
|
678
|
+
Defaults to ``True`` (pass the args) when the arity can't be introspected —
|
|
679
|
+
builtins, some ``functools.partial`` objects — preserving the existing
|
|
680
|
+
arg-derived behaviour.
|
|
681
|
+
"""
|
|
682
|
+
if fn is None:
|
|
683
|
+
return True
|
|
684
|
+
try:
|
|
685
|
+
params = inspect.signature(fn).parameters.values()
|
|
686
|
+
except (TypeError, ValueError):
|
|
687
|
+
return True
|
|
688
|
+
return any(
|
|
689
|
+
p.kind
|
|
690
|
+
in (
|
|
691
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
692
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
693
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
694
|
+
)
|
|
695
|
+
for p in params
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# Provider tool-call id prefixes (OpenAI ``call_``/``fc_``, Anthropic ``toolu_``,
|
|
700
|
+
# agent frameworks ``run_``). These are minted per turn, so they are the wrong
|
|
701
|
+
# thing to use as an idempotency key — see _warn_on_volatile_explicit.
|
|
702
|
+
_CALL_ID_RE = re.compile(r"^(call_|toolu_|run_|fc_|msg_)", re.IGNORECASE)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _value_looks_like_call_id(value: str) -> bool:
|
|
706
|
+
return bool(_CALL_ID_RE.match(value) or _UUID_RE.match(value))
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _name_looks_volatile(name: str) -> bool:
|
|
710
|
+
"""Whether a field name alone (no value) reads as volatile.
|
|
711
|
+
|
|
712
|
+
Used at decoration time, where only the names in ``key_fields`` are known.
|
|
713
|
+
"""
|
|
714
|
+
lowered = name.lower()
|
|
715
|
+
if lowered in _VOLATILE_NAMES or lowered.endswith("_id"):
|
|
716
|
+
return True
|
|
717
|
+
return "timestamp" in lowered or "nonce" in lowered
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _looks_volatile(name: str, value: Any) -> bool:
|
|
721
|
+
if _name_looks_volatile(name):
|
|
722
|
+
return True
|
|
723
|
+
return isinstance(value, str) and bool(_UUID_RE.match(value) or _ISO8601_RE.match(value))
|