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/codecs.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Result codecs — the per-surface serialization seam (spec §5.4).
|
|
2
|
+
|
|
3
|
+
The core stores a result as opaque bytes (``StoredResult.blob``). A codec maps a
|
|
4
|
+
typed return value to and from those bytes. Following AWS Powertools' model, the
|
|
5
|
+
on-the-wire form is JSON and the codec records what it serialized so a replay can
|
|
6
|
+
reconstruct the typed value.
|
|
7
|
+
|
|
8
|
+
This module ships the surface-neutral codecs the queue surface needs now:
|
|
9
|
+
|
|
10
|
+
* ``SideEffectCodec`` — for handlers that return nothing. A completed record is
|
|
11
|
+
just a "processed" marker; replay means *skip*, not *return garbage* (§5.4).
|
|
12
|
+
* ``JsonResultCodec`` — JSON in, JSON out, for result-bearing handlers whose
|
|
13
|
+
return value is already JSON-friendly.
|
|
14
|
+
|
|
15
|
+
The richer typed codecs (dataclass, pydantic v1/v2, custom, and the opt-in
|
|
16
|
+
``pickle`` with its security warning) land with the AI tool surface in a later
|
|
17
|
+
phase; they plug into the same ``ResultCodec`` protocol.
|
|
18
|
+
|
|
19
|
+
Fail-closed rule (§5.4): a value that cannot be serialized MUST raise here. The
|
|
20
|
+
caller treats that as "do not cache" and re-runs rather than silently risking a
|
|
21
|
+
second side effect.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import dataclasses
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import warnings
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from typing import Any, Protocol, TypeVar
|
|
32
|
+
|
|
33
|
+
from idemkit.core.runner import StoredResult
|
|
34
|
+
|
|
35
|
+
_logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
T_contra = TypeVar("T_contra", contravariant=True)
|
|
39
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResultCodec(Protocol[T_contra, T_co]):
|
|
43
|
+
"""Encode a handler's return value to bytes and back (spec §5.4).
|
|
44
|
+
|
|
45
|
+
Split into a contravariant input type and covariant output type so a codec
|
|
46
|
+
can advertise a precise input it accepts while its decode returns the same
|
|
47
|
+
or a wider type. Most codecs use the same type for both.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def encode(self, value: T_contra) -> StoredResult:
|
|
51
|
+
"""Serialize ``value`` into a ``StoredResult``. MUST raise (fail closed)
|
|
52
|
+
if the value cannot be serialized — never return a lossy placeholder."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def decode(self, stored: StoredResult) -> T_co:
|
|
56
|
+
"""Reconstruct the value from a stored record on replay."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SideEffectCodec:
|
|
61
|
+
"""Codec for side-effect-only handlers that return ``None`` (spec §5.4).
|
|
62
|
+
|
|
63
|
+
Stores an empty marker. A completed record means "this dedup id was already
|
|
64
|
+
processed — skip it", and decoding always yields ``None`` (there is no
|
|
65
|
+
payload to replay).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def encode(self, value: Any) -> StoredResult:
|
|
69
|
+
# The value is ignored on purpose: a side-effect-only handler returns
|
|
70
|
+
# None, and there is nothing to replay. ``value`` is typed ``Any`` only
|
|
71
|
+
# so this codec is interchangeable with the result-bearing ones.
|
|
72
|
+
return StoredResult(blob=b"", meta={"codec": "side_effect"})
|
|
73
|
+
|
|
74
|
+
def decode(self, stored: StoredResult) -> None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class JsonResultCodec:
|
|
79
|
+
"""JSON-on-the-wire codec for result-bearing handlers (spec §5.4).
|
|
80
|
+
|
|
81
|
+
The return value must be JSON-serializable. If it is not, ``encode`` raises
|
|
82
|
+
(``TypeError``) and the caller fails closed rather than caching a broken
|
|
83
|
+
result. Use a typed codec (dataclass / pydantic) for richer return types.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def encode(self, value: Any) -> StoredResult:
|
|
87
|
+
try:
|
|
88
|
+
blob = json.dumps(value).encode("utf-8")
|
|
89
|
+
except (TypeError, ValueError) as e:
|
|
90
|
+
# Fail closed (§5.4): do not store a placeholder for an
|
|
91
|
+
# unserializable result, or a retry would re-run the side effect.
|
|
92
|
+
raise TypeError(
|
|
93
|
+
f"idemkit: result is not JSON-serializable ({e}). Use a typed "
|
|
94
|
+
"ResultCodec, or fix the return value. Refusing to cache so the "
|
|
95
|
+
"operation isn't silently re-run on retry."
|
|
96
|
+
) from e
|
|
97
|
+
return StoredResult(blob=blob, meta={"codec": "json"})
|
|
98
|
+
|
|
99
|
+
def decode(self, stored: StoredResult) -> Any:
|
|
100
|
+
if not stored.blob:
|
|
101
|
+
return None
|
|
102
|
+
return json.loads(stored.blob)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DataclassResultCodec:
|
|
106
|
+
"""Typed codec for a dataclass return type (spec §5.4).
|
|
107
|
+
|
|
108
|
+
JSON on the wire; the dataclass type is inferred from the tool's return
|
|
109
|
+
annotation. ``encode`` flattens to a dict via ``dataclasses.asdict``;
|
|
110
|
+
``decode`` rebuilds the instance via ``cls(**data)``. Nested dataclasses
|
|
111
|
+
round-trip through ``asdict`` but rebuild as plain dicts unless the type's
|
|
112
|
+
own ``__init__`` reconstructs them — keep return types flat, or supply a
|
|
113
|
+
custom codec for nested shapes.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, cls: type) -> None:
|
|
117
|
+
if not dataclasses.is_dataclass(cls):
|
|
118
|
+
raise TypeError(
|
|
119
|
+
f"idemkit: result_codec='dataclass' needs a dataclass return "
|
|
120
|
+
f"annotation; {cls!r} is not a dataclass."
|
|
121
|
+
)
|
|
122
|
+
self._cls = cls
|
|
123
|
+
|
|
124
|
+
def encode(self, value: Any) -> StoredResult:
|
|
125
|
+
if not dataclasses.is_dataclass(value) or isinstance(value, type):
|
|
126
|
+
raise TypeError(
|
|
127
|
+
f"idemkit: expected a {self._cls.__name__} instance, got "
|
|
128
|
+
f"{type(value).__name__}. Refusing to cache (fail closed, §5.4)."
|
|
129
|
+
)
|
|
130
|
+
blob = json.dumps(dataclasses.asdict(value)).encode("utf-8")
|
|
131
|
+
return StoredResult(blob=blob, meta={"codec": "dataclass"})
|
|
132
|
+
|
|
133
|
+
def decode(self, stored: StoredResult) -> Any:
|
|
134
|
+
data = json.loads(stored.blob) if stored.blob else {}
|
|
135
|
+
return self._cls(**data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PydanticResultCodec:
|
|
139
|
+
"""Typed codec for a pydantic model return type, v1 and v2 (spec §5.4).
|
|
140
|
+
|
|
141
|
+
JSON on the wire. Works against either pydantic generation by feature-probing
|
|
142
|
+
the model class (``model_validate``/``model_dump`` on v2,
|
|
143
|
+
``parse_obj``/``dict`` on v1) rather than importing pydantic, so idemkit
|
|
144
|
+
keeps its zero-dependency core.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, model: type) -> None:
|
|
148
|
+
# Probe for the v2 then v1 API so we fail loudly at decoration time, not
|
|
149
|
+
# mid-call, if the annotation isn't a pydantic model.
|
|
150
|
+
if not (hasattr(model, "model_validate") or hasattr(model, "parse_obj")):
|
|
151
|
+
raise TypeError(
|
|
152
|
+
f"idemkit: result_codec='pydantic' needs a pydantic model return "
|
|
153
|
+
f"annotation; {model!r} has neither model_validate (v2) nor "
|
|
154
|
+
"parse_obj (v1)."
|
|
155
|
+
)
|
|
156
|
+
# Typed Any: we feature-probe the v1/v2 API rather than depend on pydantic.
|
|
157
|
+
self._model: Any = model
|
|
158
|
+
|
|
159
|
+
def encode(self, value: Any) -> StoredResult:
|
|
160
|
+
if hasattr(value, "model_dump"):
|
|
161
|
+
data = value.model_dump(mode="json") # pydantic v2
|
|
162
|
+
elif hasattr(value, "dict"):
|
|
163
|
+
data = value.dict() # pydantic v1
|
|
164
|
+
else:
|
|
165
|
+
raise TypeError(
|
|
166
|
+
f"idemkit: expected a pydantic model instance, got "
|
|
167
|
+
f"{type(value).__name__}. Refusing to cache (fail closed, §5.4)."
|
|
168
|
+
)
|
|
169
|
+
return StoredResult(blob=json.dumps(data).encode("utf-8"), meta={"codec": "pydantic"})
|
|
170
|
+
|
|
171
|
+
def decode(self, stored: StoredResult) -> Any:
|
|
172
|
+
data = json.loads(stored.blob) if stored.blob else {}
|
|
173
|
+
if hasattr(self._model, "model_validate"):
|
|
174
|
+
return self._model.model_validate(data) # v2
|
|
175
|
+
return self._model.parse_obj(data) # v1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CustomResultCodec:
|
|
179
|
+
"""Codec from a ``(to_dict, from_dict)`` pair (spec §5.4).
|
|
180
|
+
|
|
181
|
+
The escape hatch for return types the built-in codecs don't cover. JSON on
|
|
182
|
+
the wire; ``to_dict`` must return something ``json.dumps`` accepts.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
to_dict: Callable[[Any], Any],
|
|
188
|
+
from_dict: Callable[[Any], Any],
|
|
189
|
+
) -> None:
|
|
190
|
+
self._to_dict = to_dict
|
|
191
|
+
self._from_dict = from_dict
|
|
192
|
+
|
|
193
|
+
def encode(self, value: Any) -> StoredResult:
|
|
194
|
+
blob = json.dumps(self._to_dict(value)).encode("utf-8")
|
|
195
|
+
return StoredResult(blob=blob, meta={"codec": "custom"})
|
|
196
|
+
|
|
197
|
+
def decode(self, stored: StoredResult) -> Any:
|
|
198
|
+
data = json.loads(stored.blob) if stored.blob else None
|
|
199
|
+
return self._from_dict(data)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class PickleResultCodec:
|
|
203
|
+
"""Pickle codec — OPT-IN, and a remote-code-execution risk (spec §5.4, §12).
|
|
204
|
+
|
|
205
|
+
Constructing one emits a security warning, because a stored pickle that an
|
|
206
|
+
attacker can influence is an RCE vector on ``decode``. Use a JSON or typed
|
|
207
|
+
codec unless you fully control and trust the backend contents. Provided so
|
|
208
|
+
arbitrary return types can be cached when the operator accepts the risk.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(self) -> None:
|
|
212
|
+
warnings.warn(
|
|
213
|
+
"idemkit: the pickle result codec is a remote-code-execution risk — "
|
|
214
|
+
"a stored pickle is executed on decode. Only use it if you fully "
|
|
215
|
+
"control and trust the backend's contents; prefer JSON or a typed "
|
|
216
|
+
"codec otherwise (spec §5.4, §12).",
|
|
217
|
+
stacklevel=2,
|
|
218
|
+
)
|
|
219
|
+
_logger.warning("idemkit: pickle result codec enabled (RCE risk on decode, §5.4).")
|
|
220
|
+
|
|
221
|
+
def encode(self, value: Any) -> StoredResult:
|
|
222
|
+
import pickle
|
|
223
|
+
|
|
224
|
+
return StoredResult(blob=pickle.dumps(value), meta={"codec": "pickle"})
|
|
225
|
+
|
|
226
|
+
def decode(self, stored: StoredResult) -> Any:
|
|
227
|
+
import pickle
|
|
228
|
+
|
|
229
|
+
return pickle.loads(stored.blob)
|
idemkit/core/config.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Public configuration for idemkit middleware, per spec §4.2 / §4.6 / §4.8 / §4.16."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from idemkit.core.events import EventHandler
|
|
11
|
+
from idemkit.core.exceptions import ConfigurationError
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Type aliases for the pluggable hooks (§4.7, §4.14).
|
|
17
|
+
# Adapters pass framework-specific request types here.
|
|
18
|
+
CallerIdentityExtractor = Callable[[Any], str]
|
|
19
|
+
KeyExtractor = Callable[[Any], "str | None"]
|
|
20
|
+
ResponseRedactor = Callable[[bytes, dict[str, str], int], "tuple[bytes, dict[str, str]]"]
|
|
21
|
+
# (replay body, replay headers, status) -> the (body, headers) to send to the
|
|
22
|
+
# DUPLICATE. Runs ONLY when replaying a stored response, so you can add a header,
|
|
23
|
+
# tweak Cache-Control, and so on. Never runs on the first (live) request.
|
|
24
|
+
ResponseHook = Callable[[bytes, dict[str, str], int], "tuple[bytes, dict[str, str]]"]
|
|
25
|
+
# (raw request body, content-type) -> the bytes to fingerprint. Lets you select
|
|
26
|
+
# which body fields matter (drop volatile timestamps/nonces). Returns the
|
|
27
|
+
# canonical bytes idemkit hashes; return b"" to ignore the body entirely (§4.5).
|
|
28
|
+
BodyFingerprint = Callable[[bytes, "str | None"], bytes]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_HEADER_ALLOW: frozenset[str] = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"content-type",
|
|
34
|
+
"content-encoding",
|
|
35
|
+
"content-language",
|
|
36
|
+
"content-disposition",
|
|
37
|
+
"location",
|
|
38
|
+
"etag",
|
|
39
|
+
"last-modified",
|
|
40
|
+
"link",
|
|
41
|
+
"cache-control",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DEFAULT_HEADER_DENY: frozenset[str] = frozenset(
|
|
46
|
+
{
|
|
47
|
+
# Privacy / auth sensitive
|
|
48
|
+
"set-cookie",
|
|
49
|
+
"authorization",
|
|
50
|
+
"www-authenticate",
|
|
51
|
+
# Vary (idemkit is not Vary-aware on replay)
|
|
52
|
+
"vary",
|
|
53
|
+
# Hop-by-hop per RFC 7230 §6.1
|
|
54
|
+
"connection",
|
|
55
|
+
"keep-alive",
|
|
56
|
+
"proxy-authenticate",
|
|
57
|
+
"proxy-authorization",
|
|
58
|
+
"te",
|
|
59
|
+
"trailers",
|
|
60
|
+
"transfer-encoding",
|
|
61
|
+
"upgrade",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class IdempotencyConfig:
|
|
68
|
+
"""Internal settings container for the HTTP middleware/decorator.
|
|
69
|
+
|
|
70
|
+
Built from the flat keyword arguments passed to ``IdempotencyMiddleware`` /
|
|
71
|
+
``Idempotency``; users configure those surfaces with plain keywords and do not
|
|
72
|
+
construct this object directly. Holds the core policy (TTLs, lease, storage-error
|
|
73
|
+
policy, local cache, observability) plus the HTTP-specific options.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# ── Core policy ──
|
|
77
|
+
lease_ttl_seconds: float = 30.0
|
|
78
|
+
wait_timeout_seconds: float = 10.0
|
|
79
|
+
expires_after_seconds: float = 86_400.0
|
|
80
|
+
on_storage_error: Literal["fail_closed", "fail_open"] = "fail_closed"
|
|
81
|
+
use_local_cache: bool = False
|
|
82
|
+
local_cache_max_items: int = 1024
|
|
83
|
+
event_handlers: list[EventHandler] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
# ── HTTP-specific ──
|
|
86
|
+
|
|
87
|
+
# Methods on which idempotency applies (§4.2 method scope)
|
|
88
|
+
applicable_methods: set[str] = field(default_factory=lambda: {"POST", "PATCH"})
|
|
89
|
+
|
|
90
|
+
# Response body size cap (§4.9)
|
|
91
|
+
max_body_bytes: int = 1024 * 1024 # 1 MiB
|
|
92
|
+
|
|
93
|
+
# Request body buffering cap. The middleware buffers the request body in
|
|
94
|
+
# memory to fingerprint it; this bounds that buffer to prevent a
|
|
95
|
+
# large-request memory DoS. A request whose body exceeds this is streamed
|
|
96
|
+
# to the handler WITHOUT idempotency (it is not deduplicated). Set to
|
|
97
|
+
# None to disable the cap (unbounded buffering — not recommended).
|
|
98
|
+
max_request_body_bytes: int | None = 1024 * 1024 # 1 MiB
|
|
99
|
+
|
|
100
|
+
# Cacheable status policy (§4.2): 5xx never cached by default
|
|
101
|
+
cacheable_status: set[int] = field(default_factory=lambda: {200, 201, 202})
|
|
102
|
+
|
|
103
|
+
# Wire-compatibility mode (§6.3.1).
|
|
104
|
+
# "default" — 422 on payload mismatch, 423 on in-flight timeout, and the
|
|
105
|
+
# Idempotency-Replayed header.
|
|
106
|
+
# "stripe" — 409 for both, and the Idempotent-Replayed header (Stripe's
|
|
107
|
+
# deployed spelling), for drop-in compatibility with clients
|
|
108
|
+
# written against Stripe's idempotency format.
|
|
109
|
+
compat_mode: Literal["default", "stripe"] = "default"
|
|
110
|
+
|
|
111
|
+
# Required-key enforcement (§6.1)
|
|
112
|
+
require_key_for_mutations: bool = False
|
|
113
|
+
|
|
114
|
+
# Request-body fingerprint (§4.5). The body is part of the FINGERPRINT (not the
|
|
115
|
+
# idempotency key): reusing a key with a different body is detected and rejected
|
|
116
|
+
# (422/409), so the wrong stored response is never replayed.
|
|
117
|
+
# None (default) — fingerprint the whole body.
|
|
118
|
+
# a callback ``body_fingerprint(raw_body, content_type) -> bytes`` —
|
|
119
|
+
# fingerprint only what it returns, so you keep the fields that define the
|
|
120
|
+
# operation and ignore volatile ones (a client timestamp, a nonce). Return
|
|
121
|
+
# canonical bytes (e.g. ``json.dumps({...}, sort_keys=True).encode()``), or
|
|
122
|
+
# ``b""`` to ignore the body entirely (dedupe on key + caller + method + path).
|
|
123
|
+
body_fingerprint: BodyFingerprint | None = None
|
|
124
|
+
|
|
125
|
+
# Pluggable extraction (§4.7) and cross-tenant scoping (§4.6).
|
|
126
|
+
# With no `scope`, idemkit runs in SINGLE-TENANT mode (all callers share one
|
|
127
|
+
# namespace). scope_mode picks what happens then:
|
|
128
|
+
# "warn" (default) run single-tenant and log a loud warning;
|
|
129
|
+
# "single_tenant" run single-tenant, no warning (you acknowledged it);
|
|
130
|
+
# "strict" raise ConfigurationError if no scope (CI/production gate).
|
|
131
|
+
scope: CallerIdentityExtractor | None = None
|
|
132
|
+
scope_mode: Literal["warn", "single_tenant", "strict"] = "warn"
|
|
133
|
+
key: KeyExtractor | None = None
|
|
134
|
+
|
|
135
|
+
# PII redaction (§4.14)
|
|
136
|
+
response_redactor: ResponseRedactor | None = None
|
|
137
|
+
|
|
138
|
+
# Replay response hook: modify the response served to a DUPLICATE (runs only
|
|
139
|
+
# on replay, never on the first request).
|
|
140
|
+
response_hook: ResponseHook | None = None
|
|
141
|
+
|
|
142
|
+
# Header allow/deny lists (§4.10) — None falls back to module defaults
|
|
143
|
+
header_allow: set[str] | None = None
|
|
144
|
+
header_deny: set[str] | None = None
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
# Normalize methods
|
|
148
|
+
self.applicable_methods = {m.upper() for m in self.applicable_methods}
|
|
149
|
+
|
|
150
|
+
if self.compat_mode not in ("default", "stripe"):
|
|
151
|
+
raise ConfigurationError(
|
|
152
|
+
f"idemkit: compat_mode must be 'default' or 'stripe', got {self.compat_mode!r}."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if self.scope_mode not in ("warn", "single_tenant", "strict"):
|
|
156
|
+
raise ConfigurationError(
|
|
157
|
+
f"idemkit: scope_mode must be 'warn', 'single_tenant', or "
|
|
158
|
+
f"'strict', got {self.scope_mode!r}."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Cross-tenant scoping (§4.6). Default: single-tenant + loud warning.
|
|
162
|
+
if self.scope is None:
|
|
163
|
+
if self.scope_mode == "strict":
|
|
164
|
+
raise ConfigurationError(
|
|
165
|
+
"idemkit: scope_mode='strict' requires a `scope` extractor. "
|
|
166
|
+
"Provide one, or use scope_mode='single_tenant' to run "
|
|
167
|
+
"single-tenant on purpose. See spec §4.6."
|
|
168
|
+
)
|
|
169
|
+
if self.scope_mode == "warn":
|
|
170
|
+
_logger.warning(
|
|
171
|
+
"idemkit: running single-tenant — no `scope` is set, so all "
|
|
172
|
+
"callers share one idempotency namespace. That's fine for local "
|
|
173
|
+
"dev and single-tenant services; if you have more than one user "
|
|
174
|
+
"or tenant, set `scope` so they don't collide. Silence this with "
|
|
175
|
+
"scope_mode='single_tenant', or make it a hard error with "
|
|
176
|
+
"scope_mode='strict'. See spec §4.6."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Lease sanity warning (§4.8)
|
|
180
|
+
if self.lease_ttl_seconds < 5.0:
|
|
181
|
+
_logger.warning(
|
|
182
|
+
"idemkit: lease_ttl_seconds=%.2f is very low; if your handler p99 "
|
|
183
|
+
"exceeds this, claims will expire mid-handler and adverse reclaim "
|
|
184
|
+
"races may occur. See spec §4.8.",
|
|
185
|
+
self.lease_ttl_seconds,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def is_stripe_compat(self) -> bool:
|
|
190
|
+
"""True when the Stripe wire-compatibility mode is active (§6.3.1)."""
|
|
191
|
+
return self.compat_mode == "stripe"
|
|
192
|
+
|
|
193
|
+
def effective_header_allow(self) -> frozenset[str]:
|
|
194
|
+
if self.header_allow:
|
|
195
|
+
return frozenset(h.lower() for h in self.header_allow)
|
|
196
|
+
return DEFAULT_HEADER_ALLOW
|
|
197
|
+
|
|
198
|
+
def effective_header_deny(self) -> frozenset[str]:
|
|
199
|
+
if self.header_deny:
|
|
200
|
+
return frozenset(h.lower() for h in self.header_deny)
|
|
201
|
+
return DEFAULT_HEADER_DENY
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_http_config(config: Any) -> IdempotencyConfig:
|
|
205
|
+
"""Build the HTTP config from an ``HttpConfig`` (flat, widened with the HTTP
|
|
206
|
+
defaults) or the internal ``IdempotencyConfig``.
|
|
207
|
+
"""
|
|
208
|
+
from idemkit.core.policy import HttpConfig
|
|
209
|
+
|
|
210
|
+
if config is None:
|
|
211
|
+
return IdempotencyConfig()
|
|
212
|
+
|
|
213
|
+
if isinstance(config, HttpConfig):
|
|
214
|
+
# A flat HttpConfig. lease/wait default to None ("use the HTTP default");
|
|
215
|
+
# other None fields fall back to the IdempotencyConfig defaults (not passed).
|
|
216
|
+
data: dict[str, Any] = {
|
|
217
|
+
"lease_ttl_seconds": (
|
|
218
|
+
config.lease_ttl_seconds if config.lease_ttl_seconds is not None else 30.0
|
|
219
|
+
),
|
|
220
|
+
"wait_timeout_seconds": (
|
|
221
|
+
config.wait_timeout_seconds if config.wait_timeout_seconds is not None else 10.0
|
|
222
|
+
),
|
|
223
|
+
"expires_after_seconds": config.expires_after_seconds,
|
|
224
|
+
"on_storage_error": config.on_storage_error,
|
|
225
|
+
"use_local_cache": config.use_local_cache,
|
|
226
|
+
"local_cache_max_items": config.local_cache_max_items,
|
|
227
|
+
"event_handlers": list(config.event_handlers),
|
|
228
|
+
"scope": config.scope,
|
|
229
|
+
"scope_mode": config.scope_mode,
|
|
230
|
+
"key": config.key,
|
|
231
|
+
"require_key_for_mutations": config.require_key_for_mutations,
|
|
232
|
+
"body_fingerprint": config.body_fingerprint,
|
|
233
|
+
"response_redactor": config.response_redactor,
|
|
234
|
+
"response_hook": config.response_hook,
|
|
235
|
+
"compat_mode": config.compat_mode,
|
|
236
|
+
}
|
|
237
|
+
for f in (
|
|
238
|
+
"applicable_methods",
|
|
239
|
+
"header_allow",
|
|
240
|
+
"header_deny",
|
|
241
|
+
"cacheable_status",
|
|
242
|
+
"max_body_bytes",
|
|
243
|
+
"max_request_body_bytes",
|
|
244
|
+
):
|
|
245
|
+
v = getattr(config, f)
|
|
246
|
+
if v is not None:
|
|
247
|
+
data[f] = v
|
|
248
|
+
return IdempotencyConfig(**data)
|
|
249
|
+
|
|
250
|
+
if not isinstance(config, IdempotencyConfig):
|
|
251
|
+
raise TypeError(
|
|
252
|
+
"idemkit: config= must be an HttpConfig or IdempotencyConfig, "
|
|
253
|
+
f"got {type(config).__name__}."
|
|
254
|
+
)
|
|
255
|
+
return config
|