idempotency-kit 0.1.1__tar.gz → 0.3.0__tar.gz
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.
- idempotency_kit-0.3.0/CHANGELOG.md +59 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/PKG-INFO +17 -6
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/README.md +16 -5
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/__init__.py +6 -0
- idempotency_kit-0.3.0/idempotency_kit/__version__.py +1 -0
- idempotency_kit-0.3.0/idempotency_kit/core/adapters/basic.py +61 -0
- idempotency_kit-0.3.0/idempotency_kit/core/constants.py +41 -0
- idempotency_kit-0.3.0/idempotency_kit/core/decorators/aio/idempotent.py +161 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/exceptions.py +23 -0
- idempotency_kit-0.3.0/idempotency_kit/core/fingerprint.py +22 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/models/entities.py +45 -1
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/repository.py +16 -0
- idempotency_kit-0.3.0/idempotency_kit/core/services/aio/coordinator.py +537 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/services/domain.py +41 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/coordinator.py +6 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/dishka/protocols.py +26 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/aio/repository.py +119 -88
- idempotency_kit-0.3.0/idempotency_kit/settings.py +43 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/pyproject.toml +4 -0
- idempotency_kit-0.1.1/CHANGELOG.md +0 -31
- idempotency_kit-0.1.1/idempotency_kit/__version__.py +0 -1
- idempotency_kit-0.1.1/idempotency_kit/core/adapters/basic.py +0 -43
- idempotency_kit-0.1.1/idempotency_kit/core/constants.py +0 -18
- idempotency_kit-0.1.1/idempotency_kit/core/decorators/aio/idempotent.py +0 -93
- idempotency_kit-0.1.1/idempotency_kit/core/services/aio/coordinator.py +0 -224
- idempotency_kit-0.1.1/idempotency_kit/settings.py +0 -18
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/.gitignore +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/LICENSE +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/adapters/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/models/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/adapter.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/metrics.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/services/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/dishka/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/redis.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/dishka/common.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/metrics/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/metrics/prometheus.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/aio/__init__.py +0 -0
- {idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/py.typed +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.0](https://github.com/bedrock-python/idempotency-kit/compare/idempotency-kit-v0.2.0...idempotency-kit-v0.3.0) (2026-09-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* a second concurrent caller with the same key now waits for the first caller's result instead of running the action too, and coordinate() and the decorator can raise IdempotencyInProgressError. Pass in_flight="run" to the coordinator (or set it on the settings object) to keep the previous behaviour. AsyncIdempotencyRepository gained replace(record); a custom repository needs it, and the coordinator raises TypeError at construction without it unless in_flight="run". IdempotencyRecord gained status, which records written before this change read as "completed".
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* reserve the key while the action runs ([#28](https://github.com/bedrock-python/idempotency-kit/issues/28)) ([0dbf68b](https://github.com/bedrock-python/idempotency-kit/commit/0dbf68b04ce5a54c18f041a35b77d1df2c043fb7)), closes [#26](https://github.com/bedrock-python/idempotency-kit/issues/26)
|
|
13
|
+
* store a fingerprint of the request and refuse a key reused for another ([#30](https://github.com/bedrock-python/idempotency-kit/issues/30)) ([78a9ca2](https://github.com/bedrock-python/idempotency-kit/commit/78a9ca2abb1b5d6a660d1bdf0b31f32a7da256d1)), closes [#27](https://github.com/bedrock-python/idempotency-kit/issues/27)
|
|
14
|
+
|
|
15
|
+
## [0.2.0](https://github.com/bedrock-python/idempotency-kit/compare/idempotency-kit-v0.1.1...idempotency-kit-v0.2.0) (2026-09-06)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### ⚠ BREAKING CHANGES
|
|
19
|
+
|
|
20
|
+
* DEFAULT_TTL_MINUTES is 60 (was 30) and MAX_TTL_SECONDS is 2592000 (was 86400), so IdempotencyDomainService() built without arguments now keeps records for an hour and accepts a TTL of up to 30 days. Pass default_ttl_minutes=30 and max_ttl_seconds=86400 to keep the old values.
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* the defects the agents page turned up ([#22](https://github.com/bedrock-python/idempotency-kit/issues/22)) ([1573db4](https://github.com/bedrock-python/idempotency-kit/commit/1573db4774191598cad38b02e653b2656d3bb763))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* an upgrade note for the unified TTL defaults ([#25](https://github.com/bedrock-python/idempotency-kit/issues/25)) ([bb90e84](https://github.com/bedrock-python/idempotency-kit/commit/bb90e8453e0ac74856b354b34f01dfe495e9393c))
|
|
30
|
+
|
|
31
|
+
## [0.1.1](https://github.com/bedrock-python/idempotency-kit/compare/idempotency-kit-v0.1.0...idempotency-kit-v0.1.1) (2026-08-28)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
* **core:** store any JSON result and decide hit/miss on the record, not on None ([#8](https://github.com/bedrock-python/idempotency-kit/issues/8)) ([ddb537e](https://github.com/bedrock-python/idempotency-kit/commit/ddb537e8c39e5becb1a9b1ff7549bc11f40735d7)), closes [#6](https://github.com/bedrock-python/idempotency-kit/issues/6)
|
|
37
|
+
* **dishka:** provide the metrics collector from settings so the shipped providers build a container ([#9](https://github.com/bedrock-python/idempotency-kit/issues/9)) ([2a06416](https://github.com/bedrock-python/idempotency-kit/commit/2a064169a16332762d3ab62926c03d2f203482bd)), closes [#7](https://github.com/bedrock-python/idempotency-kit/issues/7)
|
|
38
|
+
* **release:** update version to 0.1.0 and fix release-please config ([016c6c4](https://github.com/bedrock-python/idempotency-kit/commit/016c6c42ba189291be56250192f9e6db7020f853))
|
|
39
|
+
* update publish workflow, release-please version search, gitignore ([#3](https://github.com/bedrock-python/idempotency-kit/issues/3)) ([9915f0e](https://github.com/bedrock-python/idempotency-kit/commit/9915f0e6e784b6d59375541e6244efaca694a0bb))
|
|
40
|
+
|
|
41
|
+
## 0.1.0 (2026-05-13)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
### Bug Fixes
|
|
45
|
+
|
|
46
|
+
* **ci:** remove coverage threshold from integration tests ([b00f037](https://github.com/bedrock-python/idempotency-kit/commit/b00f03742925922e261790b0ee649055c31fdaae))
|
|
47
|
+
* **ci:** set integration test coverage threshold to 50% ([ed4e790](https://github.com/bedrock-python/idempotency-kit/commit/ed4e7907ff977916b0be11a6597a090b9ae11cab))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
|
|
52
|
+
* rewrite README to match library style with badges ([cebded3](https://github.com/bedrock-python/idempotency-kit/commit/cebded360b4c0c37d7604efd230574b4464f47c8))
|
|
53
|
+
|
|
54
|
+
## Changelog
|
|
55
|
+
|
|
56
|
+
All notable changes to this project will be documented in this file.
|
|
57
|
+
|
|
58
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
59
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: idempotency-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Production-ready idempotency library for async Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/bedrock-python/idempotency-kit
|
|
6
6
|
Project-URL: Documentation, https://bedrock-python.github.io/idempotency-kit/
|
|
@@ -244,6 +244,15 @@ Production-ready idempotency library for async Python applications.
|
|
|
244
244
|
|
|
245
245
|
Ensure operations execute exactly once, even when called multiple times with the same idempotency key. Built for production microservices with graceful degradation, collision handling, and observability.
|
|
246
246
|
|
|
247
|
+
> [!TIP]
|
|
248
|
+
> **Building this with an AI assistant?** Hand it
|
|
249
|
+
> **[one page](https://bedrock-python.github.io/idempotency-kit/agents/)** instead of the
|
|
250
|
+
> whole site: the complete API surface, the rules that break code when they are broken —
|
|
251
|
+
> what the key actually identifies, what two concurrent callers really do, how a TTL in
|
|
252
|
+
> seconds is rounded — the mistakes models make with this API, and a map of which page to
|
|
253
|
+
> fetch for the rest. Every docs page is also served as raw Markdown at its own URL, and a
|
|
254
|
+
> **Copy page** button at the top of each one hands it straight to a chat window.
|
|
255
|
+
|
|
247
256
|
## Features
|
|
248
257
|
|
|
249
258
|
- **Clean Architecture** — core domain separated from infrastructure
|
|
@@ -251,7 +260,8 @@ Ensure operations execute exactly once, even when called multiple times with the
|
|
|
251
260
|
- **Type-Safe** — full type hints with Pydantic validation
|
|
252
261
|
- **Async First** — built for asyncio applications
|
|
253
262
|
- **Graceful Degradation** — high availability over strict exactly-once
|
|
254
|
-
- **
|
|
263
|
+
- **In-flight Reservation** — a retry that arrives while the original is still running waits for its result or gets a 409; the action runs once
|
|
264
|
+
- **Request Fingerprints** — name the parameters that identify a request, and a key reused for a different one is refused instead of replayed
|
|
255
265
|
- **Observability** — built-in metrics (hits, misses, collisions, latency)
|
|
256
266
|
- **Bulk Operations** — efficient `get_many`, `save_many`, `delete_many`
|
|
257
267
|
- **Redis Cluster Compatible** — non-transactional pipelines
|
|
@@ -334,12 +344,12 @@ return order
|
|
|
334
344
|
|
|
335
345
|
### 4. Concurrent requests handled
|
|
336
346
|
|
|
337
|
-
If two requests arrive
|
|
347
|
+
If two requests arrive while the first is still executing:
|
|
338
348
|
|
|
339
|
-
- First request:
|
|
340
|
-
- Second request:
|
|
349
|
+
- First request: reserves the key → execute → write the result over the reservation ✅
|
|
350
|
+
- Second request: finds the reservation → waits for the first result → return ✅ (or a 409 with `in_flight="raise"`)
|
|
341
351
|
|
|
342
|
-
Both requests get the **same result
|
|
352
|
+
Both requests get the **same result**, and the business logic ran once.
|
|
343
353
|
|
|
344
354
|
## Use cases
|
|
345
355
|
|
|
@@ -352,6 +362,7 @@ Both requests get the **same result** - idempotency guaranteed!
|
|
|
352
362
|
|
|
353
363
|
📚 **[Full Documentation](https://bedrock-python.github.io/idempotency-kit/)**
|
|
354
364
|
|
|
365
|
+
- [For AI agents](https://bedrock-python.github.io/idempotency-kit/agents/) — the whole library on one page
|
|
355
366
|
- [Quick Start](https://bedrock-python.github.io/idempotency-kit/quickstart/) — get started in 5 minutes
|
|
356
367
|
- [User Guide](https://bedrock-python.github.io/idempotency-kit/user_guide/) — detailed usage and patterns
|
|
357
368
|
- [Architecture](https://bedrock-python.github.io/idempotency-kit/architecture/) — design principles
|
|
@@ -11,6 +11,15 @@ Production-ready idempotency library for async Python applications.
|
|
|
11
11
|
|
|
12
12
|
Ensure operations execute exactly once, even when called multiple times with the same idempotency key. Built for production microservices with graceful degradation, collision handling, and observability.
|
|
13
13
|
|
|
14
|
+
> [!TIP]
|
|
15
|
+
> **Building this with an AI assistant?** Hand it
|
|
16
|
+
> **[one page](https://bedrock-python.github.io/idempotency-kit/agents/)** instead of the
|
|
17
|
+
> whole site: the complete API surface, the rules that break code when they are broken —
|
|
18
|
+
> what the key actually identifies, what two concurrent callers really do, how a TTL in
|
|
19
|
+
> seconds is rounded — the mistakes models make with this API, and a map of which page to
|
|
20
|
+
> fetch for the rest. Every docs page is also served as raw Markdown at its own URL, and a
|
|
21
|
+
> **Copy page** button at the top of each one hands it straight to a chat window.
|
|
22
|
+
|
|
14
23
|
## Features
|
|
15
24
|
|
|
16
25
|
- **Clean Architecture** — core domain separated from infrastructure
|
|
@@ -18,7 +27,8 @@ Ensure operations execute exactly once, even when called multiple times with the
|
|
|
18
27
|
- **Type-Safe** — full type hints with Pydantic validation
|
|
19
28
|
- **Async First** — built for asyncio applications
|
|
20
29
|
- **Graceful Degradation** — high availability over strict exactly-once
|
|
21
|
-
- **
|
|
30
|
+
- **In-flight Reservation** — a retry that arrives while the original is still running waits for its result or gets a 409; the action runs once
|
|
31
|
+
- **Request Fingerprints** — name the parameters that identify a request, and a key reused for a different one is refused instead of replayed
|
|
22
32
|
- **Observability** — built-in metrics (hits, misses, collisions, latency)
|
|
23
33
|
- **Bulk Operations** — efficient `get_many`, `save_many`, `delete_many`
|
|
24
34
|
- **Redis Cluster Compatible** — non-transactional pipelines
|
|
@@ -101,12 +111,12 @@ return order
|
|
|
101
111
|
|
|
102
112
|
### 4. Concurrent requests handled
|
|
103
113
|
|
|
104
|
-
If two requests arrive
|
|
114
|
+
If two requests arrive while the first is still executing:
|
|
105
115
|
|
|
106
|
-
- First request:
|
|
107
|
-
- Second request:
|
|
116
|
+
- First request: reserves the key → execute → write the result over the reservation ✅
|
|
117
|
+
- Second request: finds the reservation → waits for the first result → return ✅ (or a 409 with `in_flight="raise"`)
|
|
108
118
|
|
|
109
|
-
Both requests get the **same result
|
|
119
|
+
Both requests get the **same result**, and the business logic ran once.
|
|
110
120
|
|
|
111
121
|
## Use cases
|
|
112
122
|
|
|
@@ -119,6 +129,7 @@ Both requests get the **same result** - idempotency guaranteed!
|
|
|
119
129
|
|
|
120
130
|
📚 **[Full Documentation](https://bedrock-python.github.io/idempotency-kit/)**
|
|
121
131
|
|
|
132
|
+
- [For AI agents](https://bedrock-python.github.io/idempotency-kit/agents/) — the whole library on one page
|
|
122
133
|
- [Quick Start](https://bedrock-python.github.io/idempotency-kit/quickstart/) — get started in 5 minutes
|
|
123
134
|
- [User Guide](https://bedrock-python.github.io/idempotency-kit/user_guide/) — detailed usage and patterns
|
|
124
135
|
- [Architecture](https://bedrock-python.github.io/idempotency-kit/architecture/) — design principles
|
|
@@ -8,12 +8,15 @@ from .core.adapters import (
|
|
|
8
8
|
from .core.decorators.aio.idempotent import async_idempotent
|
|
9
9
|
from .core.exceptions import (
|
|
10
10
|
IdempotencyError,
|
|
11
|
+
IdempotencyInProgressError,
|
|
11
12
|
IdempotencyInvalidTTLError,
|
|
12
13
|
IdempotencyKeyCollisionError,
|
|
14
|
+
IdempotencyKeyReuseError,
|
|
13
15
|
IdempotencyRecordExpiredError,
|
|
14
16
|
IdempotencyStorageError,
|
|
15
17
|
IdempotencyValidationError,
|
|
16
18
|
)
|
|
19
|
+
from .core.fingerprint import fingerprint_of
|
|
17
20
|
from .core.models.entities import IdempotencyIdentifiers, IdempotencyRecord
|
|
18
21
|
from .core.protocols.adapter import ResultAdapter
|
|
19
22
|
from .core.protocols.aio.repository import AsyncIdempotencyRepository
|
|
@@ -27,8 +30,10 @@ __all__ = [
|
|
|
27
30
|
"IdempotencyDomainService",
|
|
28
31
|
"IdempotencyError",
|
|
29
32
|
"IdempotencyIdentifiers",
|
|
33
|
+
"IdempotencyInProgressError",
|
|
30
34
|
"IdempotencyInvalidTTLError",
|
|
31
35
|
"IdempotencyKeyCollisionError",
|
|
36
|
+
"IdempotencyKeyReuseError",
|
|
32
37
|
"IdempotencyMetricsProtocol",
|
|
33
38
|
"IdempotencyRecord",
|
|
34
39
|
"IdempotencyRecordExpiredError",
|
|
@@ -40,4 +45,5 @@ __all__ = [
|
|
|
40
45
|
"ResultAdapter",
|
|
41
46
|
"VoidResultAdapter",
|
|
42
47
|
"async_idempotent",
|
|
48
|
+
"fingerprint_of",
|
|
43
49
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0" # x-release-please-version
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Any, TypeVar
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from idempotency_kit.core.exceptions import IdempotencyValidationError
|
|
6
|
+
from idempotency_kit.core.protocols.adapter import ResultAdapter
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T", bound=BaseModel)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PydanticResultAdapter(ResultAdapter[T]):
|
|
12
|
+
"""Adapter for Pydantic models.
|
|
13
|
+
|
|
14
|
+
It has no representation for an absent result: an action that may return ``None``
|
|
15
|
+
wants ``VoidResultAdapter`` or ``JsonResultAdapter`` instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, model_class: type[T]) -> None:
|
|
19
|
+
self.model_class = model_class
|
|
20
|
+
|
|
21
|
+
def encode(self, value: T) -> Any:
|
|
22
|
+
# Encoding None to null would store a record this adapter can never decode again,
|
|
23
|
+
# turning every later replay into a miss. Refusing it leaves the operation
|
|
24
|
+
# uncached and says why, which the coordinator reports and swallows.
|
|
25
|
+
model: BaseModel | None = value
|
|
26
|
+
if model is None:
|
|
27
|
+
msg = (
|
|
28
|
+
f"{type(self).__name__}({self.model_class.__name__}) cannot encode None. "
|
|
29
|
+
"Use VoidResultAdapter for an action that returns None, or JsonResultAdapter "
|
|
30
|
+
"for one that may."
|
|
31
|
+
)
|
|
32
|
+
raise IdempotencyValidationError(msg)
|
|
33
|
+
return model.model_dump(mode="json")
|
|
34
|
+
|
|
35
|
+
def decode(self, data: Any) -> T:
|
|
36
|
+
# Only a null payload is undecodable: an empty mapping or list is a model that
|
|
37
|
+
# happens to dump to nothing.
|
|
38
|
+
if data is None:
|
|
39
|
+
msg = "cannot decode a null idempotency payload"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
return self.model_class.model_validate(data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class JsonResultAdapter(ResultAdapter[Any]):
|
|
45
|
+
"""Adapter for results that already are JSON values (dict, list, str, number, bool or ``None``)."""
|
|
46
|
+
|
|
47
|
+
def encode(self, value: Any) -> Any:
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
def decode(self, data: Any) -> Any:
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class VoidResultAdapter(ResultAdapter[None]):
|
|
55
|
+
"""Adapter for functions that return ``None``: the record stores JSON ``null`` and decodes back to ``None``."""
|
|
56
|
+
|
|
57
|
+
def encode(self, value: None) -> Any:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def decode(self, data: Any) -> None:
|
|
61
|
+
return None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Core constants for idempotency."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
# Key constraints
|
|
6
|
+
# Maximum allowed length for idempotency key.
|
|
7
|
+
MAX_KEY_LENGTH: int = 255
|
|
8
|
+
|
|
9
|
+
# Maximum allowed length for operation name.
|
|
10
|
+
MAX_OPERATION_LENGTH: int = 100
|
|
11
|
+
|
|
12
|
+
# TTL defaults
|
|
13
|
+
# The single source for both IdempotencyDomainService's own defaults and the field
|
|
14
|
+
# defaults of BaseIdempotencySettings, so the bounds do not depend on how the service
|
|
15
|
+
# was built.
|
|
16
|
+
|
|
17
|
+
# Default TTL in minutes when not explicitly specified (1 hour).
|
|
18
|
+
DEFAULT_TTL_MINUTES: int = 60
|
|
19
|
+
|
|
20
|
+
# Minimum allowed TTL in seconds (1 minute); the coordinator floors every TTL at a minute.
|
|
21
|
+
MIN_TTL_SECONDS: int = 60
|
|
22
|
+
|
|
23
|
+
# Maximum allowed TTL in seconds (30 days).
|
|
24
|
+
MAX_TTL_SECONDS: int = 30 * 24 * 3600
|
|
25
|
+
|
|
26
|
+
# In-flight handling
|
|
27
|
+
# What the coordinator does with a second caller that arrives while the first one's
|
|
28
|
+
# action is still running under the same key: wait for the first caller's result, raise
|
|
29
|
+
# IdempotencyInProgressError at once, or run the action too.
|
|
30
|
+
InFlightMode = Literal["wait", "raise", "run"]
|
|
31
|
+
|
|
32
|
+
# Wait by default: a second caller in the retry window gets the first caller's result
|
|
33
|
+
# instead of producing a second one.
|
|
34
|
+
DEFAULT_IN_FLIGHT_MODE: InFlightMode = "wait"
|
|
35
|
+
|
|
36
|
+
# How long a reservation is held before it counts as abandoned (30 seconds). It has to
|
|
37
|
+
# outlive the action; a waiting caller gives up after the same span.
|
|
38
|
+
DEFAULT_IN_FLIGHT_LEASE_SECONDS: int = 30
|
|
39
|
+
|
|
40
|
+
# How often a waiting caller re-reads the key.
|
|
41
|
+
IN_FLIGHT_POLL_INTERVAL_SECONDS: float = 0.05
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from idempotency_kit.core.fingerprint import fingerprint_of
|
|
8
|
+
from idempotency_kit.core.protocols.adapter import ResultAdapter
|
|
9
|
+
from idempotency_kit.core.services.aio.coordinator import AsyncIdempotencyCoordinator
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_POSITIONAL_KINDS = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _positional_index(func: Callable[..., Any], key_param: str) -> int | None:
|
|
19
|
+
"""Index at which ``key_param`` can arrive positionally, or ``None`` if it cannot."""
|
|
20
|
+
try:
|
|
21
|
+
parameters = list(inspect.signature(func).parameters.values())
|
|
22
|
+
except (TypeError, ValueError):
|
|
23
|
+
return None
|
|
24
|
+
for index, parameter in enumerate(parameters):
|
|
25
|
+
if parameter.name == key_param and parameter.kind in _POSITIONAL_KINDS:
|
|
26
|
+
return index
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fingerprint_resolver(
|
|
31
|
+
func: Callable[..., Any], fingerprint_params: tuple[str, ...] | None
|
|
32
|
+
) -> Callable[[tuple[Any, ...], dict[str, Any]], str] | None:
|
|
33
|
+
"""Build the function that fingerprints a call from the named parameters, or ``None`` when there are none.
|
|
34
|
+
|
|
35
|
+
Resolved at decoration time so that a parameter the function does not have, or a
|
|
36
|
+
signature ``inspect`` cannot describe, fails at import rather than on the first call.
|
|
37
|
+
"""
|
|
38
|
+
if not fingerprint_params:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
signature = inspect.signature(func)
|
|
42
|
+
except (TypeError, ValueError) as e:
|
|
43
|
+
raise TypeError(f"fingerprint_params needs a signature inspect can describe; {func!r} has none") from e
|
|
44
|
+
unknown = [name for name in fingerprint_params if name not in signature.parameters]
|
|
45
|
+
if unknown:
|
|
46
|
+
raise TypeError(f"fingerprint_params names parameters {func.__qualname__} does not have: {unknown}")
|
|
47
|
+
|
|
48
|
+
def resolve(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
49
|
+
# Defaults applied, so an argument passed at its default and one left out agree.
|
|
50
|
+
bound = signature.bind(*args, **kwargs)
|
|
51
|
+
bound.apply_defaults()
|
|
52
|
+
return fingerprint_of(**{name: bound.arguments[name] for name in fingerprint_params})
|
|
53
|
+
|
|
54
|
+
return resolve
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def async_idempotent(
|
|
58
|
+
operation: str,
|
|
59
|
+
adapter: ResultAdapter[T],
|
|
60
|
+
ttl_seconds: int | None = None,
|
|
61
|
+
key_param: str = "idempotency_key",
|
|
62
|
+
infra_param: str | None = None,
|
|
63
|
+
fingerprint_params: tuple[str, ...] | None = None,
|
|
64
|
+
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
65
|
+
"""Decorator for asynchronous idempotent operations.
|
|
66
|
+
|
|
67
|
+
Can be used on methods (finding coordinator in 'self') or standalone functions
|
|
68
|
+
(finding coordinator in arguments).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
operation: Unique operation name.
|
|
72
|
+
adapter: Result adapter for encoding/decoding.
|
|
73
|
+
ttl_seconds: Optional TTL for idempotency record in seconds.
|
|
74
|
+
If not provided, uses value from coordinator settings or global default.
|
|
75
|
+
key_param: Name of the argument containing the idempotency key. Read from the
|
|
76
|
+
keyword arguments, or from the positional arguments when the parameter can
|
|
77
|
+
be passed positionally.
|
|
78
|
+
infra_param: Optional name of the argument or attribute containing AsyncIdempotencyCoordinator.
|
|
79
|
+
If not provided, searches for AsyncIdempotencyCoordinator by type.
|
|
80
|
+
fingerprint_params: Names of the parameters whose values identify the request. Their
|
|
81
|
+
bound values, defaults applied, are hashed with ``fingerprint_of`` and stored with
|
|
82
|
+
the record; a later call under the same key with a different fingerprint raises
|
|
83
|
+
``IdempotencyKeyReuseError``. ``None`` means the key alone is the identity.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
87
|
+
key_index = _positional_index(func, key_param)
|
|
88
|
+
fingerprint = _fingerprint_resolver(func, fingerprint_params)
|
|
89
|
+
|
|
90
|
+
@functools.wraps(func)
|
|
91
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
92
|
+
# 1. Resolve idempotency key
|
|
93
|
+
idempotency_key = _resolve_key(args, kwargs)
|
|
94
|
+
if not idempotency_key:
|
|
95
|
+
return await func(*args, **kwargs)
|
|
96
|
+
|
|
97
|
+
# 2. Resolve coordinator
|
|
98
|
+
coordinator = _resolve_coordinator(args, kwargs)
|
|
99
|
+
|
|
100
|
+
if coordinator is None:
|
|
101
|
+
# Proceeding without idempotency keeps the operation available, but it is
|
|
102
|
+
# never what the decorator was put there for: say so loudly enough to be
|
|
103
|
+
# caught by whoever renamed the attribute or forgot the argument.
|
|
104
|
+
logger.warning(
|
|
105
|
+
"No idempotency coordinator found; running the operation without idempotency",
|
|
106
|
+
extra={
|
|
107
|
+
"operation": operation,
|
|
108
|
+
"idempotency_key": idempotency_key,
|
|
109
|
+
"infra_param": infra_param,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
return await func(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
# 3. Delegate to coordinator
|
|
115
|
+
if fingerprint is not None:
|
|
116
|
+
kwargs = {"idempotency_fingerprint": fingerprint(args, kwargs), **kwargs}
|
|
117
|
+
return await coordinator.coordinate(
|
|
118
|
+
operation,
|
|
119
|
+
idempotency_key,
|
|
120
|
+
ttl_seconds,
|
|
121
|
+
adapter,
|
|
122
|
+
func,
|
|
123
|
+
*args,
|
|
124
|
+
**kwargs,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _resolve_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
128
|
+
if key_param in kwargs:
|
|
129
|
+
return kwargs[key_param]
|
|
130
|
+
if key_index is not None and key_index < len(args):
|
|
131
|
+
return args[key_index]
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _resolve_coordinator(args: tuple[Any, ...], kwargs: dict[str, Any]) -> AsyncIdempotencyCoordinator | None:
|
|
135
|
+
# By name, if infra_param is provided
|
|
136
|
+
if infra_param:
|
|
137
|
+
named: AsyncIdempotencyCoordinator | None = kwargs.get(infra_param)
|
|
138
|
+
if named is None and args:
|
|
139
|
+
named = getattr(args[0], infra_param, None)
|
|
140
|
+
if named is not None:
|
|
141
|
+
return named
|
|
142
|
+
|
|
143
|
+
# By type, in the keyword arguments
|
|
144
|
+
for val in kwargs.values():
|
|
145
|
+
if isinstance(val, AsyncIdempotencyCoordinator):
|
|
146
|
+
return val
|
|
147
|
+
|
|
148
|
+
# By type, in the positional arguments, then in their attributes:
|
|
149
|
+
# DI often injects the coordinator into an attribute of 'self'.
|
|
150
|
+
for arg in args:
|
|
151
|
+
if isinstance(arg, AsyncIdempotencyCoordinator):
|
|
152
|
+
return arg
|
|
153
|
+
if hasattr(arg, "__dict__"):
|
|
154
|
+
for attr_val in vars(arg).values():
|
|
155
|
+
if isinstance(attr_val, AsyncIdempotencyCoordinator):
|
|
156
|
+
return attr_val
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
return wrapper
|
|
160
|
+
|
|
161
|
+
return decorator
|
|
@@ -54,3 +54,26 @@ class IdempotencyInvalidTTLError(IdempotencyError):
|
|
|
54
54
|
self.min_ttl = min_ttl
|
|
55
55
|
self.max_ttl = max_ttl
|
|
56
56
|
super().__init__(f"Invalid TTL {ttl_seconds}s. Must be between {min_ttl}s and {max_ttl}s")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class IdempotencyInProgressError(IdempotencyError):
|
|
60
|
+
"""Raised when another call with the same key is still running its action."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, operation: str, key: str) -> None:
|
|
63
|
+
self.operation = operation
|
|
64
|
+
self.key = key
|
|
65
|
+
super().__init__(f"Idempotency record for operation '{operation}', key '{key}' is still in flight")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class IdempotencyKeyReuseError(IdempotencyError):
|
|
69
|
+
"""Raised when a key is reused for a request with a different fingerprint."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, operation: str, key: str, stored_fingerprint: str, fingerprint: str) -> None:
|
|
72
|
+
self.operation = operation
|
|
73
|
+
self.key = key
|
|
74
|
+
self.stored_fingerprint = stored_fingerprint
|
|
75
|
+
self.fingerprint = fingerprint
|
|
76
|
+
super().__init__(
|
|
77
|
+
f"Idempotency key '{key}' for operation '{operation}' was used for a different request: "
|
|
78
|
+
f"stored fingerprint '{stored_fingerprint}', got '{fingerprint}'"
|
|
79
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The request fingerprint: what makes two calls under one key the same request."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic_core import to_jsonable_python
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fingerprint_of(**values: Any) -> str:
|
|
11
|
+
"""Hash the named values into the fingerprint of a request.
|
|
12
|
+
|
|
13
|
+
Pydantic models, dataclasses, UUIDs, datetimes, Decimals and the JSON types are turned
|
|
14
|
+
into JSON-able Python first, keys are sorted, and the SHA-256 hex digest is returned, so
|
|
15
|
+
two equal payloads built in different orders agree. This is what ``async_idempotent``
|
|
16
|
+
computes from ``fingerprint_params``; a caller of ``coordinate()`` uses it directly.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
pydantic_core.PydanticSerializationError: a value has no JSON form
|
|
20
|
+
"""
|
|
21
|
+
canonical = json.dumps(to_jsonable_python(values), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
22
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Idempotency record entity."""
|
|
2
2
|
|
|
3
3
|
from datetime import UTC, datetime, timedelta
|
|
4
|
-
from typing import Annotated, Self
|
|
4
|
+
from typing import Annotated, Literal, Self
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, StringConstraints, field_validator
|
|
7
7
|
|
|
@@ -34,6 +34,10 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
34
34
|
|
|
35
35
|
Stores the result of an operation so that repeated calls with the same key
|
|
36
36
|
return the cached result without re-executing business logic.
|
|
37
|
+
|
|
38
|
+
A *pending* record is an in-flight reservation: the key is taken, the action is still
|
|
39
|
+
running, and the result is not there yet. It is written before the action and replaced
|
|
40
|
+
by the completed record after it; its ``expires_at`` is the lease.
|
|
37
41
|
"""
|
|
38
42
|
|
|
39
43
|
model_config = ConfigDict(frozen=True)
|
|
@@ -45,6 +49,19 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
45
49
|
created_at: datetime = Field(description="When this record was created")
|
|
46
50
|
expires_at: datetime = Field(description="When this record should expire from cache")
|
|
47
51
|
|
|
52
|
+
# Records written before the field existed carry no status and read as completed.
|
|
53
|
+
status: Literal["pending", "completed"] = Field(
|
|
54
|
+
default="completed",
|
|
55
|
+
description="'pending' while the action runs under a reservation, 'completed' once the result is stored",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# What the caller said the request was; None means the record carries no fingerprint
|
|
59
|
+
# and a hit never raises for it.
|
|
60
|
+
fingerprint: str | None = Field(
|
|
61
|
+
default=None,
|
|
62
|
+
description="Fingerprint of the request the result belongs to; a hit with a different one is a key reuse",
|
|
63
|
+
)
|
|
64
|
+
|
|
48
65
|
@classmethod
|
|
49
66
|
def create(
|
|
50
67
|
cls,
|
|
@@ -52,6 +69,7 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
52
69
|
idempotency_key: str,
|
|
53
70
|
result: JsonValue,
|
|
54
71
|
ttl_seconds: float,
|
|
72
|
+
fingerprint: str | None = None,
|
|
55
73
|
) -> Self:
|
|
56
74
|
"""Create a new record with calculated expiration."""
|
|
57
75
|
now = datetime.now(UTC)
|
|
@@ -61,8 +79,34 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
61
79
|
result=result,
|
|
62
80
|
created_at=now,
|
|
63
81
|
expires_at=now + timedelta(seconds=ttl_seconds),
|
|
82
|
+
fingerprint=fingerprint,
|
|
64
83
|
)
|
|
65
84
|
|
|
85
|
+
@classmethod
|
|
86
|
+
def pending(
|
|
87
|
+
cls,
|
|
88
|
+
operation: str,
|
|
89
|
+
idempotency_key: str,
|
|
90
|
+
lease_seconds: float,
|
|
91
|
+
fingerprint: str | None = None,
|
|
92
|
+
) -> Self:
|
|
93
|
+
"""Create the in-flight reservation for an action that is about to run."""
|
|
94
|
+
now = datetime.now(UTC)
|
|
95
|
+
return cls(
|
|
96
|
+
operation=operation,
|
|
97
|
+
idempotency_key=idempotency_key,
|
|
98
|
+
result=None,
|
|
99
|
+
created_at=now,
|
|
100
|
+
expires_at=now + timedelta(seconds=lease_seconds),
|
|
101
|
+
status="pending",
|
|
102
|
+
fingerprint=fingerprint,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_pending(self) -> bool:
|
|
107
|
+
"""Whether this record is an in-flight reservation rather than a stored result."""
|
|
108
|
+
return self.status == "pending"
|
|
109
|
+
|
|
66
110
|
@property
|
|
67
111
|
def is_expired(self) -> bool:
|
|
68
112
|
"""Check if the record has expired."""
|
{idempotency_kit-0.1.1 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/repository.py
RENAMED
|
@@ -43,6 +43,22 @@ class AsyncIdempotencyRepository(Protocol):
|
|
|
43
43
|
"""
|
|
44
44
|
...
|
|
45
45
|
|
|
46
|
+
async def replace(self, record: IdempotencyRecord) -> None:
|
|
47
|
+
"""Write a record whether or not the key is already there.
|
|
48
|
+
|
|
49
|
+
The coordinator completes an in-flight reservation with it: the pending record
|
|
50
|
+
under the key gives way to the final one.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
record: Record to write
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
IdempotencyValidationError: If record fails validation
|
|
57
|
+
IdempotencyStorageError: If storage operation fails
|
|
58
|
+
IdempotencyError: If internal error (e.g. serialization) occurs
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
46
62
|
async def delete(self, operation: str, idempotency_key: str) -> bool:
|
|
47
63
|
"""Delete an idempotency record.
|
|
48
64
|
|