idempotency-kit 0.1.1__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 (43) hide show
  1. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/CHANGELOG.md +16 -0
  2. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/PKG-INFO +11 -1
  3. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/README.md +10 -0
  4. idempotency_kit-0.2.0/idempotency_kit/__version__.py +1 -0
  5. idempotency_kit-0.2.0/idempotency_kit/core/adapters/basic.py +61 -0
  6. idempotency_kit-0.2.0/idempotency_kit/core/constants.py +22 -0
  7. idempotency_kit-0.2.0/idempotency_kit/core/decorators/aio/idempotent.py +125 -0
  8. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/services/aio/coordinator.py +28 -3
  9. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/coordinator.py +3 -0
  10. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/dishka/protocols.py +8 -0
  11. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/repository.py +81 -87
  12. idempotency_kit-0.2.0/idempotency_kit/settings.py +25 -0
  13. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/pyproject.toml +4 -0
  14. idempotency_kit-0.1.1/idempotency_kit/__version__.py +0 -1
  15. idempotency_kit-0.1.1/idempotency_kit/core/adapters/basic.py +0 -43
  16. idempotency_kit-0.1.1/idempotency_kit/core/constants.py +0 -18
  17. idempotency_kit-0.1.1/idempotency_kit/core/decorators/aio/idempotent.py +0 -93
  18. idempotency_kit-0.1.1/idempotency_kit/settings.py +0 -18
  19. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/.gitignore +0 -0
  20. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/LICENSE +0 -0
  21. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/__init__.py +0 -0
  22. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/__init__.py +0 -0
  23. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/adapters/__init__.py +0 -0
  24. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/exceptions.py +0 -0
  25. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/models/__init__.py +0 -0
  26. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/models/entities.py +0 -0
  27. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/adapter.py +0 -0
  28. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/__init__.py +0 -0
  29. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/aio/repository.py +0 -0
  30. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/protocols/metrics.py +0 -0
  31. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/services/__init__.py +0 -0
  32. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/core/services/domain.py +0 -0
  33. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/dishka/__init__.py +0 -0
  34. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/__init__.py +0 -0
  35. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/dishka/aio/redis.py +0 -0
  36. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/dishka/common.py +0 -0
  37. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/__init__.py +0 -0
  38. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/metrics/__init__.py +0 -0
  39. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/metrics/prometheus.py +0 -0
  40. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/__init__.py +0 -0
  41. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/__init__.py +0 -0
  42. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/infra/storage/redis/aio/__init__.py +0 -0
  43. {idempotency_kit-0.1.1 → idempotency_kit-0.2.0}/idempotency_kit/py.typed +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
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
+
3
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)
4
20
 
5
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: idempotency-kit
3
- Version: 0.1.1
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
@@ -7,6 +7,7 @@ from typing import Any, Generic, TypeVar
7
7
  from idempotency_kit.core.exceptions import (
8
8
  IdempotencyInvalidTTLError,
9
9
  IdempotencyKeyCollisionError,
10
+ IdempotencyRecordExpiredError,
10
11
  IdempotencyValidationError,
11
12
  )
12
13
  from idempotency_kit.core.protocols.adapter import ResultAdapter
@@ -32,7 +33,15 @@ class _Hit(Generic[T]):
32
33
 
33
34
 
34
35
  class AsyncIdempotencyCoordinator:
35
- """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
+ """
36
45
 
37
46
  def __init__(
38
47
  self,
@@ -40,11 +49,13 @@ class AsyncIdempotencyCoordinator:
40
49
  domain_service: IdempotencyDomainService,
41
50
  operation_ttls: dict[str, int] | None = None,
42
51
  metrics: IdempotencyMetricsProtocol | None = None,
52
+ enabled: bool = True,
43
53
  ) -> None:
44
54
  self._repo = repository
45
55
  self._svc = domain_service
46
56
  self._operation_ttls = operation_ttls or {}
47
57
  self._metrics = metrics or NoOpIdempotencyMetrics()
58
+ self._enabled = enabled
48
59
 
49
60
  async def coordinate(
50
61
  self,
@@ -57,8 +68,12 @@ class AsyncIdempotencyCoordinator:
57
68
  *args: Any,
58
69
  **kwargs: Any,
59
70
  ) -> T:
60
- """Coordinate an idempotent operation."""
61
- 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:
62
77
  return await action(*args, **kwargs)
63
78
 
64
79
  # 1. Try to get from storage
@@ -163,6 +178,16 @@ class AsyncIdempotencyCoordinator:
163
178
  cached = await self._repo.get(operation, idempotency_key)
164
179
  if cached is None:
165
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
+ )
190
+ return None
166
191
  return self._decode_safely(adapter, cached.result, operation, idempotency_key)
167
192
 
168
193
  async def _save_to_repo(
@@ -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
  )
@@ -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."""
@@ -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
+ )
@@ -100,6 +100,10 @@ warn_unreachable = true
100
100
  [tool.ruff]
101
101
  line-length = 120
102
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"]
103
107
 
104
108
  [tool.ruff.lint]
105
109
  select = ["F", "E", "W", "I", "B", "N", "S", "C4", "DTZ", "SIM", "TRY", "PERF", "RUF", "UP", "ANN", "T20", "PTH", "PLC", "PLE", "PLW"]
@@ -1 +0,0 @@
1
- __version__ = "0.1.1" # x-release-please-version
@@ -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 results that already are JSON values (dict, list, str, number, bool or ``None``)."""
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``: the record stores JSON ``null`` and decodes back to ``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,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