idempotency-kit 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/CHANGELOG.md +12 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/PKG-INFO +7 -6
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/README.md +6 -5
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/__init__.py +6 -0
- idempotency_kit-0.3.0/idempotency_kit/__version__.py +1 -0
- idempotency_kit-0.3.0/idempotency_kit/core/constants.py +41 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/decorators/aio/idempotent.py +36 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/exceptions.py +23 -0
- idempotency_kit-0.3.0/idempotency_kit/core/fingerprint.py +22 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/models/entities.py +45 -1
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/repository.py +16 -0
- idempotency_kit-0.3.0/idempotency_kit/core/services/aio/coordinator.py +537 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/services/domain.py +41 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/coordinator.py +4 -1
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/protocols.py +18 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/aio/repository.py +63 -26
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/settings.py +19 -1
- idempotency_kit-0.2.0/idempotency_kit/__version__.py +0 -1
- idempotency_kit-0.2.0/idempotency_kit/core/constants.py +0 -22
- idempotency_kit-0.2.0/idempotency_kit/core/services/aio/coordinator.py +0 -249
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/.gitignore +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/LICENSE +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/adapters/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/adapters/basic.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/models/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/adapter.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/metrics.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/services/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/redis.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/common.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/metrics/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/metrics/prometheus.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/aio/__init__.py +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/py.typed +0 -0
- {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/pyproject.toml +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
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)
|
|
4
16
|
|
|
5
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: idempotency-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Production-ready idempotency library for async Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/bedrock-python/idempotency-kit
|
|
6
6
|
Project-URL: Documentation, https://bedrock-python.github.io/idempotency-kit/
|
|
@@ -260,7 +260,8 @@ Ensure operations execute exactly once, even when called multiple times with the
|
|
|
260
260
|
- **Type-Safe** — full type hints with Pydantic validation
|
|
261
261
|
- **Async First** — built for asyncio applications
|
|
262
262
|
- **Graceful Degradation** — high availability over strict exactly-once
|
|
263
|
-
- **
|
|
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
|
|
264
265
|
- **Observability** — built-in metrics (hits, misses, collisions, latency)
|
|
265
266
|
- **Bulk Operations** — efficient `get_many`, `save_many`, `delete_many`
|
|
266
267
|
- **Redis Cluster Compatible** — non-transactional pipelines
|
|
@@ -343,12 +344,12 @@ return order
|
|
|
343
344
|
|
|
344
345
|
### 4. Concurrent requests handled
|
|
345
346
|
|
|
346
|
-
If two requests arrive
|
|
347
|
+
If two requests arrive while the first is still executing:
|
|
347
348
|
|
|
348
|
-
- First request:
|
|
349
|
-
- Second request:
|
|
349
|
+
- First request: reserves the key → execute → write the result over the reservation ✅
|
|
350
|
+
- Second request: finds the reservation → waits for the first result → return ✅ (or a 409 with `in_flight="raise"`)
|
|
350
351
|
|
|
351
|
-
Both requests get the **same result
|
|
352
|
+
Both requests get the **same result**, and the business logic ran once.
|
|
352
353
|
|
|
353
354
|
## Use cases
|
|
354
355
|
|
|
@@ -27,7 +27,8 @@ Ensure operations execute exactly once, even when called multiple times with the
|
|
|
27
27
|
- **Type-Safe** — full type hints with Pydantic validation
|
|
28
28
|
- **Async First** — built for asyncio applications
|
|
29
29
|
- **Graceful Degradation** — high availability over strict exactly-once
|
|
30
|
-
- **
|
|
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
|
|
31
32
|
- **Observability** — built-in metrics (hits, misses, collisions, latency)
|
|
32
33
|
- **Bulk Operations** — efficient `get_many`, `save_many`, `delete_many`
|
|
33
34
|
- **Redis Cluster Compatible** — non-transactional pipelines
|
|
@@ -110,12 +111,12 @@ return order
|
|
|
110
111
|
|
|
111
112
|
### 4. Concurrent requests handled
|
|
112
113
|
|
|
113
|
-
If two requests arrive
|
|
114
|
+
If two requests arrive while the first is still executing:
|
|
114
115
|
|
|
115
|
-
- First request:
|
|
116
|
-
- Second request:
|
|
116
|
+
- First request: reserves the key → execute → write the result over the reservation ✅
|
|
117
|
+
- Second request: finds the reservation → waits for the first result → return ✅ (or a 409 with `in_flight="raise"`)
|
|
117
118
|
|
|
118
|
-
Both requests get the **same result
|
|
119
|
+
Both requests get the **same result**, and the business logic ran once.
|
|
119
120
|
|
|
120
121
|
## Use cases
|
|
121
122
|
|
|
@@ -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,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
|
{idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/decorators/aio/idempotent.py
RENAMED
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
from collections.abc import Awaitable, Callable
|
|
5
5
|
from typing import Any, TypeVar
|
|
6
6
|
|
|
7
|
+
from idempotency_kit.core.fingerprint import fingerprint_of
|
|
7
8
|
from idempotency_kit.core.protocols.adapter import ResultAdapter
|
|
8
9
|
from idempotency_kit.core.services.aio.coordinator import AsyncIdempotencyCoordinator
|
|
9
10
|
|
|
@@ -26,12 +27,40 @@ def _positional_index(func: Callable[..., Any], key_param: str) -> int | None:
|
|
|
26
27
|
return None
|
|
27
28
|
|
|
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
|
+
|
|
29
57
|
def async_idempotent(
|
|
30
58
|
operation: str,
|
|
31
59
|
adapter: ResultAdapter[T],
|
|
32
60
|
ttl_seconds: int | None = None,
|
|
33
61
|
key_param: str = "idempotency_key",
|
|
34
62
|
infra_param: str | None = None,
|
|
63
|
+
fingerprint_params: tuple[str, ...] | None = None,
|
|
35
64
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
|
36
65
|
"""Decorator for asynchronous idempotent operations.
|
|
37
66
|
|
|
@@ -48,10 +77,15 @@ def async_idempotent(
|
|
|
48
77
|
be passed positionally.
|
|
49
78
|
infra_param: Optional name of the argument or attribute containing AsyncIdempotencyCoordinator.
|
|
50
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.
|
|
51
84
|
"""
|
|
52
85
|
|
|
53
86
|
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
54
87
|
key_index = _positional_index(func, key_param)
|
|
88
|
+
fingerprint = _fingerprint_resolver(func, fingerprint_params)
|
|
55
89
|
|
|
56
90
|
@functools.wraps(func)
|
|
57
91
|
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
@@ -78,6 +112,8 @@ def async_idempotent(
|
|
|
78
112
|
return await func(*args, **kwargs)
|
|
79
113
|
|
|
80
114
|
# 3. Delegate to coordinator
|
|
115
|
+
if fingerprint is not None:
|
|
116
|
+
kwargs = {"idempotency_fingerprint": fingerprint(args, kwargs), **kwargs}
|
|
81
117
|
return await coordinator.coordinate(
|
|
82
118
|
operation,
|
|
83
119
|
idempotency_key,
|
|
@@ -54,3 +54,26 @@ class IdempotencyInvalidTTLError(IdempotencyError):
|
|
|
54
54
|
self.min_ttl = min_ttl
|
|
55
55
|
self.max_ttl = max_ttl
|
|
56
56
|
super().__init__(f"Invalid TTL {ttl_seconds}s. Must be between {min_ttl}s and {max_ttl}s")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class IdempotencyInProgressError(IdempotencyError):
|
|
60
|
+
"""Raised when another call with the same key is still running its action."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, operation: str, key: str) -> None:
|
|
63
|
+
self.operation = operation
|
|
64
|
+
self.key = key
|
|
65
|
+
super().__init__(f"Idempotency record for operation '{operation}', key '{key}' is still in flight")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class IdempotencyKeyReuseError(IdempotencyError):
|
|
69
|
+
"""Raised when a key is reused for a request with a different fingerprint."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, operation: str, key: str, stored_fingerprint: str, fingerprint: str) -> None:
|
|
72
|
+
self.operation = operation
|
|
73
|
+
self.key = key
|
|
74
|
+
self.stored_fingerprint = stored_fingerprint
|
|
75
|
+
self.fingerprint = fingerprint
|
|
76
|
+
super().__init__(
|
|
77
|
+
f"Idempotency key '{key}' for operation '{operation}' was used for a different request: "
|
|
78
|
+
f"stored fingerprint '{stored_fingerprint}', got '{fingerprint}'"
|
|
79
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The request fingerprint: what makes two calls under one key the same request."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic_core import to_jsonable_python
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fingerprint_of(**values: Any) -> str:
|
|
11
|
+
"""Hash the named values into the fingerprint of a request.
|
|
12
|
+
|
|
13
|
+
Pydantic models, dataclasses, UUIDs, datetimes, Decimals and the JSON types are turned
|
|
14
|
+
into JSON-able Python first, keys are sorted, and the SHA-256 hex digest is returned, so
|
|
15
|
+
two equal payloads built in different orders agree. This is what ``async_idempotent``
|
|
16
|
+
computes from ``fingerprint_params``; a caller of ``coordinate()`` uses it directly.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
pydantic_core.PydanticSerializationError: a value has no JSON form
|
|
20
|
+
"""
|
|
21
|
+
canonical = json.dumps(to_jsonable_python(values), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
22
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Idempotency record entity."""
|
|
2
2
|
|
|
3
3
|
from datetime import UTC, datetime, timedelta
|
|
4
|
-
from typing import Annotated, Self
|
|
4
|
+
from typing import Annotated, Literal, Self
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, StringConstraints, field_validator
|
|
7
7
|
|
|
@@ -34,6 +34,10 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
34
34
|
|
|
35
35
|
Stores the result of an operation so that repeated calls with the same key
|
|
36
36
|
return the cached result without re-executing business logic.
|
|
37
|
+
|
|
38
|
+
A *pending* record is an in-flight reservation: the key is taken, the action is still
|
|
39
|
+
running, and the result is not there yet. It is written before the action and replaced
|
|
40
|
+
by the completed record after it; its ``expires_at`` is the lease.
|
|
37
41
|
"""
|
|
38
42
|
|
|
39
43
|
model_config = ConfigDict(frozen=True)
|
|
@@ -45,6 +49,19 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
45
49
|
created_at: datetime = Field(description="When this record was created")
|
|
46
50
|
expires_at: datetime = Field(description="When this record should expire from cache")
|
|
47
51
|
|
|
52
|
+
# Records written before the field existed carry no status and read as completed.
|
|
53
|
+
status: Literal["pending", "completed"] = Field(
|
|
54
|
+
default="completed",
|
|
55
|
+
description="'pending' while the action runs under a reservation, 'completed' once the result is stored",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# What the caller said the request was; None means the record carries no fingerprint
|
|
59
|
+
# and a hit never raises for it.
|
|
60
|
+
fingerprint: str | None = Field(
|
|
61
|
+
default=None,
|
|
62
|
+
description="Fingerprint of the request the result belongs to; a hit with a different one is a key reuse",
|
|
63
|
+
)
|
|
64
|
+
|
|
48
65
|
@classmethod
|
|
49
66
|
def create(
|
|
50
67
|
cls,
|
|
@@ -52,6 +69,7 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
52
69
|
idempotency_key: str,
|
|
53
70
|
result: JsonValue,
|
|
54
71
|
ttl_seconds: float,
|
|
72
|
+
fingerprint: str | None = None,
|
|
55
73
|
) -> Self:
|
|
56
74
|
"""Create a new record with calculated expiration."""
|
|
57
75
|
now = datetime.now(UTC)
|
|
@@ -61,8 +79,34 @@ class IdempotencyRecord(IdempotencyIdentifiers):
|
|
|
61
79
|
result=result,
|
|
62
80
|
created_at=now,
|
|
63
81
|
expires_at=now + timedelta(seconds=ttl_seconds),
|
|
82
|
+
fingerprint=fingerprint,
|
|
64
83
|
)
|
|
65
84
|
|
|
85
|
+
@classmethod
|
|
86
|
+
def pending(
|
|
87
|
+
cls,
|
|
88
|
+
operation: str,
|
|
89
|
+
idempotency_key: str,
|
|
90
|
+
lease_seconds: float,
|
|
91
|
+
fingerprint: str | None = None,
|
|
92
|
+
) -> Self:
|
|
93
|
+
"""Create the in-flight reservation for an action that is about to run."""
|
|
94
|
+
now = datetime.now(UTC)
|
|
95
|
+
return cls(
|
|
96
|
+
operation=operation,
|
|
97
|
+
idempotency_key=idempotency_key,
|
|
98
|
+
result=None,
|
|
99
|
+
created_at=now,
|
|
100
|
+
expires_at=now + timedelta(seconds=lease_seconds),
|
|
101
|
+
status="pending",
|
|
102
|
+
fingerprint=fingerprint,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_pending(self) -> bool:
|
|
107
|
+
"""Whether this record is an in-flight reservation rather than a stored result."""
|
|
108
|
+
return self.status == "pending"
|
|
109
|
+
|
|
66
110
|
@property
|
|
67
111
|
def is_expired(self) -> bool:
|
|
68
112
|
"""Check if the record has expired."""
|
{idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/repository.py
RENAMED
|
@@ -43,6 +43,22 @@ class AsyncIdempotencyRepository(Protocol):
|
|
|
43
43
|
"""
|
|
44
44
|
...
|
|
45
45
|
|
|
46
|
+
async def replace(self, record: IdempotencyRecord) -> None:
|
|
47
|
+
"""Write a record whether or not the key is already there.
|
|
48
|
+
|
|
49
|
+
The coordinator completes an in-flight reservation with it: the pending record
|
|
50
|
+
under the key gives way to the final one.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
record: Record to write
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
IdempotencyValidationError: If record fails validation
|
|
57
|
+
IdempotencyStorageError: If storage operation fails
|
|
58
|
+
IdempotencyError: If internal error (e.g. serialization) occurs
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
46
62
|
async def delete(self, operation: str, idempotency_key: str) -> bool:
|
|
47
63
|
"""Delete an idempotency record.
|
|
48
64
|
|