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.
Files changed (45) hide show
  1. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/.gitignore +3 -0
  2. idempotency_kit-0.2.0/CHANGELOG.md +47 -0
  3. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/PKG-INFO +12 -2
  4. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/README.md +10 -0
  5. idempotency_kit-0.2.0/idempotency_kit/__version__.py +1 -0
  6. idempotency_kit-0.2.0/idempotency_kit/core/adapters/basic.py +61 -0
  7. idempotency_kit-0.2.0/idempotency_kit/core/constants.py +22 -0
  8. idempotency_kit-0.2.0/idempotency_kit/core/decorators/aio/idempotent.py +125 -0
  9. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/models/entities.py +3 -4
  10. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/aio/coordinator.py +79 -21
  11. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/domain.py +1 -1
  12. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/coordinator.py +4 -1
  13. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/redis.py +1 -1
  14. idempotency_kit-0.2.0/idempotency_kit/dishka/common.py +35 -0
  15. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/protocols.py +11 -1
  16. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/repository.py +81 -87
  17. idempotency_kit-0.2.0/idempotency_kit/settings.py +25 -0
  18. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/pyproject.toml +6 -0
  19. idempotency_kit-0.1.0/CHANGELOG.md +0 -21
  20. idempotency_kit-0.1.0/idempotency_kit/__version__.py +0 -1
  21. idempotency_kit-0.1.0/idempotency_kit/core/adapters/basic.py +0 -43
  22. idempotency_kit-0.1.0/idempotency_kit/core/constants.py +0 -18
  23. idempotency_kit-0.1.0/idempotency_kit/core/decorators/aio/idempotent.py +0 -93
  24. idempotency_kit-0.1.0/idempotency_kit/dishka/common.py +0 -22
  25. idempotency_kit-0.1.0/idempotency_kit/settings.py +0 -18
  26. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/LICENSE +0 -0
  27. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/__init__.py +0 -0
  28. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/__init__.py +0 -0
  29. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/adapters/__init__.py +0 -0
  30. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/exceptions.py +0 -0
  31. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/models/__init__.py +0 -0
  32. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/adapter.py +0 -0
  33. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/__init__.py +0 -0
  34. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/repository.py +0 -0
  35. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/metrics.py +0 -0
  36. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/core/services/__init__.py +0 -0
  37. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/__init__.py +0 -0
  38. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/__init__.py +0 -0
  39. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/__init__.py +0 -0
  40. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/metrics/__init__.py +0 -0
  41. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/metrics/prometheus.py +0 -0
  42. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/__init__.py +0 -0
  43. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/__init__.py +0 -0
  44. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/__init__.py +0 -0
  45. {idempotency_kit-0.1.0 → idempotency_kit-0.2.0}/idempotency_kit/py.typed +0 -0
@@ -125,3 +125,6 @@ dmypy.json
125
125
 
126
126
  # ruff
127
127
  .ruff_cache/
128
+
129
+ # Claude Code worktrees
130
+ .claude/worktrees/
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: idempotency-kit
3
- Version: 0.1.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: Mapping[str, JsonValue] = Field(description="Cached operation result (JSON-serializable)")
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: Mapping[str, JsonValue],
53
+ result: JsonValue,
55
54
  ttl_seconds: float,
56
55
  ) -> Self:
57
56
  """Create a new record with calculated expiration."""
@@ -1,9 +1,15 @@
1
1
  import logging
2
2
  import time
3
3
  from collections.abc import Awaitable, Callable
4
- from typing import Any, TypeVar
5
-
6
- from idempotency_kit.core.exceptions import IdempotencyKeyCollisionError
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
- if not idempotency_key:
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
- cached_result = await self._try_get_cached(operation, idempotency_key, adapter)
49
- if cached_result is not None:
50
- return cached_result
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
- result = await self._get_and_decode(operation, idempotency_key, adapter)
76
- if result is not None:
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 result
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 not cached:
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
- winner_result = await self._get_and_decode(operation, idempotency_key, adapter)
167
- if winner_result is not None:
168
- return winner_result
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",
@@ -52,7 +52,7 @@ class IdempotencyDomainService:
52
52
  self,
53
53
  operation: str,
54
54
  idempotency_key: str,
55
- result: dict[str, JsonValue],
55
+ result: JsonValue,
56
56
  *,
57
57
  ttl_minutes: int | None = None,
58
58
  ) -> IdempotencyRecord:
@@ -23,7 +23,7 @@ class AsyncIdempotencyCoordinatorProvider(Provider):
23
23
  repository: AsyncIdempotencyRepository,
24
24
  domain_service: IdempotencyDomainService,
25
25
  settings: IdempotencySettingsProtocol,
26
- metrics: IdempotencyMetricsProtocol | None = None,
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 | None = None,
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 idempotency metrics are enabled."""
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-client-kit and orjson. "
65
- "Install them with: pip install idempotency-kit[redis-aio]"
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
- start = time.perf_counter()
143
+ self._validate_inputs(operation, idempotency_key)
144
+ key = self._make_key(operation, idempotency_key)
138
145
  try:
139
- self._validate_inputs(operation, idempotency_key)
140
- key = self._make_key(operation, idempotency_key)
141
- try:
142
- data = await self._redis.get(key)
143
- except Exception as e:
144
- self._metrics.record_error(operation, type(e).__name__)
145
- logger.exception(
146
- "Redis error during get",
147
- extra={"operation": operation, "key": idempotency_key},
148
- )
149
- raise IdempotencyStorageError(
150
- f"Redis storage failure during get for {operation}",
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
- if not data:
156
- self._metrics.record_miss(operation)
157
- return None
159
+ if not data:
160
+ return None
158
161
 
159
- record = self._deserialize_record(data, operation, idempotency_key)
160
- if record is None:
161
- self._metrics.record_miss(operation)
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
- "Found expired record in Redis (TTL mismatch). Deleting it.",
172
+ "Failed to delete expired record from Redis",
164
173
  extra={"operation": operation, "key": idempotency_key},
174
+ exc_info=True,
165
175
  )
166
- try:
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
- self._metrics.record_hit(operation)
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
- try:
199
- self._validate_inputs(operation, record.idempotency_key)
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
- # Calculate TTL
203
- ttl_seconds = math.ceil(record.ttl_seconds)
199
+ # Calculate TTL
200
+ ttl_seconds = math.ceil(record.ttl_seconds)
204
201
 
205
- if ttl_seconds <= 0:
206
- self._metrics.record_error(operation, "validation_error")
207
- logger.warning(
208
- "Attempted to save already expired record",
209
- extra={"operation": operation, "key": record.idempotency_key},
210
- )
211
- raise IdempotencyValidationError("Cannot save already expired record")
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
- # Serialize record
214
- try:
215
- data = orjson.dumps(record.model_dump(mode="json")) if _HAS_ORJSON else record.model_dump_json()
216
- except Exception as e:
217
- self._metrics.record_error(operation, "serialization_error")
218
- logger.exception(
219
- "Failed to serialize record",
220
- extra={"operation": operation, "key": record.idempotency_key},
221
- )
222
- raise IdempotencyError("Serialization failed") from e
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
- # Use SET with NX (only if key doesn't exist) and EX (expiration)
225
- try:
226
- was_set = await self._redis.set(key, data, ex=ttl_seconds, nx=True)
227
- except Exception as e:
228
- self._metrics.record_error(operation, type(e).__name__)
229
- logger.exception(
230
- "Redis error during save",
231
- extra={"operation": operation, "key": record.idempotency_key},
232
- )
233
- # In case of Redis error, we cannot guarantee idempotency.
234
- raise IdempotencyStorageError(
235
- "Redis storage failure during save",
236
- operation=operation,
237
- original_error=e,
238
- ) from e
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
- if not was_set:
241
- self._metrics.record_collision(operation)
242
- raise IdempotencyKeyCollisionError(operation, record.idempotency_key)
237
+ if not was_set:
238
+ raise IdempotencyKeyCollisionError(operation, record.idempotency_key)
243
239
 
244
- logger.debug(
245
- "Saved idempotency record",
246
- extra={"operation": operation, "key": record.idempotency_key, "ttl_seconds": ttl_seconds},
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