idempotency-kit 0.1.1__tar.gz → 0.3.0__tar.gz

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