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
|
@@ -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
|
+
[](https://github.com/idemkit/idemkit/actions/workflows/ci.yml)
|
|
66
|
+
[](../LICENSE)
|
|
67
|
+
[](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,,
|