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.
Files changed (42) hide show
  1. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/CHANGELOG.md +12 -0
  2. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/PKG-INFO +7 -6
  3. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/README.md +6 -5
  4. {idempotency_kit-0.2.0 → 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/constants.py +41 -0
  7. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/decorators/aio/idempotent.py +36 -0
  8. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/exceptions.py +23 -0
  9. idempotency_kit-0.3.0/idempotency_kit/core/fingerprint.py +22 -0
  10. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/models/entities.py +45 -1
  11. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/repository.py +16 -0
  12. idempotency_kit-0.3.0/idempotency_kit/core/services/aio/coordinator.py +537 -0
  13. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/services/domain.py +41 -0
  14. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/coordinator.py +4 -1
  15. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/protocols.py +18 -0
  16. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/aio/repository.py +63 -26
  17. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/settings.py +19 -1
  18. idempotency_kit-0.2.0/idempotency_kit/__version__.py +0 -1
  19. idempotency_kit-0.2.0/idempotency_kit/core/constants.py +0 -22
  20. idempotency_kit-0.2.0/idempotency_kit/core/services/aio/coordinator.py +0 -249
  21. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/.gitignore +0 -0
  22. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/LICENSE +0 -0
  23. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/__init__.py +0 -0
  24. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/adapters/__init__.py +0 -0
  25. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/adapters/basic.py +0 -0
  26. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/models/__init__.py +0 -0
  27. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/adapter.py +0 -0
  28. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/aio/__init__.py +0 -0
  29. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/protocols/metrics.py +0 -0
  30. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/core/services/__init__.py +0 -0
  31. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/__init__.py +0 -0
  32. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/__init__.py +0 -0
  33. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/aio/redis.py +0 -0
  34. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/dishka/common.py +0 -0
  35. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/__init__.py +0 -0
  36. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/metrics/__init__.py +0 -0
  37. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/metrics/prometheus.py +0 -0
  38. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/__init__.py +0 -0
  39. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/__init__.py +0 -0
  40. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/infra/storage/redis/aio/__init__.py +0 -0
  41. {idempotency_kit-0.2.0 → idempotency_kit-0.3.0}/idempotency_kit/py.typed +0 -0
  42. {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.2.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
- - **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
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 simultaneously:
347
+ If two requests arrive while the first is still executing:
347
348
 
348
- - First request: cache miss → execute → save
349
- - Second request: collision on savefetch first result → return ✅
349
+ - First request: reserves the key → execute → write the result over the reservation
350
+ - Second request: finds the reservationwaits for the first result → return ✅ (or a 409 with `in_flight="raise"`)
350
351
 
351
- Both requests get the **same result** - idempotency guaranteed!
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
- - **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
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 simultaneously:
114
+ If two requests arrive while the first is still executing:
114
115
 
115
- - First request: cache miss → execute → save
116
- - Second request: collision on savefetch first result → return ✅
116
+ - First request: reserves the key → execute → write the result over the reservation
117
+ - Second request: finds the reservationwaits for the first result → return ✅ (or a 409 with `in_flight="raise"`)
117
118
 
118
- Both requests get the **same result** - idempotency guaranteed!
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
@@ -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."""
@@ -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