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
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