idempotency-kit 0.1.0__tar.gz → 0.2.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.1.0 → idempotency_kit-0.2.0}/.gitignore +3 -0
- idempotency_kit-0.2.0/CHANGELOG.md +47 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/PKG-INFO +12 -2
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/README.md +10 -0
- idempotency_kit-0.2.0/idempotency_kit/__version__.py +1 -0
- idempotency_kit-0.2.0/idempotency_kit/core/adapters/basic.py +61 -0
- idempotency_kit-0.2.0/idempotency_kit/core/constants.py +22 -0
- idempotency_kit-0.2.0/idempotency_kit/core/decorators/aio/idempotent.py +125 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/models/entities.py +3 -4
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/aio/coordinator.py +79 -21
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/domain.py +1 -1
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/coordinator.py +4 -1
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/redis.py +1 -1
- idempotency_kit-0.2.0/idempotency_kit/dishka/common.py +35 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/protocols.py +11 -1
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/repository.py +81 -87
- idempotency_kit-0.2.0/idempotency_kit/settings.py +25 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/pyproject.toml +6 -0
- idempotency_kit-0.1.0/CHANGELOG.md +0 -21
- idempotency_kit-0.1.0/idempotency_kit/__version__.py +0 -1
- idempotency_kit-0.1.0/idempotency_kit/core/adapters/basic.py +0 -43
- idempotency_kit-0.1.0/idempotency_kit/core/constants.py +0 -18
- idempotency_kit-0.1.0/idempotency_kit/core/decorators/aio/idempotent.py +0 -93
- idempotency_kit-0.1.0/idempotency_kit/dishka/common.py +0 -22
- idempotency_kit-0.1.0/idempotency_kit/settings.py +0 -18
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/LICENSE +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/adapters/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/exceptions.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/models/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/adapter.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/repository.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/metrics.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/metrics/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/metrics/prometheus.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/__init__.py +0 -0
- {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/py.typed +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/bedrock-python/idempotency-kit/compare/idempotency-kit-v0.1.1...idempotency-kit-v0.2.0) (2026-09-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* 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.
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* 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))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* 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))
|
|
18
|
+
|
|
19
|
+
## [0.1.1](https://github.com/bedrock-python/idempotency-kit/compare/idempotency-kit-v0.1.0...idempotency-kit-v0.1.1) (2026-08-28)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **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)
|
|
25
|
+
* **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)
|
|
26
|
+
* **release:** update version to 0.1.0 and fix release-please config ([016c6c4](https://github.com/bedrock-python/idempotency-kit/commit/016c6c42ba189291be56250192f9e6db7020f853))
|
|
27
|
+
* 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))
|
|
28
|
+
|
|
29
|
+
## 0.1.0 (2026-05-13)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Bug Fixes
|
|
33
|
+
|
|
34
|
+
* **ci:** remove coverage threshold from integration tests ([b00f037](https://github.com/bedrock-python/idempotency-kit/commit/b00f03742925922e261790b0ee649055c31fdaae))
|
|
35
|
+
* **ci:** set integration test coverage threshold to 50% ([ed4e790](https://github.com/bedrock-python/idempotency-kit/commit/ed4e7907ff977916b0be11a6597a090b9ae11cab))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
* rewrite README to match library style with badges ([cebded3](https://github.com/bedrock-python/idempotency-kit/commit/cebded360b4c0c37d7604efd230574b4464f47c8))
|
|
41
|
+
|
|
42
|
+
## Changelog
|
|
43
|
+
|
|
44
|
+
All notable changes to this project will be documented in this file.
|
|
45
|
+
|
|
46
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
47
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: idempotency-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -352,6 +361,7 @@ Both requests get the **same result** - idempotency guaranteed!
|
|
|
352
361
|
|
|
353
362
|
📚 **[Full Documentation](https://bedrock-python.github.io/idempotency-kit/)**
|
|
354
363
|
|
|
364
|
+
- [For AI agents](https://bedrock-python.github.io/idempotency-kit/agents/) — the whole library on one page
|
|
355
365
|
- [Quick Start](https://bedrock-python.github.io/idempotency-kit/quickstart/) — get started in 5 minutes
|
|
356
366
|
- [User Guide](https://bedrock-python.github.io/idempotency-kit/user_guide/) — detailed usage and patterns
|
|
357
367
|
- [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
|
|
@@ -119,6 +128,7 @@ Both requests get the **same result** - idempotency guaranteed!
|
|
|
119
128
|
|
|
120
129
|
📚 **[Full Documentation](https://bedrock-python.github.io/idempotency-kit/)**
|
|
121
130
|
|
|
131
|
+
- [For AI agents](https://bedrock-python.github.io/idempotency-kit/agents/) — the whole library on one page
|
|
122
132
|
- [Quick Start](https://bedrock-python.github.io/idempotency-kit/quickstart/) — get started in 5 minutes
|
|
123
133
|
- [User Guide](https://bedrock-python.github.io/idempotency-kit/user_guide/) — detailed usage and patterns
|
|
124
134
|
- [Architecture](https://bedrock-python.github.io/idempotency-kit/architecture/) — design principles
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.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,22 @@
|
|
|
1
|
+
"""Core constants for idempotency."""
|
|
2
|
+
|
|
3
|
+
# Key constraints
|
|
4
|
+
# Maximum allowed length for idempotency key.
|
|
5
|
+
MAX_KEY_LENGTH: int = 255
|
|
6
|
+
|
|
7
|
+
# Maximum allowed length for operation name.
|
|
8
|
+
MAX_OPERATION_LENGTH: int = 100
|
|
9
|
+
|
|
10
|
+
# TTL defaults
|
|
11
|
+
# The single source for both IdempotencyDomainService's own defaults and the field
|
|
12
|
+
# defaults of BaseIdempotencySettings, so the bounds do not depend on how the service
|
|
13
|
+
# was built.
|
|
14
|
+
|
|
15
|
+
# Default TTL in minutes when not explicitly specified (1 hour).
|
|
16
|
+
DEFAULT_TTL_MINUTES: int = 60
|
|
17
|
+
|
|
18
|
+
# Minimum allowed TTL in seconds (1 minute); the coordinator floors every TTL at a minute.
|
|
19
|
+
MIN_TTL_SECONDS: int = 60
|
|
20
|
+
|
|
21
|
+
# Maximum allowed TTL in seconds (30 days).
|
|
22
|
+
MAX_TTL_SECONDS: int = 30 * 24 * 3600
|
|
@@ -0,0 +1,125 @@
|
|
|
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.protocols.adapter import ResultAdapter
|
|
8
|
+
from idempotency_kit.core.services.aio.coordinator import AsyncIdempotencyCoordinator
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_POSITIONAL_KINDS = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _positional_index(func: Callable[..., Any], key_param: str) -> int | None:
|
|
18
|
+
"""Index at which ``key_param`` can arrive positionally, or ``None`` if it cannot."""
|
|
19
|
+
try:
|
|
20
|
+
parameters = list(inspect.signature(func).parameters.values())
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
return None
|
|
23
|
+
for index, parameter in enumerate(parameters):
|
|
24
|
+
if parameter.name == key_param and parameter.kind in _POSITIONAL_KINDS:
|
|
25
|
+
return index
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def async_idempotent(
|
|
30
|
+
operation: str,
|
|
31
|
+
adapter: ResultAdapter[T],
|
|
32
|
+
ttl_seconds: int | None = None,
|
|
33
|
+
key_param: str = "idempotency_key",
|
|
34
|
+
infra_param: str | None = None,
|
|
35
|
+
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
36
|
+
"""Decorator for asynchronous idempotent operations.
|
|
37
|
+
|
|
38
|
+
Can be used on methods (finding coordinator in 'self') or standalone functions
|
|
39
|
+
(finding coordinator in arguments).
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
operation: Unique operation name.
|
|
43
|
+
adapter: Result adapter for encoding/decoding.
|
|
44
|
+
ttl_seconds: Optional TTL for idempotency record in seconds.
|
|
45
|
+
If not provided, uses value from coordinator settings or global default.
|
|
46
|
+
key_param: Name of the argument containing the idempotency key. Read from the
|
|
47
|
+
keyword arguments, or from the positional arguments when the parameter can
|
|
48
|
+
be passed positionally.
|
|
49
|
+
infra_param: Optional name of the argument or attribute containing AsyncIdempotencyCoordinator.
|
|
50
|
+
If not provided, searches for AsyncIdempotencyCoordinator by type.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
54
|
+
key_index = _positional_index(func, key_param)
|
|
55
|
+
|
|
56
|
+
@functools.wraps(func)
|
|
57
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
58
|
+
# 1. Resolve idempotency key
|
|
59
|
+
idempotency_key = _resolve_key(args, kwargs)
|
|
60
|
+
if not idempotency_key:
|
|
61
|
+
return await func(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
# 2. Resolve coordinator
|
|
64
|
+
coordinator = _resolve_coordinator(args, kwargs)
|
|
65
|
+
|
|
66
|
+
if coordinator is None:
|
|
67
|
+
# Proceeding without idempotency keeps the operation available, but it is
|
|
68
|
+
# never what the decorator was put there for: say so loudly enough to be
|
|
69
|
+
# caught by whoever renamed the attribute or forgot the argument.
|
|
70
|
+
logger.warning(
|
|
71
|
+
"No idempotency coordinator found; running the operation without idempotency",
|
|
72
|
+
extra={
|
|
73
|
+
"operation": operation,
|
|
74
|
+
"idempotency_key": idempotency_key,
|
|
75
|
+
"infra_param": infra_param,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
return await func(*args, **kwargs)
|
|
79
|
+
|
|
80
|
+
# 3. Delegate to coordinator
|
|
81
|
+
return await coordinator.coordinate(
|
|
82
|
+
operation,
|
|
83
|
+
idempotency_key,
|
|
84
|
+
ttl_seconds,
|
|
85
|
+
adapter,
|
|
86
|
+
func,
|
|
87
|
+
*args,
|
|
88
|
+
**kwargs,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _resolve_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
92
|
+
if key_param in kwargs:
|
|
93
|
+
return kwargs[key_param]
|
|
94
|
+
if key_index is not None and key_index < len(args):
|
|
95
|
+
return args[key_index]
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def _resolve_coordinator(args: tuple[Any, ...], kwargs: dict[str, Any]) -> AsyncIdempotencyCoordinator | None:
|
|
99
|
+
# By name, if infra_param is provided
|
|
100
|
+
if infra_param:
|
|
101
|
+
named: AsyncIdempotencyCoordinator | None = kwargs.get(infra_param)
|
|
102
|
+
if named is None and args:
|
|
103
|
+
named = getattr(args[0], infra_param, None)
|
|
104
|
+
if named is not None:
|
|
105
|
+
return named
|
|
106
|
+
|
|
107
|
+
# By type, in the keyword arguments
|
|
108
|
+
for val in kwargs.values():
|
|
109
|
+
if isinstance(val, AsyncIdempotencyCoordinator):
|
|
110
|
+
return val
|
|
111
|
+
|
|
112
|
+
# By type, in the positional arguments, then in their attributes:
|
|
113
|
+
# DI often injects the coordinator into an attribute of 'self'.
|
|
114
|
+
for arg in args:
|
|
115
|
+
if isinstance(arg, AsyncIdempotencyCoordinator):
|
|
116
|
+
return arg
|
|
117
|
+
if hasattr(arg, "__dict__"):
|
|
118
|
+
for attr_val in vars(arg).values():
|
|
119
|
+
if isinstance(attr_val, AsyncIdempotencyCoordinator):
|
|
120
|
+
return attr_val
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
return wrapper
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Idempotency record entity."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import Mapping
|
|
4
3
|
from datetime import UTC, datetime, timedelta
|
|
5
4
|
from typing import Annotated, Self
|
|
6
5
|
|
|
@@ -39,8 +38,8 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
39
38
|
|
|
40
39
|
model_config = ConfigDict(frozen=True)
|
|
41
40
|
|
|
42
|
-
# Result
|
|
43
|
-
result:
|
|
41
|
+
# Result: whatever the adapter encoded — any JSON value, ``null`` for a void result.
|
|
42
|
+
result: JsonValue = Field(description="Cached operation result (any JSON value; null for void results)")
|
|
44
43
|
|
|
45
44
|
# Timing
|
|
46
45
|
created_at: datetime = Field(description="When this record was created")
|
|
@@ -51,7 +50,7 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
51
50
|
cls,
|
|
52
51
|
operation: str,
|
|
53
52
|
idempotency_key: str,
|
|
54
|
-
result:
|
|
53
|
+
result: JsonValue,
|
|
55
54
|
ttl_seconds: float,
|
|
56
55
|
) -> Self:
|
|
57
56
|
"""Create a new record with calculated expiration."""
|
{idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/aio/coordinator.py
RENAMED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import time
|
|
3
3
|
from collections.abc import Awaitable, Callable
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from idempotency_kit.core.exceptions import (
|
|
8
|
+
IdempotencyInvalidTTLError,
|
|
9
|
+
IdempotencyKeyCollisionError,
|
|
10
|
+
IdempotencyRecordExpiredError,
|
|
11
|
+
IdempotencyValidationError,
|
|
12
|
+
)
|
|
7
13
|
from idempotency_kit.core.protocols.adapter import ResultAdapter
|
|
8
14
|
from idempotency_kit.core.protocols.aio.repository import AsyncIdempotencyRepository
|
|
9
15
|
from idempotency_kit.core.protocols.metrics import IdempotencyMetricsProtocol, NoOpIdempotencyMetrics
|
|
@@ -14,8 +20,28 @@ T = TypeVar("T")
|
|
|
14
20
|
logger = logging.getLogger(__name__)
|
|
15
21
|
|
|
16
22
|
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _Hit(Generic[T]):
|
|
25
|
+
"""A decoded cached result.
|
|
26
|
+
|
|
27
|
+
Wrapping it keeps hit/miss a property of the *record*: an adapter may
|
|
28
|
+
legitimately decode to ``None`` (``VoidResultAdapter``, a stored JSON
|
|
29
|
+
``null``), which a bare ``None`` sentinel would misread as a miss.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
value: T
|
|
33
|
+
|
|
34
|
+
|
|
17
35
|
class AsyncIdempotencyCoordinator:
|
|
18
|
-
"""Coordinator for asynchronous idempotent operations.
|
|
36
|
+
"""Coordinator for asynchronous idempotent operations.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
repository: Storage for idempotency records.
|
|
40
|
+
domain_service: Record factory and TTL bounds.
|
|
41
|
+
operation_ttls: Per-operation TTL overrides in seconds; wins over the decorator.
|
|
42
|
+
metrics: Metrics collector for hits, misses, collisions, errors and latency.
|
|
43
|
+
enabled: Set to False to make every call a pass-through to the action.
|
|
44
|
+
"""
|
|
19
45
|
|
|
20
46
|
def __init__(
|
|
21
47
|
self,
|
|
@@ -23,11 +49,13 @@ class AsyncIdempotencyCoordinator:
|
|
|
23
49
|
domain_service: IdempotencyDomainService,
|
|
24
50
|
operation_ttls: dict[str, int] | None = None,
|
|
25
51
|
metrics: IdempotencyMetricsProtocol | None = None,
|
|
52
|
+
enabled: bool = True,
|
|
26
53
|
) -> None:
|
|
27
54
|
self._repo = repository
|
|
28
55
|
self._svc = domain_service
|
|
29
56
|
self._operation_ttls = operation_ttls or {}
|
|
30
57
|
self._metrics = metrics or NoOpIdempotencyMetrics()
|
|
58
|
+
self._enabled = enabled
|
|
31
59
|
|
|
32
60
|
async def coordinate(
|
|
33
61
|
self,
|
|
@@ -40,14 +68,18 @@ class AsyncIdempotencyCoordinator:
|
|
|
40
68
|
*args: Any,
|
|
41
69
|
**kwargs: Any,
|
|
42
70
|
) -> T:
|
|
43
|
-
"""Coordinate an idempotent operation.
|
|
44
|
-
|
|
71
|
+
"""Coordinate an idempotent operation.
|
|
72
|
+
|
|
73
|
+
With ``enabled=False`` the action is simply run: nothing is read, nothing is
|
|
74
|
+
written, and no metric is recorded.
|
|
75
|
+
"""
|
|
76
|
+
if not self._enabled or not idempotency_key:
|
|
45
77
|
return await action(*args, **kwargs)
|
|
46
78
|
|
|
47
79
|
# 1. Try to get from storage
|
|
48
|
-
|
|
49
|
-
if
|
|
50
|
-
return
|
|
80
|
+
hit = await self._try_get_cached(operation, idempotency_key, adapter)
|
|
81
|
+
if hit is not None:
|
|
82
|
+
return hit.value
|
|
51
83
|
|
|
52
84
|
# 2. Execute business logic
|
|
53
85
|
result = await action(*args, **kwargs)
|
|
@@ -68,18 +100,18 @@ class AsyncIdempotencyCoordinator:
|
|
|
68
100
|
operation: str,
|
|
69
101
|
idempotency_key: str,
|
|
70
102
|
adapter: ResultAdapter[T],
|
|
71
|
-
) -> T | None:
|
|
103
|
+
) -> _Hit[T] | None:
|
|
72
104
|
"""Try to fetch and decode result from storage. Returns None on miss or error."""
|
|
73
105
|
start_time = time.perf_counter()
|
|
74
106
|
try:
|
|
75
|
-
|
|
76
|
-
if
|
|
107
|
+
hit = await self._get_and_decode(operation, idempotency_key, adapter)
|
|
108
|
+
if hit is not None:
|
|
77
109
|
self._metrics.record_hit(operation)
|
|
78
110
|
logger.info(
|
|
79
111
|
"Idempotency cache hit",
|
|
80
112
|
extra={"operation": operation, "idempotency_key": idempotency_key},
|
|
81
113
|
)
|
|
82
|
-
return
|
|
114
|
+
return hit
|
|
83
115
|
self._metrics.record_miss(operation)
|
|
84
116
|
except Exception:
|
|
85
117
|
self._metrics.record_error(operation, "storage_get_error")
|
|
@@ -109,6 +141,22 @@ class AsyncIdempotencyCoordinator:
|
|
|
109
141
|
)
|
|
110
142
|
except IdempotencyKeyCollisionError:
|
|
111
143
|
return await self._handle_collision(operation, idempotency_key, result, adapter)
|
|
144
|
+
except (IdempotencyValidationError, IdempotencyInvalidTTLError):
|
|
145
|
+
# The record itself is invalid — the adapter encoded something the
|
|
146
|
+
# storage format cannot hold, or the TTL is out of range. No retry can
|
|
147
|
+
# fix that, so it is reported as a contract violation rather than a
|
|
148
|
+
# storage blip. The result is still returned: the action has already
|
|
149
|
+
# run, and raising here would make the caller retry a completed
|
|
150
|
+
# operation — the one thing an idempotency layer must never cause.
|
|
151
|
+
self._metrics.record_error(operation, "record_validation_error")
|
|
152
|
+
logger.exception(
|
|
153
|
+
"Idempotency record rejected; this operation will not be cached",
|
|
154
|
+
extra={
|
|
155
|
+
"operation": operation,
|
|
156
|
+
"idempotency_key": idempotency_key,
|
|
157
|
+
"adapter": type(adapter).__name__,
|
|
158
|
+
},
|
|
159
|
+
)
|
|
112
160
|
except Exception:
|
|
113
161
|
self._metrics.record_error(operation, "storage_save_error")
|
|
114
162
|
logger.exception(
|
|
@@ -125,10 +173,20 @@ class AsyncIdempotencyCoordinator:
|
|
|
125
173
|
operation: str,
|
|
126
174
|
idempotency_key: str,
|
|
127
175
|
adapter: ResultAdapter[T],
|
|
128
|
-
) -> T | None:
|
|
129
|
-
"""Fetch record from repository and decode it safely."""
|
|
176
|
+
) -> _Hit[T] | None:
|
|
177
|
+
"""Fetch record from repository and decode it safely; ``None`` means no usable record."""
|
|
130
178
|
cached = await self._repo.get(operation, idempotency_key)
|
|
131
|
-
if
|
|
179
|
+
if cached is None:
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
# The protocol asks a repository not to return an expired record, but expiry is
|
|
183
|
+
# the domain's rule to enforce, and a backend without native expiry cannot.
|
|
184
|
+
self._svc.validate_record(cached)
|
|
185
|
+
except IdempotencyRecordExpiredError:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"Idempotency record expired; treating it as a miss",
|
|
188
|
+
extra={"operation": operation, "idempotency_key": idempotency_key},
|
|
189
|
+
)
|
|
132
190
|
return None
|
|
133
191
|
return self._decode_safely(adapter, cached.result, operation, idempotency_key)
|
|
134
192
|
|
|
@@ -163,9 +221,9 @@ class AsyncIdempotencyCoordinator:
|
|
|
163
221
|
extra={"operation": operation, "idempotency_key": idempotency_key},
|
|
164
222
|
)
|
|
165
223
|
try:
|
|
166
|
-
|
|
167
|
-
if
|
|
168
|
-
return
|
|
224
|
+
winner = await self._get_and_decode(operation, idempotency_key, adapter)
|
|
225
|
+
if winner is not None:
|
|
226
|
+
return winner.value
|
|
169
227
|
except Exception:
|
|
170
228
|
logger.exception(
|
|
171
229
|
"Failed to fetch concurrent result after collision",
|
|
@@ -179,10 +237,10 @@ class AsyncIdempotencyCoordinator:
|
|
|
179
237
|
data: Any,
|
|
180
238
|
operation: str,
|
|
181
239
|
idempotency_key: str,
|
|
182
|
-
) -> T | None:
|
|
240
|
+
) -> _Hit[T] | None:
|
|
183
241
|
"""Try to decode data using adapter. Returns None and logs error on failure."""
|
|
184
242
|
try:
|
|
185
|
-
return adapter.decode(data)
|
|
243
|
+
return _Hit(adapter.decode(data))
|
|
186
244
|
except Exception:
|
|
187
245
|
logger.exception(
|
|
188
246
|
"Idempotency decode error",
|
|
@@ -23,7 +23,7 @@ class AsyncIdempotencyCoordinatorProvider(Provider):
|
|
|
23
23
|
repository: AsyncIdempotencyRepository,
|
|
24
24
|
domain_service: IdempotencyDomainService,
|
|
25
25
|
settings: IdempotencySettingsProtocol,
|
|
26
|
-
metrics: IdempotencyMetricsProtocol
|
|
26
|
+
metrics: IdempotencyMetricsProtocol,
|
|
27
27
|
) -> AsyncIdempotencyCoordinator:
|
|
28
28
|
"""Provide idempotency coordinator."""
|
|
29
29
|
return AsyncIdempotencyCoordinator(
|
|
@@ -31,4 +31,7 @@ class AsyncIdempotencyCoordinatorProvider(Provider):
|
|
|
31
31
|
domain_service=domain_service,
|
|
32
32
|
operation_ttls=settings.operation_ttls,
|
|
33
33
|
metrics=metrics,
|
|
34
|
+
# Read defensively: settings objects written against the protocol before
|
|
35
|
+
# ``enabled`` was part of it stay valid, and they mean enabled.
|
|
36
|
+
enabled=getattr(settings, "enabled", True),
|
|
34
37
|
)
|
|
@@ -19,7 +19,7 @@ class AsyncRedisIdempotencyProvider(Provider):
|
|
|
19
19
|
self,
|
|
20
20
|
redis: AsyncRedisClient,
|
|
21
21
|
settings: IdempotencySettingsProtocol,
|
|
22
|
-
metrics: IdempotencyMetricsProtocol
|
|
22
|
+
metrics: IdempotencyMetricsProtocol,
|
|
23
23
|
) -> AsyncIdempotencyRepository:
|
|
24
24
|
"""Provide idempotency repository."""
|
|
25
25
|
return RedisAsyncIdempotencyRepository(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Common Dishka providers for idempotency."""
|
|
2
|
+
|
|
3
|
+
from dishka import Provider, Scope, provide
|
|
4
|
+
|
|
5
|
+
from idempotency_kit import IdempotencyDomainService, IdempotencyMetricsProtocol, NoOpIdempotencyMetrics
|
|
6
|
+
from idempotency_kit.infra.metrics.prometheus import PrometheusIdempotencyMetrics
|
|
7
|
+
|
|
8
|
+
from .protocols import IdempotencySettingsProtocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IdempotencyProvider(Provider):
|
|
12
|
+
"""Provider for the idempotency domain service and the metrics collector (sync / framework-agnostic).
|
|
13
|
+
|
|
14
|
+
The metrics collector is a single APP-scoped instance shared by the repository and the coordinator,
|
|
15
|
+
which is also what ``PrometheusIdempotencyMetrics`` needs — it registers its collectors once per process.
|
|
16
|
+
An application with another metrics backend overrides it with ``@provide(override=True)`` in a later provider.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
scope = Scope.APP
|
|
20
|
+
|
|
21
|
+
@provide
|
|
22
|
+
def get_service(self, settings: IdempotencySettingsProtocol) -> IdempotencyDomainService:
|
|
23
|
+
"""Provide idempotency domain service."""
|
|
24
|
+
return IdempotencyDomainService(
|
|
25
|
+
default_ttl_minutes=settings.default_ttl_minutes,
|
|
26
|
+
min_ttl_seconds=settings.min_ttl_seconds,
|
|
27
|
+
max_ttl_seconds=settings.max_ttl_seconds,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@provide
|
|
31
|
+
def get_metrics(self, settings: IdempotencySettingsProtocol) -> IdempotencyMetricsProtocol:
|
|
32
|
+
"""Provide the metrics collector: Prometheus when ``settings.metrics_enabled``, a no-op otherwise."""
|
|
33
|
+
if settings.metrics_enabled:
|
|
34
|
+
return PrometheusIdempotencyMetrics()
|
|
35
|
+
return NoOpIdempotencyMetrics()
|
|
@@ -7,6 +7,14 @@ from typing import Protocol, runtime_checkable
|
|
|
7
7
|
class IdempotencySettingsProtocol(Protocol):
|
|
8
8
|
"""Protocol for idempotency settings."""
|
|
9
9
|
|
|
10
|
+
@property
|
|
11
|
+
def enabled(self) -> bool:
|
|
12
|
+
"""Whether the shipped coordinator applies idempotency at all.
|
|
13
|
+
|
|
14
|
+
A settings object without the attribute is read as enabled.
|
|
15
|
+
"""
|
|
16
|
+
...
|
|
17
|
+
|
|
10
18
|
@property
|
|
11
19
|
def key_prefix(self) -> str:
|
|
12
20
|
"""Key prefix for Redis."""
|
|
@@ -14,7 +22,9 @@ class IdempotencySettingsProtocol(Protocol):
|
|
|
14
22
|
|
|
15
23
|
@property
|
|
16
24
|
def metrics_enabled(self) -> bool:
|
|
17
|
-
"""Whether
|
|
25
|
+
"""Whether the shipped providers wire ``PrometheusIdempotencyMetrics``
|
|
26
|
+
(needs the ``prometheus`` extra); a no-op collector otherwise.
|
|
27
|
+
"""
|
|
18
28
|
...
|
|
19
29
|
|
|
20
30
|
@property
|
|
@@ -43,6 +43,12 @@ class RedisAsyncIdempotencyRepository(AsyncIdempotencyRepository):
|
|
|
43
43
|
"""Redis implementation of idempotency repository.
|
|
44
44
|
|
|
45
45
|
Stores records as JSON with automatic TTL expiration.
|
|
46
|
+
|
|
47
|
+
It records only the metrics the coordinator cannot produce for it -- errors, the bulk
|
|
48
|
+
hit and miss counts of ``get_many``, and the latency of ``delete`` and ``get_many``.
|
|
49
|
+
Hit, miss, collision and the latency of ``get`` and ``save`` belong to
|
|
50
|
+
``AsyncIdempotencyCoordinator``, so that a collector shared by both counts each
|
|
51
|
+
operation once.
|
|
46
52
|
"""
|
|
47
53
|
|
|
48
54
|
def __init__(
|
|
@@ -61,8 +67,8 @@ class RedisAsyncIdempotencyRepository(AsyncIdempotencyRepository):
|
|
|
61
67
|
"""
|
|
62
68
|
if not _HAS_REDIS or not _HAS_ORJSON:
|
|
63
69
|
raise ImportError(
|
|
64
|
-
"RedisAsyncIdempotencyRepository requires redis
|
|
65
|
-
"Install them with: pip install idempotency-kit[redis
|
|
70
|
+
"RedisAsyncIdempotencyRepository requires redis and orjson. "
|
|
71
|
+
"Install them with: pip install idempotency-kit[redis]"
|
|
66
72
|
)
|
|
67
73
|
self._redis = redis
|
|
68
74
|
self._key_prefix = key_prefix
|
|
@@ -74,11 +80,11 @@ class RedisAsyncIdempotencyRepository(AsyncIdempotencyRepository):
|
|
|
74
80
|
|
|
75
81
|
def _deserialize_record(
|
|
76
82
|
self,
|
|
77
|
-
data: bytes,
|
|
83
|
+
data: bytes | str,
|
|
78
84
|
operation: str,
|
|
79
85
|
idempotency_key: str,
|
|
80
86
|
) -> IdempotencyRecord | None:
|
|
81
|
-
"""Deserialize record from JSON bytes.
|
|
87
|
+
"""Deserialize record from its JSON payload (bytes, or str with ``decode_responses``).
|
|
82
88
|
|
|
83
89
|
Returns None if record is expired.
|
|
84
90
|
Raises IdempotencyValidationError or IdempotencyError on corruption.
|
|
@@ -134,49 +140,42 @@ class RedisAsyncIdempotencyRepository(AsyncIdempotencyRepository):
|
|
|
134
140
|
IdempotencyStorageError: If Redis operation fails
|
|
135
141
|
IdempotencyError: If data is corrupted
|
|
136
142
|
"""
|
|
137
|
-
|
|
143
|
+
self._validate_inputs(operation, idempotency_key)
|
|
144
|
+
key = self._make_key(operation, idempotency_key)
|
|
138
145
|
try:
|
|
139
|
-
self.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
operation=operation,
|
|
152
|
-
original_error=e,
|
|
153
|
-
) from e
|
|
146
|
+
data = await self._redis.get(key)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
self._metrics.record_error(operation, type(e).__name__)
|
|
149
|
+
logger.exception(
|
|
150
|
+
"Redis error during get",
|
|
151
|
+
extra={"operation": operation, "key": idempotency_key},
|
|
152
|
+
)
|
|
153
|
+
raise IdempotencyStorageError(
|
|
154
|
+
f"Redis storage failure during get for {operation}",
|
|
155
|
+
operation=operation,
|
|
156
|
+
original_error=e,
|
|
157
|
+
) from e
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return None
|
|
159
|
+
if not data:
|
|
160
|
+
return None
|
|
158
161
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
record = self._deserialize_record(data, operation, idempotency_key)
|
|
163
|
+
if record is None:
|
|
164
|
+
logger.warning(
|
|
165
|
+
"Found expired record in Redis (TTL mismatch). Deleting it.",
|
|
166
|
+
extra={"operation": operation, "key": idempotency_key},
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
await self._redis.delete(key)
|
|
170
|
+
except Exception:
|
|
162
171
|
logger.warning(
|
|
163
|
-
"
|
|
172
|
+
"Failed to delete expired record from Redis",
|
|
164
173
|
extra={"operation": operation, "key": idempotency_key},
|
|
174
|
+
exc_info=True,
|
|
165
175
|
)
|
|
166
|
-
|
|
167
|
-
await self._redis.delete(key)
|
|
168
|
-
except Exception:
|
|
169
|
-
logger.warning(
|
|
170
|
-
"Failed to delete expired record from Redis",
|
|
171
|
-
extra={"operation": operation, "key": idempotency_key},
|
|
172
|
-
exc_info=True,
|
|
173
|
-
)
|
|
174
|
-
return None
|
|
176
|
+
return None
|
|
175
177
|
|
|
176
|
-
|
|
177
|
-
return record
|
|
178
|
-
finally:
|
|
179
|
-
self._metrics.record_latency(operation, "get", time.perf_counter() - start)
|
|
178
|
+
return record
|
|
180
179
|
|
|
181
180
|
async def save(
|
|
182
181
|
self,
|
|
@@ -193,60 +192,55 @@ class RedisAsyncIdempotencyRepository(AsyncIdempotencyRepository):
|
|
|
193
192
|
IdempotencyStorageError: If Redis operation fails
|
|
194
193
|
IdempotencyError: If serialization fails
|
|
195
194
|
"""
|
|
196
|
-
start = time.perf_counter()
|
|
197
195
|
operation = record.operation
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
key = self._make_key(operation, record.idempotency_key)
|
|
196
|
+
self._validate_inputs(operation, record.idempotency_key)
|
|
197
|
+
key = self._make_key(operation, record.idempotency_key)
|
|
201
198
|
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
# Calculate TTL
|
|
200
|
+
ttl_seconds = math.ceil(record.ttl_seconds)
|
|
204
201
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
202
|
+
if ttl_seconds <= 0:
|
|
203
|
+
self._metrics.record_error(operation, "validation_error")
|
|
204
|
+
logger.warning(
|
|
205
|
+
"Attempted to save already expired record",
|
|
206
|
+
extra={"operation": operation, "key": record.idempotency_key},
|
|
207
|
+
)
|
|
208
|
+
raise IdempotencyValidationError("Cannot save already expired record")
|
|
212
209
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
210
|
+
# Serialize record
|
|
211
|
+
try:
|
|
212
|
+
data = orjson.dumps(record.model_dump(mode="json")) if _HAS_ORJSON else record.model_dump_json()
|
|
213
|
+
except Exception as e:
|
|
214
|
+
self._metrics.record_error(operation, "serialization_error")
|
|
215
|
+
logger.exception(
|
|
216
|
+
"Failed to serialize record",
|
|
217
|
+
extra={"operation": operation, "key": record.idempotency_key},
|
|
218
|
+
)
|
|
219
|
+
raise IdempotencyError("Serialization failed") from e
|
|
223
220
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
221
|
+
# Use SET with NX (only if key doesn't exist) and EX (expiration)
|
|
222
|
+
try:
|
|
223
|
+
was_set = await self._redis.set(key, data, ex=ttl_seconds, nx=True)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
self._metrics.record_error(operation, type(e).__name__)
|
|
226
|
+
logger.exception(
|
|
227
|
+
"Redis error during save",
|
|
228
|
+
extra={"operation": operation, "key": record.idempotency_key},
|
|
229
|
+
)
|
|
230
|
+
# In case of Redis error, we cannot guarantee idempotency.
|
|
231
|
+
raise IdempotencyStorageError(
|
|
232
|
+
"Redis storage failure during save",
|
|
233
|
+
operation=operation,
|
|
234
|
+
original_error=e,
|
|
235
|
+
) from e
|
|
239
236
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
raise IdempotencyKeyCollisionError(operation, record.idempotency_key)
|
|
237
|
+
if not was_set:
|
|
238
|
+
raise IdempotencyKeyCollisionError(operation, record.idempotency_key)
|
|
243
239
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
finally:
|
|
249
|
-
self._metrics.record_latency(operation, "save", time.perf_counter() - start)
|
|
240
|
+
logger.debug(
|
|
241
|
+
"Saved idempotency record",
|
|
242
|
+
extra={"operation": operation, "key": record.idempotency_key, "ttl_seconds": ttl_seconds},
|
|
243
|
+
)
|
|
250
244
|
|
|
251
245
|
async def delete(self, operation: str, idempotency_key: str) -> bool:
|
|
252
246
|
"""Delete record from Redis.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Settings for idempotency kit."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from .core.constants import DEFAULT_TTL_MINUTES, MAX_TTL_SECONDS, MIN_TTL_SECONDS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseIdempotencySettings(BaseModel):
|
|
9
|
+
"""Common configuration for idempotency kit."""
|
|
10
|
+
|
|
11
|
+
enabled: bool = Field(
|
|
12
|
+
default=True,
|
|
13
|
+
description="Whether the coordinator applies idempotency; False makes every call a pass-through",
|
|
14
|
+
)
|
|
15
|
+
key_prefix: str = Field(description="Redis key prefix for idempotency records")
|
|
16
|
+
metrics_enabled: bool = Field(default=False, description="Whether idempotency metrics are enabled")
|
|
17
|
+
default_ttl_minutes: int = Field(
|
|
18
|
+
default=DEFAULT_TTL_MINUTES, description="Default TTL for records in minutes (1 hour)"
|
|
19
|
+
)
|
|
20
|
+
min_ttl_seconds: int = Field(default=MIN_TTL_SECONDS, description="Minimum allowed TTL in seconds (1 minute)")
|
|
21
|
+
max_ttl_seconds: int = Field(default=MAX_TTL_SECONDS, description="Maximum allowed TTL in seconds (30 days)")
|
|
22
|
+
operation_ttls: dict[str, int] = Field(
|
|
23
|
+
default_factory=dict,
|
|
24
|
+
description="Operation-specific TTLs in seconds (overrides decorator and default)",
|
|
25
|
+
)
|
|
@@ -50,6 +50,8 @@ test = [
|
|
|
50
50
|
"fakeredis>=2.28.2",
|
|
51
51
|
"redis>=5.0.0",
|
|
52
52
|
"orjson>=3.11.7,<4.0.0",
|
|
53
|
+
"dishka>=1.0.0",
|
|
54
|
+
"prometheus-client>=0.15.0",
|
|
53
55
|
]
|
|
54
56
|
docs = [
|
|
55
57
|
"zensical>=0.0.37",
|
|
@@ -98,6 +100,10 @@ warn_unreachable = true
|
|
|
98
100
|
[tool.ruff]
|
|
99
101
|
line-length = 120
|
|
100
102
|
target-version = "py311"
|
|
103
|
+
# The Markdown guides hold fragments -- a keyword argument on its own, a body
|
|
104
|
+
# without its def -- that read as code to a formatter and come out as
|
|
105
|
+
# something else. ruff 0.16 formats fenced Python in Markdown by default.
|
|
106
|
+
extend-exclude = ["*.md"]
|
|
101
107
|
|
|
102
108
|
[tool.ruff.lint]
|
|
103
109
|
select = ["F", "E", "W", "I", "B", "N", "S", "C4", "DTZ", "SIM", "TRY", "PERF", "RUF", "UP", "ANN", "T20", "PTH", "PLC", "PLE", "PLW"]
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0 (2026-05-13)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
### Bug Fixes
|
|
7
|
-
|
|
8
|
-
* **ci:** remove coverage threshold from integration tests ([b00f037](https://github.com/bedrock-python/idempotency-kit/commit/b00f03742925922e261790b0ee649055c31fdaae))
|
|
9
|
-
* **ci:** set integration test coverage threshold to 50% ([ed4e790](https://github.com/bedrock-python/idempotency-kit/commit/ed4e7907ff977916b0be11a6597a090b9ae11cab))
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
### Documentation
|
|
13
|
-
|
|
14
|
-
* rewrite README to match library style with badges ([cebded3](https://github.com/bedrock-python/idempotency-kit/commit/cebded360b4c0c37d7604efd230574b4464f47c8))
|
|
15
|
-
|
|
16
|
-
## Changelog
|
|
17
|
-
|
|
18
|
-
All notable changes to this project will be documented in this file.
|
|
19
|
-
|
|
20
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
21
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
from typing import Any, TypeVar
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
|
|
5
|
-
from idempotency_kit.core.protocols.adapter import ResultAdapter
|
|
6
|
-
|
|
7
|
-
T = TypeVar("T", bound=BaseModel)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class PydanticResultAdapter(ResultAdapter[T]):
|
|
11
|
-
"""Adapter for Pydantic models."""
|
|
12
|
-
|
|
13
|
-
def __init__(self, model_class: type[T]) -> None:
|
|
14
|
-
self.model_class = model_class
|
|
15
|
-
|
|
16
|
-
def encode(self, value: T) -> Any:
|
|
17
|
-
return value.model_dump(mode="json") if value else None
|
|
18
|
-
|
|
19
|
-
def decode(self, data: Any) -> T:
|
|
20
|
-
if not data:
|
|
21
|
-
msg = "cannot decode empty idempotency payload"
|
|
22
|
-
raise ValueError(msg)
|
|
23
|
-
return self.model_class.model_validate(data)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class JsonResultAdapter(ResultAdapter[Any]):
|
|
27
|
-
"""Adapter for JSON-serializable types (dict, list, etc.)."""
|
|
28
|
-
|
|
29
|
-
def encode(self, value: Any) -> Any:
|
|
30
|
-
return value
|
|
31
|
-
|
|
32
|
-
def decode(self, data: Any) -> Any:
|
|
33
|
-
return data
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class VoidResultAdapter(ResultAdapter[None]):
|
|
37
|
-
"""Adapter for functions that return None."""
|
|
38
|
-
|
|
39
|
-
def encode(self, value: None) -> Any:
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
def decode(self, data: Any) -> None:
|
|
43
|
-
return None
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"""Core constants for idempotency."""
|
|
2
|
-
|
|
3
|
-
# Key constraints
|
|
4
|
-
# Maximum allowed length for idempotency key.
|
|
5
|
-
MAX_KEY_LENGTH: int = 255
|
|
6
|
-
|
|
7
|
-
# Maximum allowed length for operation name.
|
|
8
|
-
MAX_OPERATION_LENGTH: int = 100
|
|
9
|
-
|
|
10
|
-
# TTL defaults
|
|
11
|
-
# Default TTL in minutes when not explicitly specified.
|
|
12
|
-
DEFAULT_TTL_MINUTES: int = 30
|
|
13
|
-
|
|
14
|
-
# Minimum allowed TTL in seconds (1 minute).
|
|
15
|
-
MIN_TTL_SECONDS: int = 60
|
|
16
|
-
|
|
17
|
-
# Maximum allowed TTL in seconds (24 hours).
|
|
18
|
-
MAX_TTL_SECONDS: int = 86400 # 24 hours
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import functools
|
|
2
|
-
from collections.abc import Awaitable, Callable
|
|
3
|
-
from typing import Any, TypeVar
|
|
4
|
-
|
|
5
|
-
from idempotency_kit.core.protocols.adapter import ResultAdapter
|
|
6
|
-
from idempotency_kit.core.services.aio.coordinator import AsyncIdempotencyCoordinator
|
|
7
|
-
|
|
8
|
-
T = TypeVar("T")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def async_idempotent(
|
|
12
|
-
operation: str,
|
|
13
|
-
adapter: ResultAdapter[T],
|
|
14
|
-
ttl_seconds: int | None = None,
|
|
15
|
-
key_param: str = "idempotency_key",
|
|
16
|
-
infra_param: str | None = None,
|
|
17
|
-
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
18
|
-
"""Decorator for asynchronous idempotent operations.
|
|
19
|
-
|
|
20
|
-
Can be used on methods (finding coordinator in 'self') or standalone functions
|
|
21
|
-
(finding coordinator in arguments).
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
operation: Unique operation name.
|
|
25
|
-
adapter: Result adapter for encoding/decoding.
|
|
26
|
-
ttl_seconds: Optional TTL for idempotency record in seconds.
|
|
27
|
-
If not provided, uses value from coordinator settings or global default.
|
|
28
|
-
key_param: Name of the argument containing the idempotency key.
|
|
29
|
-
infra_param: Optional name of the argument or attribute containing AsyncIdempotencyCoordinator.
|
|
30
|
-
If not provided, searches for AsyncIdempotencyCoordinator by type.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
34
|
-
@functools.wraps(func)
|
|
35
|
-
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
36
|
-
# 1. Resolve idempotency key
|
|
37
|
-
idempotency_key = kwargs.get(key_param)
|
|
38
|
-
if not idempotency_key:
|
|
39
|
-
return await func(*args, **kwargs)
|
|
40
|
-
|
|
41
|
-
# 2. Resolve coordinator
|
|
42
|
-
coordinator: AsyncIdempotencyCoordinator | None = None
|
|
43
|
-
|
|
44
|
-
# 2.1. Try to find by name if infra_param is provided
|
|
45
|
-
if infra_param:
|
|
46
|
-
if infra_param in kwargs:
|
|
47
|
-
coordinator = kwargs[infra_param]
|
|
48
|
-
elif args and hasattr(args[0], infra_param):
|
|
49
|
-
coordinator = getattr(args[0], infra_param)
|
|
50
|
-
|
|
51
|
-
# 2.2. Try to find by type if not found or infra_param is None
|
|
52
|
-
if not coordinator:
|
|
53
|
-
# Search in kwargs
|
|
54
|
-
for val in kwargs.values():
|
|
55
|
-
if isinstance(val, AsyncIdempotencyCoordinator):
|
|
56
|
-
coordinator = val
|
|
57
|
-
break
|
|
58
|
-
|
|
59
|
-
# Search in args (skipping self if it was already checked)
|
|
60
|
-
if not coordinator:
|
|
61
|
-
for arg in args:
|
|
62
|
-
if isinstance(arg, AsyncIdempotencyCoordinator):
|
|
63
|
-
coordinator = arg
|
|
64
|
-
break
|
|
65
|
-
# Also check self attributes if arg is 'self'
|
|
66
|
-
# We do this because DI often injects into attributes
|
|
67
|
-
if hasattr(arg, "__dict__"):
|
|
68
|
-
for attr_val in vars(arg).values():
|
|
69
|
-
if isinstance(attr_val, AsyncIdempotencyCoordinator):
|
|
70
|
-
coordinator = attr_val
|
|
71
|
-
break
|
|
72
|
-
if coordinator:
|
|
73
|
-
break
|
|
74
|
-
|
|
75
|
-
if not coordinator:
|
|
76
|
-
# If no coordinator found but key is present, we might want to fail or proceed
|
|
77
|
-
# Proceeding without idempotency is safer but should probably be logged
|
|
78
|
-
return await func(*args, **kwargs)
|
|
79
|
-
|
|
80
|
-
# 3. Delegate to coordinator
|
|
81
|
-
return await coordinator.coordinate(
|
|
82
|
-
operation,
|
|
83
|
-
idempotency_key,
|
|
84
|
-
ttl_seconds,
|
|
85
|
-
adapter,
|
|
86
|
-
func,
|
|
87
|
-
*args,
|
|
88
|
-
**kwargs,
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
return wrapper
|
|
92
|
-
|
|
93
|
-
return decorator
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Common Dishka providers for idempotency."""
|
|
2
|
-
|
|
3
|
-
from dishka import Provider, Scope, provide
|
|
4
|
-
|
|
5
|
-
from idempotency_kit import IdempotencyDomainService
|
|
6
|
-
|
|
7
|
-
from .protocols import IdempotencySettingsProtocol
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class IdempotencyProvider(Provider):
|
|
11
|
-
"""Provider for idempotency domain service (sync / framework-agnostic)."""
|
|
12
|
-
|
|
13
|
-
scope = Scope.APP
|
|
14
|
-
|
|
15
|
-
@provide
|
|
16
|
-
def get_service(self, settings: IdempotencySettingsProtocol) -> IdempotencyDomainService:
|
|
17
|
-
"""Provide idempotency domain service."""
|
|
18
|
-
return IdempotencyDomainService(
|
|
19
|
-
default_ttl_minutes=settings.default_ttl_minutes,
|
|
20
|
-
min_ttl_seconds=settings.min_ttl_seconds,
|
|
21
|
-
max_ttl_seconds=settings.max_ttl_seconds,
|
|
22
|
-
)
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"""Settings for idempotency kit."""
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel, Field
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class BaseIdempotencySettings(BaseModel):
|
|
7
|
-
"""Common configuration for idempotency kit."""
|
|
8
|
-
|
|
9
|
-
enabled: bool = Field(default=True, description="Whether idempotency is enabled")
|
|
10
|
-
key_prefix: str = Field(description="Redis key prefix for idempotency records")
|
|
11
|
-
metrics_enabled: bool = Field(default=False, description="Whether idempotency metrics are enabled")
|
|
12
|
-
default_ttl_minutes: int = Field(default=60, description="Default TTL for records in minutes")
|
|
13
|
-
min_ttl_seconds: int = Field(default=1, description="Minimum allowed TTL in seconds")
|
|
14
|
-
max_ttl_seconds: int = Field(default=30 * 24 * 3600, description="Maximum allowed TTL in seconds (30 days)")
|
|
15
|
-
operation_ttls: dict[str, int] = Field(
|
|
16
|
-
default_factory=dict,
|
|
17
|
-
description="Operation-specific TTLs in seconds (overrides decorator and default)",
|
|
18
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/__init__.py
RENAMED
|
File without changes
|
{idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/repository.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/__init__.py
RENAMED
|
File without changes
|
{idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|