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,446 @@
1
+ Metadata-Version: 2.4
2
+ Name: idemkit
3
+ Version: 0.1.0
4
+ Summary: Make any operation safe to retry: crash-safe idempotency for HTTP requests, queue messages, and function calls, on Redis, Postgres, MongoDB, or DynamoDB.
5
+ Project-URL: Homepage, https://github.com/idemkit/idemkit
6
+ Project-URL: Documentation, https://github.com/idemkit/idemkit/blob/main/python/README.md
7
+ Project-URL: Specification, https://github.com/idemkit/idemkit/blob/main/spec/idemkit-unified-spec.md
8
+ Project-URL: Changelog, https://github.com/idemkit/idemkit/blob/main/python/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/idemkit/idemkit/issues
10
+ Author: Violetta Pidvolotska
11
+ License-Expression: Apache-2.0
12
+ Keywords: agent,asgi,concurrency,consumer,crash-recovery,dedup,deduplication,effectively-once,exactly-once,fastapi,http,idempotency,idempotency-key,idempotent,llm,middleware,postgres,queue,race-safe,redis,retry,retry-safe,starlette,tool-calls,webhook
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: asgi
27
+ Requires-Dist: starlette>=0.30; extra == 'asgi'
28
+ Provides-Extra: dev
29
+ Requires-Dist: aioboto3>=12.0; extra == 'dev'
30
+ Requires-Dist: anyio>=4.0; extra == 'dev'
31
+ Requires-Dist: asyncpg>=0.29; extra == 'dev'
32
+ Requires-Dist: django>=4.2; extra == 'dev'
33
+ Requires-Dist: djangorestframework>=3.14; extra == 'dev'
34
+ Requires-Dist: fakeredis[lua]>=2.20; extra == 'dev'
35
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
36
+ Requires-Dist: httpx>=0.27; extra == 'dev'
37
+ Requires-Dist: hypothesis>=6.0; extra == 'dev'
38
+ Requires-Dist: mypy>=1.10; extra == 'dev'
39
+ Requires-Dist: prometheus-client>=0.19; extra == 'dev'
40
+ Requires-Dist: pydantic>=2.0; extra == 'dev'
41
+ Requires-Dist: pymongo>=4.9; extra == 'dev'
42
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
43
+ Requires-Dist: pytest>=8.0; extra == 'dev'
44
+ Requires-Dist: redis>=5.0; extra == 'dev'
45
+ Requires-Dist: ruff>=0.4; extra == 'dev'
46
+ Requires-Dist: starlette>=0.30; extra == 'dev'
47
+ Provides-Extra: dynamodb
48
+ Requires-Dist: aioboto3>=12.0; extra == 'dynamodb'
49
+ Provides-Extra: e2e
50
+ Requires-Dist: boto3>=1.34; extra == 'e2e'
51
+ Requires-Dist: kafka-python>=2.0; extra == 'e2e'
52
+ Requires-Dist: pika>=1.3; extra == 'e2e'
53
+ Provides-Extra: mongo
54
+ Requires-Dist: pymongo>=4.9; extra == 'mongo'
55
+ Provides-Extra: postgres
56
+ Requires-Dist: asyncpg>=0.29; extra == 'postgres'
57
+ Provides-Extra: prometheus
58
+ Requires-Dist: prometheus-client>=0.19; extra == 'prometheus'
59
+ Provides-Extra: redis
60
+ Requires-Dist: redis>=5.0; extra == 'redis'
61
+ Description-Content-Type: text/markdown
62
+
63
+ # idemkit for Python
64
+
65
+ [![CI](https://github.com/idemkit/idemkit/actions/workflows/ci.yml/badge.svg)](https://github.com/idemkit/idemkit/actions/workflows/ci.yml)
66
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](../LICENSE)
67
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org)
68
+
69
+ Make any operation safe to retry. When a client retries, a broker redelivers, or an agent re-plans, idemkit runs your code **once per key** and replays the first result to the duplicates, even when they arrive at the same instant.
70
+
71
+ One core covers the three places a retry turns into a duplicate: **HTTP requests**, **queue messages**, and **plain function calls** (agents, jobs, internal calls).
72
+
73
+ Why a key on its own is not enough is the subject of [Why an idempotency key isn't an idempotency guarantee](https://www.infoworld.com/article/4191741/why-an-idempotency-key-isnt-an-idempotency-guarantee.html). The article names four assumptions a correct design has to hold; [The four assumptions, in code](docs/the-four-assumptions.md) maps each one to the exact place idemkit implements it.
74
+
75
+ > 🚧 **Pre-release (v0.1).** The API may still shift before 1.0. All three surfaces pass the same correctness suite on all five backends (see below); HTTP has the most production mileage so far.
76
+
77
+ ## What you get
78
+
79
+ New to the vocabulary (*lease*, *fencing token*, *scope*, *fingerprint*)? Each links to the [Glossary](#glossary).
80
+
81
+ - **One atomic claim, not check-then-set.** Two duplicates racing at the same instant: exactly one wins the claim and runs, the rest replay its result. No read-then-write gap where both see "not done" and both run.
82
+ - **Crash-safe.** A worker that dies mid-operation can't block the key or double-execute. Its claim expires on a [lease](#glossary), and a [fencing token](#glossary) makes its late write get rejected instead of overwriting the real result.
83
+ - **Duplicates wait and replay.** A concurrent retry waits for the in-flight call to finish and gets its result, instead of an immediate conflict error.
84
+ - **Cross-tenant safe.** A [`scope`](#glossary) isolates callers, so one user never sees another's stored response.
85
+ - **async-native, zero-dependency core.** Each backend is one opt-in extra. Nothing is imported until you ask.
86
+ - **Every guarantee has a test.** The same [conformance vectors](#conformance) run on all five backends — in-memory, Redis, Postgres, Mongo, DynamoDB (Dynamo skips only the clock-skew one). What isn't covered yet — a multi-node partition sim, a formal model — is written down too, not hidden.
87
+
88
+ ## Install
89
+
90
+ Not on PyPI yet — install from source:
91
+
92
+ ```bash
93
+ git clone https://github.com/idemkit/idemkit && cd idemkit/python
94
+ pip install -e ".[asgi,redis]" # pick the extras you need
95
+ ```
96
+
97
+ Once published, this becomes `pip install "idemkit[redis]"` (or `[postgres]`, `[mongo]`, `[dynamodb]`, `[asgi]`).
98
+
99
+ The core has no third-party dependencies. Runs on Python 3.10 to 3.13, Redis 6+/Cluster, PostgreSQL 12+, MongoDB 4.2+, DynamoDB, any ASGI 3 or WSGI app.
100
+
101
+ ## Pick your surface
102
+
103
+ | Your duplicate is | Deduped on | You add | Jump to |
104
+ |---|---|---|---|
105
+ | a client retrying `POST`/`PATCH` | the `Idempotency-Key` header | middleware (one line) | [HTTP](#http) |
106
+ | a broker redelivering a message | the broker's message id | `IdempotentConsumer` | [Queue](#queue-consumers) |
107
+ | a function called again (agent, job, internal call) | the function's arguments | the `@idempotent` decorator | [Method calls](#method-calls-and-ai-tools) |
108
+
109
+ ---
110
+
111
+ ## HTTP
112
+
113
+ A client sends `Idempotency-Key: abc-123` on a `POST`. If it retries with the same key, idemkit replays the first response instead of running your handler again.
114
+
115
+ The middleware wraps the whole app, but by default it only acts on `POST` and `PATCH`, the methods where a retry causes a duplicate. `GET`, `PUT`, `DELETE`, and the rest pass straight through untouched. Change that with `applicable_methods`.
116
+
117
+ Works in 30 seconds, no infrastructure (the `[asgi]` extra ships Starlette; this snippet also uses FastAPI):
118
+
119
+ ```bash
120
+ pip install -e ".[asgi]" fastapi uvicorn
121
+ ```
122
+
123
+ ```python
124
+ from fastapi import FastAPI
125
+ from idemkit import IdempotencyMiddleware, InMemoryBackend
126
+
127
+ app = FastAPI()
128
+ app.add_middleware(IdempotencyMiddleware, backend=InMemoryBackend())
129
+
130
+ @app.post("/charge")
131
+ async def charge():
132
+ return {"charged": True} # runs once per Idempotency-Key; retries replay it
133
+ ```
134
+
135
+ ```bash
136
+ curl -X POST localhost:8000/charge -H "Idempotency-Key: k1" -H "Content-Type: application/json" -d '{}'
137
+ # run it again: same response, plus header idempotency-replayed: true (the handler did not run)
138
+ ```
139
+
140
+ On startup this logs a one-time `single-tenant mode` warning: with no `scope`, all callers share one namespace. That's fine for this local demo — you add `scope` when you go multi-tenant, next.
141
+
142
+ For production, swap the backend and add `scope`:
143
+
144
+ ```python
145
+ from idemkit import HttpConfig, IdempotencyMiddleware, RedisBackend
146
+
147
+ app.add_middleware(
148
+ IdempotencyMiddleware,
149
+ backend=RedisBackend.from_url("redis://localhost:6379"),
150
+ config=HttpConfig(scope=lambda req: req.headers.get("x-user-id", "anonymous")), # isolate tenants
151
+ )
152
+ ```
153
+
154
+ Without `scope`, idemkit runs single-tenant and warns once. Set `scope_mode="strict"` to turn a missing id into a hard error in CI.
155
+
156
+ > In the middleware, `scope` and `key` receive a lightweight proxy, not a full `Request`. Read identity from `req.headers` or `req.scope`, not `req.state`. If you need `request.state` or typed exceptions, use the per-route decorator below, which gets the real `Request`.
157
+
158
+ The middleware returns `application/problem+json` on the unhappy paths. Branch on the `type` URI, not the status:
159
+
160
+ | Outcome | Status | `type` URI | Client should |
161
+ |---|---|---|---|
162
+ | replay | original status + `idempotency-replayed: true` | — | use it |
163
+ | same key, different body | `422` | `urn:idemkit:payload-mismatch` | resend the original body, or use a new key |
164
+ | another request with this key is in flight | `423` | `urn:idemkit:in-progress` | retry after `Retry-After` |
165
+ | storage down (fail-closed) | `503` | `urn:idemkit:storage-error` | retry after `Retry-After` |
166
+ | key missing (with `require_key_for_mutations`) or too long | `400` | `urn:idemkit:missing-key` | fix the request |
167
+
168
+ **Other ways to wire HTTP.** Not sure which? Pick by your framework and how much you want to wrap — all take the same `HttpConfig`:
169
+
170
+ | You're on… | You want… | Use | Example |
171
+ |---|---|---|---|
172
+ | FastAPI / Starlette / any ASGI | the whole app | `IdempotencyMiddleware` (shown above) | [fastapi_middleware.py](examples/http/fastapi_middleware.py) |
173
+ | Flask / Django / any WSGI (sync) | the whole app | `WSGIIdempotencyMiddleware` | [flask_wsgi.py](examples/http/flask_wsgi.py), [django_wsgi.py](examples/http/django_wsgi.py) |
174
+ | FastAPI | one route that returns a `dict` and reads `request.state` | `contrib.fastapi.idempotent_route` (route class) | [fastapi_route.py](examples/http/fastapi_route.py) |
175
+ | any ASGI | one route, and to catch typed exceptions (e.g. a webhook) | `Idempotency(...).protect` (decorator) | [route_decorator.py](examples/http/route_decorator.py) |
176
+ | Django REST Framework | one view, scoped per authenticated user (a header in the standalone demo) | `contrib.drf.idempotent_view` (mixin) | [drf_view.py](examples/http/drf_view.py) |
177
+
178
+ Rule of thumb: **whole app → middleware; one route → the route class (FastAPI) or `.protect` (any ASGI).** The full middleware walkthrough with scope, `body_fingerprint`, and a redactor is in [fastapi_middleware.py](examples/http/fastapi_middleware.py).
179
+
180
+ You rarely set past `scope`; the rest (`body_fingerprint`, `response_redactor`, `cacheable_status`, ...) is in **[docs/configuration.md → HTTP options](docs/configuration.md#http-options)**.
181
+
182
+ ---
183
+
184
+ ## Queue consumers
185
+
186
+ At-least-once brokers redeliver the same message by design. `IdempotentConsumer` wraps your handler so its side effect runs once per dedup id, even under redelivery, concurrent consumers, and crashes. You read the broker's id and visibility timeout, and it tells you to ack or redeliver.
187
+
188
+ ```python
189
+ from idemkit import IdempotentConsumer, ConsumerAction, InMemoryBackend, QueueConfig
190
+
191
+ consumer = IdempotentConsumer(
192
+ backend=InMemoryBackend(),
193
+ config=QueueConfig(
194
+ dedup_id=lambda msg: msg.message_id, # however YOUR broker exposes the id
195
+ visibility_timeout_seconds=30, # the lease derives from this, kept shorter
196
+ ),
197
+ )
198
+
199
+ @consumer.handle
200
+ async def process(msg) -> None:
201
+ await charge_customer(msg.body) # process each message once, even on redelivery
202
+
203
+ # in your poll loop:
204
+ result = await consumer.dispatch(msg)
205
+ broker.ack(msg) if result.action is ConsumerAction.ACK else broker.nack(msg)
206
+ ```
207
+
208
+ A sync worker (a threaded SQS/Kafka consumer) calls `consumer.dispatch_sync(msg)` instead. See [`examples/queue/getting_started.py`](examples/queue/getting_started.py), [`generic_broker.py`](examples/queue/generic_broker.py) (RabbitMQ/NATS), [`dead_letter.py`](examples/queue/dead_letter.py), and [`cache_result.py`](examples/queue/cache_result.py). A **Celery task is a function call, not a queue consumer** — use `@idempotent_sync` under `@app.task` ([Method calls](#method-calls-and-ai-tools)).
209
+
210
+ > **Size the visibility timeout above your handler's runtime.** The lease derives from `visibility_timeout_seconds`, and every at-least-once broker makes a message visible again if you don't ack within its window. If a handler — or a retry backoff, or a Celery `eta`/`countdown` — runs longer than the visibility timeout, the broker hands the same message to a *second* consumer while the first is still running. That is the usual source of "my task ran twice" (SQS default 30s, Celery Redis 1h). Set the visibility timeout above your handler's p99, keep any delay/backoff shorter than it, and — since the dedup record lives for `expires_after_seconds` — keep that window longer than the broker's whole redelivery horizon. idemkit still fences the late finisher, but you avoid the needless second run.
211
+
212
+ **What `dispatch` does with a duplicate**, so you know what to ack:
213
+
214
+ | The message is | `result.action` | The handler | `result.result` |
215
+ |---|---|---|---|
216
+ | new | `ACK` | runs | its return value |
217
+ | a redelivery of a **completed** one | `ACK` | does not run | replayed (if `cache_result`) |
218
+ | a **concurrent** duplicate, first still running | `RETRY` | does not run | none (redeliver; it replays once the first finishes) |
219
+ | a handler that raised, under `max_attempts` | `RETRY` | ran and failed | none (redeliver) |
220
+ | a handler that raised, at `max_attempts` | `ACK` (`exhausted`) | given up on | none (`on_exhausted` fired) |
221
+
222
+ `ACK` means remove it from the broker; `RETRY` means leave it for redelivery. A handler that returns `None` records a "processed" marker, so its redelivery is a no-op skip.
223
+
224
+ **Your broker comes wired.** `idemkit.contrib` presets the dedup id, attempt count, and ack glue for SQS, Kafka, RabbitMQ, and Google Pub/Sub so you don't hand-roll it:
225
+
226
+ ```python
227
+ import boto3
228
+ from idemkit import RedisBackend
229
+ from idemkit import QueueConfig
230
+ from idemkit.contrib.sqs import sqs_consumer, run_forever
231
+
232
+ sqs = boto3.client("sqs")
233
+ consumer = sqs_consumer(
234
+ backend=RedisBackend.from_url("redis://..."),
235
+ visibility_timeout_seconds=30,
236
+ config=QueueConfig(
237
+ max_attempts=5,
238
+ on_exhausted=lambda msg, exc: sqs.send_message(QueueUrl=DLQ, MessageBody=msg["Body"]),
239
+ ),
240
+ )
241
+
242
+ @consumer.handle
243
+ def process(msg) -> None:
244
+ charge_customer(msg["Body"]) # once per SQS MessageId
245
+
246
+ run_forever(consumer, sqs_client=sqs, queue_url=QUEUE, visibility_timeout=30) # deletes on ack
247
+ ```
248
+
249
+ The others are the same shape: `contrib.kafka.kafka_consumer` (dedup on `topic:partition:offset`, pass `group_id`), `contrib.rabbitmq.rabbitmq_consumer` (dedup on the AMQP `message_id`), `contrib.pubsub.pubsub_consumer` (dedup on the Pub/Sub `message_id`). None imports a broker SDK — you install and create the client yourself. Runnable SQS/Kafka: [`queue/sqs.py`](examples/queue/sqs.py), [`queue/kafka.py`](examples/queue/kafka.py); for anything else, [`queue/generic_broker.py`](examples/queue/generic_broker.py).
250
+
251
+ Every `QueueConfig` knob — `max_attempts`, `on_exhausted`, `cache_result`, `validation_fingerprint`, and the rest — is in **[docs/configuration.md → Queue options](docs/configuration.md#queue-options)**.
252
+
253
+ ---
254
+
255
+ ## Method calls and AI tools
256
+
257
+ Some duplicates are a plain function call, and the caller has no key to give you: an LLM agent re-emitting a tool call, a job that overlaps, an internal call. `@idempotent` dedupes on the **arguments**.
258
+
259
+ ```python
260
+ from idemkit import MethodConfig, idempotent, RedisBackend
261
+
262
+ @idempotent(backend=RedisBackend.from_url("redis://..."), config=MethodConfig(key_fields=["order_id", "amount"]))
263
+ async def refund(*, order_id, amount):
264
+ return await payments.refund(order_id, amount) # runs once per (order_id, amount)
265
+ ```
266
+
267
+ Call it twice with the same `order_id` and `amount`, and the refund happens once. The second call replays the first result. Sync code uses `idempotent_sync` — including **Celery tasks**, where `@idempotent_sync` goes *under* `@app.task` (see [`method/sync_function.py`](examples/method/sync_function.py)). Like HTTP, `scope` is optional: with none idemkit runs single-tenant and warns once, and you pass it to isolate callers (per user, or per agent session). See [`method/getting_started.py`](examples/method/getting_started.py) and [`method/agent_loop.py`](examples/method/agent_loop.py).
268
+
269
+ **Nested args:** a `key_fields` entry can be a dotted path — `key_fields=["order.id", "customer.email"]` walks `order["id"]` (dict) or `order.id` (object), no callback or expression language to learn.
270
+
271
+ > **Dedupe on the arguments, never a per-call id.** An LLM mints a fresh `tool_call_id` every turn (OpenAI `call_…`, Anthropic `toolu_…`). Passing that as the key defeats the purpose, because a retry carries a new id and runs the side effect again. The stable identity of a call is its arguments, so the default is `key_fields`. idemkit warns if you hand it a key that looks like a per-turn id.
272
+
273
+ **This is not memoization.** `functools.lru_cache` lives in one process, forgets on restart, and has no concept of a concurrent call. idemkit's claim is atomic across processes and survives a crash (lease plus fencing). A parallel duplicate waits for the first to finish, and each caller is scoped separately.
274
+
275
+ **MCP and agent tools.** MCP tools can advertise `idempotentHint`, but nothing enforces it. `idemkit.contrib.mcp` makes it real ([example](examples/method/mcp.py)):
276
+
277
+ ```python
278
+ from mcp.server.fastmcp import FastMCP
279
+ from idemkit.contrib.mcp import mcp_idempotent
280
+
281
+ mcp = FastMCP("payments")
282
+
283
+ @mcp.tool()
284
+ @mcp_idempotent(backend=backend, config=MethodConfig(key_fields=["order_id", "amount"]))
285
+ async def refund(order_id: str, amount: int) -> dict:
286
+ return await payments.refund(order_id, amount) # agent re-plan replays, no double refund
287
+ ```
288
+
289
+ Anthropic tool use and OpenAI function calling work the same way. Parse the tool arguments, call the decorated function, feed the result back. The `tool_call_id` only links the result to the call, and idemkit dedupes on the arguments.
290
+
291
+ For the rest — `version`, `normalize_args`, `require_key`, `cache_exceptions`, and the others — see **[docs/configuration.md → Method options](docs/configuration.md#method-options)**.
292
+
293
+ ---
294
+
295
+ ## Examples
296
+
297
+ Runnable snippets live in [`examples/`](examples/), grouped by surface (`http/`, `queue/`, `method/`, `shared/`). Each shows only the integration code, uses `InMemoryBackend` so it needs no setup, and is covered by a test, so a broken example fails CI.
298
+
299
+ Browse the full "I want to..." index in [`examples/README.md`](examples/README.md). Each folder has a `getting_started.py`, an `all_options.py`, and one file per use case (SQS, DLQ, MCP tools, PII redaction, ...). Run them with `pytest tests/examples`.
300
+
301
+ ## Backends
302
+
303
+ | Backend | Use it for |
304
+ |---|---|
305
+ | `InMemoryBackend` | Dev, tests, prototypes. Not for production, since state is not shared across workers. |
306
+ | `RedisBackend` | Most production deployments. Redis 6+ and Cluster. |
307
+ | `PostgresBackend` | When you already run Postgres. Ships `idemkit init-pg` (schema) and `idemkit pg-vacuum` (cleanup). |
308
+ | `MongoBackend` | When you already run MongoDB. Server-clock leases (`$$NOW`); a TTL index self-reaps, so no vacuum. Needs the `mongo` extra. |
309
+ | `DynamoBackend` | When you already run on DynamoDB (managed, nothing to operate). Conditional writes + a TTL attribute. Leases use the **client** clock (see the note in Limitations). Needs the `dynamodb` extra. |
310
+
311
+ All five pass the same [conformance suite](#conformance) on real servers (DynamoDB passes every vector except the clock-skew one, since its leases use the client clock — see [Limitations](#limitations-and-when-not-to-use-it)). The same backend serves all three surfaces. Writing your own is a five-method `Protocol` (`claim`, `complete`, `release`, `renew`, `wait_for_completion`); validate it against the conformance suite (example: [`custom_backend.py`](examples/shared/custom_backend.py)).
312
+
313
+ To share one datastore with other data, or run isolated instances on it, `RedisBackend(namespace="idemkit")` prefixes every key, `PostgresBackend(table="idempotency_keys")` / `MongoBackend.from_url(url, collection=...)` use a custom table/collection, and `DynamoBackend("idempotency_keys")` a custom table. Full setup for every backend is in [`backends.py`](examples/shared/backends.py).
314
+
315
+ Redis and Postgres hold a pool and a background listener. The ASGI middleware closes the backend it was given on `lifespan` shutdown, so the one-line install does not leak (pass `manage_backend=False` to opt out). Outside ASGI, use the backend as an async context manager:
316
+
317
+ ```python
318
+ async with RedisBackend.from_url("redis://...") as backend:
319
+ ... # closed automatically
320
+ ```
321
+
322
+ `from_url` sets a default connection timeout so an unreachable or hung backend fails fast (fail-closed) instead of blocking a request: Redis gets `socket_connect_timeout`, Postgres gets `command_timeout`. Override either via a keyword.
323
+
324
+ On **Redis Cluster** no hash tags are needed: each atomic claim and complete is a single-key script, and the in-flight-wait pub/sub broadcasts cluster-wide. **Postgres needs a reaper:** correctness does not depend on it (expiry is enforced on read), but the table grows unbounded unless you schedule `idemkit pg-vacuum` (see [Configuration](docs/configuration.md#postgres-schedule-the-reaper)).
325
+
326
+ ## Configuration
327
+
328
+ **You rarely set any of these.** The defaults are production-sane. Each surface takes one config object (`HttpConfig` / `QueueConfig` / `MethodConfig`), passed as `config=`; that is the one way in. The **datastore** (Postgres table, Redis namespace) is configured on the backend, not here.
329
+
330
+ The knobs you might actually reach for:
331
+
332
+ | Option | Default | When to touch it |
333
+ |---|---|---|
334
+ | `lease_ttl_seconds` | `30` HTTP / `60` method / derived (queue) | Raise it above your handler's p99. **HTTP does not heartbeat**, so this matters most on HTTP. |
335
+ | `expires_after_seconds` | `86400` (24h) | Set it above your longest honest retry gap. |
336
+ | `on_storage_error` | `"fail_closed"` | Switch to `fail_open` only if availability beats dedup. |
337
+ | `event_handlers` | `[]` | Wire a ready-made metrics/logging handler, or your own. |
338
+
339
+ Full reference and the three time-settings explained: **[docs/configuration.md](docs/configuration.md)**. What to alert on, the reaper, and what to pin to: **[docs/operations.md](docs/operations.md)**. Ready-made exporters:
340
+
341
+ ```python
342
+ from idemkit.contrib.prometheus import prometheus_handler # pip install "idemkit[prometheus]"
343
+ from idemkit.contrib.logging import logging_handler # zero deps
344
+
345
+ config = HttpConfig(event_handlers=(prometheus_handler(), logging_handler()))
346
+ ```
347
+
348
+ ## Limitations and when not to use it
349
+
350
+ idemkit is deliberately narrow. It gives **effectively-once**: at-least-once delivery plus idempotent execution, not the impossible exactly-once. So the one question a money path must answer is:
351
+
352
+ ### When can my code actually run twice?
353
+
354
+ | Cause | Which surface | Guard it with |
355
+ |---|---|---|
356
+ | A completion write is lost (storage blips at the instant the result is recorded) | all | pass the idempotency key downstream so the whole chain dedupes |
357
+ | An HTTP handler runs longer than `lease_ttl_seconds` (no heartbeat on HTTP) | HTTP only | size `lease_ttl_seconds` above your handler's p99, or push the key downstream |
358
+ | A storage outage while `on_storage_error="fail_open"` | all | keep the default `fail_closed` (rejects instead of running unprotected) |
359
+ | A non-2xx response (or a *raised* exception in method/queue) releases the claim | all | return a result instead of raising; add the status to `cacheable_status` to replay it |
360
+ | A handler fires two side effects and crashes between them | all | split them, or key the downstream call |
361
+
362
+ In every one of these the stored record stays correct and duplicates still replay it — what can repeat is the **side effect** (the charge, the email). idemkit fences the record; it can't un-send an email a zombie worker already sent. The detail behind each row:
363
+
364
+ - **It dedupes result delivery, not downstream side effects.** A handler that keeps running after it loses its claim (a network partition, a blocking call that ignores cancellation) can still fire its side effect. For money, pass the idempotency key downstream or use a transactional outbox.
365
+ - **One irreversible side effect per function or handler.** idemkit dedupes the whole call, not steps inside it. A function that does two side effects and crashes between them re-runs both on retry. Split them, or key the downstream.
366
+ - **`fail_open` trades safety for availability.** On a storage outage it runs unprotected, so a duplicate can slip through during the outage. `fail_closed` (the default) rejects instead.
367
+ - **`expires_after_seconds` bounds the replay window.** A retry after the TTL re-executes. Size it above your longest honest retry gap.
368
+ - **Not a workflow engine.** For multi-step orchestration with recovery points, use a durable-execution engine. idemkit makes *one* operation safe to retry.
369
+ - **Effectively-once, not exactly-once.** At-least-once delivery plus idempotent execution. No system delivers exactly-once.
370
+ - **Postgres needs a reaper.** Correctness is fine without one (expiry is enforced on read), but the table grows unbounded until you schedule `idemkit pg-vacuum`. Redis keys self-expire, so Redis needs nothing.
371
+ - **A raised exception is not cached (method/queue).** `@idempotent` caches a returned value; a handler that *raises* releases the claim so a retry re-runs it. That is right for transient errors, but a deterministic exception re-executes each retry. Return a result instead of raising if you want it replayed. (HTTP can replay a client error via `cacheable_status`.)
372
+ - **The sync API runs on a shared background loop.** `idempotent_sync` / `dispatch_sync` bridge to the async core through one process-wide event loop. It is correct and fine for typical Flask/Django/Celery loads, but it is a shim over the async core rather than a native sync path. Under very high sync concurrency, prefer the async API.
373
+ - **HTTP does not renew the lease.** Queue and method handlers heartbeat, so a slow one keeps its claim. HTTP does not: an HTTP handler that runs longer than `lease_ttl_seconds` (default 30s) can have its claim reclaimed by a concurrent retry, which then runs a second time (the first handler's write is fenced, not cancelled). Size `lease_ttl_seconds` above your HTTP handler's p99, or push the idempotency key downstream for a slow charge.
374
+ - **A non-2xx HTTP response re-executes on retry.** By default only `{200,201,202}` are cached; any other status releases the claim, so a retry re-runs the handler. That is usually right (a `409` may succeed later), but a handler that fires a side effect then returns a non-2xx will re-fire it. Add the status to `cacheable_status` to replay it instead.
375
+ - **DynamoDB leases use the client clock.** Redis, Postgres, and Mongo decide lease expiry with the storage server's own clock, so a skewed app clock cannot cause a wrongful reclaim. DynamoDB has no server clock in a condition expression, so `DynamoBackend` uses the client clock. With NTP-synced hosts this is fine; a badly skewed host could misjudge a lease. Pick Redis/Postgres/Mongo if that matters.
376
+ - **The in-flight-wait channel can drop, then self-heals.** If the Redis pub/sub or Postgres LISTEN channel dies (a failover), waiting duplicates fall back to a bounded poll (correctness holds) and the channel is re-established on the next waiter. Expect a brief latency bump on conflicts during a failover, not a hang.
377
+
378
+ ## Testing
379
+
380
+ Issue a duplicate in-process and assert the side effect fired once:
381
+
382
+ ```python
383
+ async def test_charge_is_idempotent():
384
+ r1 = await client.post("/charge", headers={"Idempotency-Key": "k"})
385
+ r2 = await client.post("/charge", headers={"Idempotency-Key": "k"})
386
+ assert r1.json() == r2.json()
387
+ assert r2.headers["idempotency-replayed"] == "true"
388
+ assert charges_in_db() == 1
389
+ ```
390
+
391
+ For lease expiry and TTL behavior, pass a `ManualClock` to `InMemoryBackend` and advance it instead of sleeping ([example](examples/method/record_expiration.py)).
392
+
393
+ ## Conformance
394
+
395
+ The correctness core (atomic claim, fencing, lease reclaim, in-flight wait, TTL expiry, lease renewal) is a runnable suite any backend can check itself against:
396
+
397
+ ```bash
398
+ idemkit conformance --redis redis://localhost:6379 --postgres postgresql://user:pass@localhost/db
399
+ ```
400
+
401
+ Every surface's vectors pass on real Redis, PostgreSQL, MongoDB, and DynamoDB. The language-neutral descriptions live in [`spec/conformance.yaml`](../spec/conformance.yaml).
402
+
403
+ Beyond the fixed vectors, three checks run against every backend:
404
+
405
+ - **Property-based model check** (Hypothesis) drives random operation sequences against the backend and a reference model, and asserts they never diverge.
406
+ - **Fault injection** raises transient storage errors under concurrency to confirm the guarantees hold.
407
+ - **Clock-skew tests** check that lease decisions follow the storage clock, not the app's.
408
+
409
+ The full picture, including the honest limits, is in [CORRECTNESS.md](docs/correctness.md).
410
+
411
+ ## Performance
412
+
413
+ Idempotency cost is one backend round-trip on the happy path (claim + complete) and one on a replay, so throughput tracks your store's latency, not the library. We publish no numbers because ours wouldn't predict yours — measure on your own backend with [`benchmarks/bench.py`](benchmarks/bench.py) (reports ops/sec and p50/p99 for in-memory, Redis, or Postgres). Details in [`benchmarks/README.md`](benchmarks/README.md).
414
+
415
+ ## Glossary
416
+
417
+ The terms the rest of this README leans on:
418
+
419
+ - **Idempotency key** — the token that says "these two calls are the same call." On HTTP it's the `Idempotency-Key` header; on a queue it's the broker's message id; on a method call it's derived from the arguments.
420
+ - **Claim** — winning the atomic right to run the code for a key. Exactly one caller claims it and runs; everyone else waits and replays that caller's result.
421
+ - **Scope** — an isolation namespace layered on top of the key, usually a tenant or user id. Two callers can send the same key and never collide, because their scopes differ. Omit it only for a single-tenant service.
422
+ - **Fingerprint** — a hash of the request body (or chosen fields). If the same key arrives with a *different* fingerprint, idemkit rejects it (`422`) instead of replaying the wrong response. `body_fingerprint` lets you fingerprint only the stable fields.
423
+ - **Lease** — a time-boxed hold on a key while your code runs. If the worker dies, the lease expires (decided by the storage server's own clock, so a skewed app clock can't misfire it) and the next attempt may reclaim the key. This is what stops a dead worker from blocking a key forever.
424
+ - **Fencing token** — a token handed to whoever holds the current lease. If a zombie worker (one that lost its lease but kept running) tries to write its result, the token no longer matches and the write is rejected — so its late write can't overwrite the real result.
425
+ - **Dedup id** (queues) — the field on a broker message that identifies "the same message," e.g. the SQS `MessageId` or Kafka `topic:partition:offset`. You tell idemkit how to read it via `dedup_id`; it plays the role the `Idempotency-Key` header plays on HTTP.
426
+ - **Visibility timeout** (queues) — the window a broker hides a message after delivery, expecting an ack. idemkit derives the lease from it. If your handler outlives the window the broker redelivers the message *while it's still running* — the usual "ran twice" bug — so size it above your handler's p99.
427
+ - **Heartbeat / renew** — a long queue or method handler periodically extends its lease while it runs, so a slow-but-alive handler keeps its claim and only a real crash lets another worker take over. (HTTP handlers don't heartbeat.)
428
+ - **`fail_open` / `fail_closed`** — what happens when the backend is down. `fail_closed` (the default) rejects the request; `fail_open` runs it unprotected, so a duplicate can slip through during the outage.
429
+
430
+ Deeper: **effectively-once** (at-least-once delivery + idempotent execution, the honest alternative to impossible exactly-once) is explained under [Limitations](#limitations-and-when-not-to-use-it); the mechanics live in [CORRECTNESS.md](docs/correctness.md).
431
+
432
+ ## Security
433
+
434
+ idemkit stores a **hash** of the idempotency key, never the raw key, and never logs the raw key. It does store the **response body verbatim** so duplicates can replay it — so on a money path, redact sensitive fields (`response_redactor`, opt-in) and enable encryption at rest on your backend. The default codec is JSON; the pickle codec is opt-in and an RCE risk. Full details and how to report a vulnerability: [SECURITY.md](SECURITY.md).
435
+
436
+ ## Contributing
437
+
438
+ ```bash
439
+ cd python/ && python3 -m venv .venv && source .venv/bin/activate
440
+ pip install -e ".[dev]"
441
+ make check # the gate CI runs: lint + typecheck + full suite (all 5 backends)
442
+ ```
443
+
444
+ Setup, the `Makefile` targets, running the e2e brokers, and the house rules are in [`CONTRIBUTING.md`](CONTRIBUTING.md).
445
+
446
+ Apache-2.0. See [`LICENSE`](../LICENSE). Design and rationale are in [`spec/idemkit-unified-spec.md`](../spec/idemkit-unified-spec.md).
@@ -0,0 +1,48 @@
1
+ idemkit/__init__.py,sha256=hA8kZXHWXC0QBvGjMqidxcqR9Cw7fmQ_1GM7YRQLeZI,3088
2
+ idemkit/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ idemkit/cli.py,sha256=6q07TUwG-rDcWxzH4VlIk4_ak7L_jZX-Oh9Ie7JNdBY,5370
4
+ idemkit/problem_details.py,sha256=K5s-9fw8K7vPKGcuGLcuJ1jcYQhstuKYEAxIHKR5-r4,3850
5
+ idemkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ idemkit/adapters/__init__.py,sha256=CcAev0tQ32eB2pUCC2cHNqzF15Jpe_f5luCfVZijOMc,347
7
+ idemkit/adapters/ai.py,sha256=9mXJ1JVhpDtbeaCTmoPDyYORUgWfzr9fVP40EcVvxLM,33575
8
+ idemkit/adapters/asgi.py,sha256=UcaJ1Ga5rza5abtb7ZNIrLLbYy0xuVRacz6egcvtkdI,20799
9
+ idemkit/adapters/queue.py,sha256=yadAPuvscFkhPDSeTbzwRqZyvWcKqnMn36qzs_LmHWM,19173
10
+ idemkit/adapters/route.py,sha256=MKLanHQqH4b7AYhJHTW1UGImh_xdbRLJd-AIavZrf1c,10189
11
+ idemkit/adapters/wsgi.py,sha256=KcrDX0RyHkRZwIFU9qzrAwQ1C0Prf97SK9_kEMxuhts,14490
12
+ idemkit/backends/__init__.py,sha256=DpZM2BrcujJg3mfpMwIWhtsBxHSAyXW4EiNU7_Xd6eI,937
13
+ idemkit/backends/base.py,sha256=qXggT39OIV5DnP6UYYWLDwWjMFZIppYjcRvSg3lyLdM,4424
14
+ idemkit/backends/dynamodb.py,sha256=c8JbavP0OJP9nDmf5PPr5z1fTx1iQh5LE7vYTsQWNs0,20151
15
+ idemkit/backends/memory.py,sha256=qoxzozE7dtoJzfDIMe4vzK6_1msIADqlvvKYQnqb41Y,12549
16
+ idemkit/backends/mongo.py,sha256=DI71aOyio29vJJ7TnGJXPPlYJR42pxMgQgKCQt2g3Vg,15542
17
+ idemkit/backends/postgres.py,sha256=MuvQ6Zsq9rY7BYChyL7MFSCyC2uoQk663_ogxO8OGuM,26717
18
+ idemkit/backends/redis.py,sha256=kDGt9cc-UJvDWmil32alh1K1czGC_FDUuX36DZyYEJk,19904
19
+ idemkit/conformance/__init__.py,sha256=rvzK2bQyJfCdYu4w2rCZD1Inx925lb9pJ_F5xARCDWE,976
20
+ idemkit/conformance/backend.py,sha256=Odkbis7-BdQguERJqpz2bs_461nzbrIhwVzsylO0L24,10595
21
+ idemkit/conformance/report.py,sha256=_yzKb1-nnGJ_3MFTULXcP-KJfPLXL16rsVYZ8dIFnWY,1523
22
+ idemkit/contrib/__init__.py,sha256=vwc_pQTUaeofEVeOsxOUYoUZXzbYd4hGoFIcIOXlrf4,1558
23
+ idemkit/contrib/drf.py,sha256=JYT5uhvO4Lpp2dmhbS6apGtm7CdoJjD9-KbvoFUGS18,7691
24
+ idemkit/contrib/fastapi.py,sha256=AwNGzLgYLGkrmM0wB7SkDWyNuySvS9CDyKL1RGL56KE,5648
25
+ idemkit/contrib/kafka.py,sha256=iZBt2BcTa8MTm8_KYbtOkJ4C8eAQz-EHz-EARxwhsXA,3610
26
+ idemkit/contrib/logging.py,sha256=p2To7vdY4KaqvNZlImU5OcJ9_2IluGnWzfH2ZLzuuNQ,2018
27
+ idemkit/contrib/mcp.py,sha256=t8qSbUBY8gh16dNnK4lC6GZlEJ34iZiXCKjBQ-xm4r0,4509
28
+ idemkit/contrib/prometheus.py,sha256=4H71tuPFeKbqxAiM3_d7CDPSwKjz45Lw-dorLPmrlT0,3174
29
+ idemkit/contrib/pubsub.py,sha256=qjt_BH09gMZpf4p-7cZgUCmoUyBdvm1-PllA642F218,3632
30
+ idemkit/contrib/rabbitmq.py,sha256=Jad8w6hxZQ02T5mqi2rdTDSGvGjrnGTIYXOY7xjB1mk,5428
31
+ idemkit/contrib/reconciliation.py,sha256=YHDlhN98fF8SVtz9H4KTMNRVa-MRrWUZzJaVbMDGL2s,3829
32
+ idemkit/contrib/sqs.py,sha256=BVuywfBdrOoJHAVqlKAe16u6U3w4Y1bvPdvglFrkRVA,4306
33
+ idemkit/core/__init__.py,sha256=xKTzY3qVkItozy7poJt7KuY9Z0G5lo8j-2tn6l6FScw,779
34
+ idemkit/core/codecs.py,sha256=4dgjd1fVwdwuqAomxSrOhMfpDj74P_PKP8f7xTaDURk,9232
35
+ idemkit/core/config.py,sha256=J0zhAOCMN_-9QwiM2H5R2afiFIKiEziA7R55lKcYgok,10708
36
+ idemkit/core/engine.py,sha256=5-pPVMe0fzbYP6N66FK9WJQ39ATJwfsCAjmcji6FaRQ,12993
37
+ idemkit/core/events.py,sha256=j7XNEu7UtVQJgXOc9insO0pJJnn3qJjm4vVBjutyKB8,2136
38
+ idemkit/core/exception_cache.py,sha256=wCunu6Qc8kWg8DRAw3lJvRnQ77epXDyrcc_KapSmO0U,3461
39
+ idemkit/core/exceptions.py,sha256=niUnycvMmcZZGEfF0nNt4JF2b8gf1tFLoyWwDYdnuZc,3144
40
+ idemkit/core/fingerprint.py,sha256=bipu73Zspo4yRkE1TGq1vLzgTXn090w51MTNUtJgr00,5057
41
+ idemkit/core/policy.py,sha256=vQewYgxXFq-O7oSrqlI97BKQ_MFzZmLCxCTLhv2k6_c,5929
42
+ idemkit/core/runner.py,sha256=6uqgwooFSFLrk2SBbu3D9vaG-XzPm_Jg9FDKpLOb1ak,29272
43
+ idemkit/core/state.py,sha256=MsehmnCCuWwrsjLVZKdnKHP4BqXP6zs9zB4AA5H0jro,3249
44
+ idemkit/core/sync_bridge.py,sha256=5chcxprflFIMMJKM3XbEoUOeigUPNfNGrXTPxdjgy2Q,3942
45
+ idemkit-0.1.0.dist-info/METADATA,sha256=89sc7zEuGEq8rEztT9v1NERJW_BNsSfQpyu44cGbQTo,33584
46
+ idemkit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
47
+ idemkit-0.1.0.dist-info/entry_points.txt,sha256=Y0dJN3HQ0cda3Ke5sA6rB76b2n0dbfTSDODEvsFhr-E,45
48
+ idemkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ idemkit = idemkit.cli:main