Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Ensure operations execute exactly once, even when called multiple times with the
- **Async First** — built for asyncio applications
- **Graceful Degradation** — high availability over strict exactly-once
- **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
- **Request Fingerprints** — name the parameters that identify a request, and a key reused for a different one is refused instead of replayed
- **Observability** — built-in metrics (hits, misses, collisions, latency)
- **Bulk Operations** — efficient `get_many`, `save_many`, `delete_many`
- **Redis Cluster Compatible** — non-transactional pipelines
Expand Down
115 changes: 84 additions & 31 deletions docs/agents.md

Large diffs are not rendered by default.

26 changes: 21 additions & 5 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ A frozen Pydantic model representing an idempotency result. Inherits from `Idemp
- `created_at` (datetime): When the record was created.
- `expires_at` (datetime): When the record will expire. On a pending record this is the in-flight lease.
- `status` (`"pending" | "completed"`, default `"completed"`): `"pending"` while the action runs under an in-flight reservation, `"completed"` once the result is stored. A record written before the field existed reads as completed.
- `fingerprint` (`str | None`, default `None`): Fingerprint of the request the record was made for. A hit with a different one is a key reuse; `None` — also how a record written before the field existed reads — never raises.
- **Methods**:
- `create(operation: str, idempotency_key: str, result: JsonValue, ttl_seconds: float) -> IdempotencyRecord`: Class method to create a new record.
- `pending(operation: str, idempotency_key: str, lease_seconds: float) -> IdempotencyRecord`: Class method to create the in-flight reservation; `result` is `null`.
- `create(operation: str, idempotency_key: str, result: JsonValue, ttl_seconds: float, fingerprint: str | None = None) -> IdempotencyRecord`: Class method to create a new record.
- `pending(operation: str, idempotency_key: str, lease_seconds: float, fingerprint: str | None = None) -> IdempotencyRecord`: Class method to create the in-flight reservation; `result` is `null`.
- `is_pending`: Property returning `True` for an in-flight reservation.
- `is_expired`: Property returning `True` if current time is after `expires_at`.
- `ttl_seconds`: Property returning remaining TTL in seconds.
Expand All @@ -38,8 +39,8 @@ Service for creating and validating records.
- *Note*: These three defaults live in `idempotency_kit.core.constants` and are also the field defaults of `BaseIdempotencySettings`.
- *Note*: Constructor validates that `default_ttl_minutes` (converted to seconds) is within the `[min_ttl_seconds, max_ttl_seconds]` range.
- **Methods**:
- `create_record(operation, idempotency_key, result, *, ttl_minutes=None)`: Creates a new `IdempotencyRecord` with validation and TTL management.
- `create_pending_record(operation, idempotency_key, *, lease_seconds)`: Creates the in-flight reservation. The lease is not held to the TTL bounds; it must be at least a second.
- `create_record(operation, idempotency_key, result, *, ttl_minutes=None, fingerprint=None)`: Creates a new `IdempotencyRecord` with validation and TTL management.
- `create_pending_record(operation, idempotency_key, *, lease_seconds, fingerprint=None)`: Creates the in-flight reservation. The lease is not held to the TTL bounds; it must be at least a second.
- `validate_record(record)`: Validates that a record is still usable, raising `IdempotencyRecordExpiredError` if not. The coordinator calls it on every record it reads.

### AsyncIdempotencyCoordinator
Expand All @@ -55,7 +56,21 @@ The flow: reserve the key, run the action, store the result; replay a stored res
- `in_flight_lease_seconds` (int, default: 30): How long a reservation is held before it counts as abandoned, and the longest a waiting caller waits. Must be at least 1 and longer than the action can take.
- *Note*: The constructor raises `TypeError` when the repository has no `replace` and `in_flight` is not `"run"`.
- **Methods**:
- `coordinate(operation, idempotency_key, ttl_seconds, adapter, action, /, *args, **kwargs)`: Runs the flow and returns the action's result type. Never raises for storage or decode trouble; raises `IdempotencyInProgressError` when the key is in flight and `in_flight` says so.
- `coordinate(operation, idempotency_key, ttl_seconds, adapter, action, /, *args, idempotency_fingerprint=None, **kwargs)`: Runs the flow and returns the action's result type. Everything after the five positional-only arguments goes to the action except `idempotency_fingerprint`, the fingerprint of the request, which is stored with the record and compared on a hit. Never raises for storage or decode trouble; raises `IdempotencyInProgressError` when the key is in flight and `in_flight` says so, and `IdempotencyKeyReuseError` when the record's fingerprint differs from the caller's.

### async_idempotent
The same flow as a decorator: it finds the key in the call's arguments and the coordinator in the call's arguments or on `self`, then delegates to `coordinate()`.

- **Parameters**:
- `operation` (str): Operation name.
- `adapter` (ResultAdapter): Encodes the result for storage and decodes it back.
- `ttl_seconds` (int, optional): TTL for the record; the coordinator's `operation_ttls` win over it.
- `key_param` (str, default: `"idempotency_key"`): Name of the parameter holding the key. Read from the keyword arguments, or from the positional ones when the parameter can be passed that way.
- `infra_param` (str, optional): Name of the argument or attribute holding the coordinator; without it the decorator searches by type.
- `fingerprint_params` (tuple[str, ...], optional): Names of the parameters whose bound values, defaults applied, identify the request. Their fingerprint is computed with `fingerprint_of` and passed to `coordinate()` as `idempotency_fingerprint`. A name the function does not have raises `TypeError` at decoration time.

### fingerprint_of
`fingerprint_of(**values) -> str`: the SHA-256 hex digest of the JSON form of the named values, keys sorted. Pydantic models, dataclasses, UUIDs, datetimes and Decimals are serialised the way Pydantic serialises them; a value with no JSON form raises `pydantic_core.PydanticSerializationError`. It is what the decorator computes from `fingerprint_params`, for a caller of `coordinate()` to use directly.

### AsyncIdempotencyRepository (Protocol)
Interface for idempotency storage.
Expand Down Expand Up @@ -102,6 +117,7 @@ Redis implementation of the repository protocol.
- `key` can be a single `str` or a `list[str]` for bulk operations.
- **`IdempotencyRecordExpiredError(operation, key)`**: Raised when record exists but is expired.
- **`IdempotencyInProgressError(operation, key)`**: Raised by `coordinate()` and the decorator when another call with the same key is still running its action.
- **`IdempotencyKeyReuseError(operation, key, stored_fingerprint, fingerprint)`**: Raised by `coordinate()` and the decorator when the record under the key was made for a request with a different fingerprint.
- **`IdempotencyStorageError(message, operation, original_error)`**: Raised on storage failure.
- **`IdempotencyValidationError(message, errors=None)`**: Raised for invalid input (e.g. empty key, too long string).
- `errors` (list, optional): Detailed Pydantic validation errors.
Expand Down
6 changes: 6 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ idempotency_key = "create_order" # Same for all requests
idempotency_key = str(datetime.now()) # Changes every millisecond
```

The key is the whole identity: the same key with a different payload replays the first result.
To have the payload count, name the parameters that identify the request with
`fingerprint_params=("dto",)` on `@async_idempotent`; a key reused for a different request then
raises `IdempotencyKeyReuseError` — see
[Key reuse and fingerprints](user_guide.md#key-reuse-and-fingerprints) in the User Guide.

### Operation Name

A string identifying the type of operation:
Expand Down
81 changes: 79 additions & 2 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,81 @@ to be migrated. Two things to know before turning the default on across a fleet:
`in_flight="run"` and switch to `"wait"` once every instance is on the new version, or
accept that window.

## Key reuse and fingerprints

The key is the identity, and the request body is not part of it: two calls with the same
`operation` and `idempotency_key` and different payloads replay the first result. For a
well-behaved client that never happens. It happens with a client that reuses keys by mistake
— a key derived from the order id, then a second, different operation on the same order — and
without a fingerprint the library answers such a request with a result for a different
request, and nothing says so.

Name the parameters that identify the request and the decorator fingerprints them:

```python
from idempotency_kit import IdempotencyKeyReuseError, PydanticResultAdapter, async_idempotent

class ChargeCard:
@async_idempotent(
operation="payment.charge",
adapter=PydanticResultAdapter(ChargeDTO),
fingerprint_params=("dto",),
)
async def execute(self, dto: ChargeRequest, *, idempotency_key: str | None = None) -> ChargeDTO:
return await self._psp.charge(dto)
```

The named values are bound to the call with defaults applied, turned into their JSON form —
Pydantic models, dataclasses, UUIDs, datetimes and Decimals included — serialised with sorted
keys, and hashed with SHA-256. The fingerprint is stored with the record. The same key coming
back with a different fingerprint raises `IdempotencyKeyReuseError` before the action runs,
whether the record is completed or still pending under an in-flight reservation, so a
retry with another payload is refused at once rather than handed someone else's result after
waiting. The error carries `operation`, `key`, `stored_fingerprint` and `fingerprint`; map
it to HTTP 422, which is what Stripe does:

```python
@app.post("/charges")
async def charge(dto: ChargeRequest, idempotency_key: str | None = Header(None, alias="Idempotency-Key")):
try:
return await use_case.execute(dto, idempotency_key=idempotency_key)
except IdempotencyKeyReuseError:
raise HTTPException(422, detail="this Idempotency-Key was already used for a different request")
```

Without the decorator, compute the fingerprint with `fingerprint_of` — the same function the
decorator uses, so both paths agree — and pass it to `coordinate()` as its one keyword:

```python
from idempotency_kit import fingerprint_of

result = await coordinator.coordinate(
"payment.charge",
idempotency_key,
3600,
PydanticResultAdapter(ChargeDTO),
self._psp.charge,
dto,
idempotency_fingerprint=fingerprint_of(dto=dto),
)
```

**Either side missing means no comparison.** A record without a fingerprint — written by a
caller that passed none, or before the field existed — never raises, and a caller without a
fingerprint gets the key-only behaviour. Nothing changes for anyone who does not opt in, and
no record needs migrating.

**Fingerprint what identifies the request, not what varies between retries.** A timestamp,
a trace id or `self` in `fingerprint_params` turns every honest retry into a key reuse. A
name the function does not have raises `TypeError` at decoration time; a value with no JSON
form raises `PydanticSerializationError` when the call is made.

**With `in_flight="run"`** the check is on the read, before the action. If the mismatch only
shows up on the collision after both callers ran, it is logged and counted as `key_reuse`
and the caller keeps its own result: raising then would make the caller retry a completed
operation. Every refusal is counted under `record_error(operation, "key_reuse")`; a client
that reuses keys is worth an alert.

## Advanced Use Cases

### Custom TTL
Expand Down Expand Up @@ -379,6 +454,7 @@ The library defines several exceptions to handle various idempotency scenarios:

- **`IdempotencyKeyCollisionError`**: Raised by `repository.save()` when you try to save a result for a key that already exists. This typically means another identical request is either being processed or has already finished.
- **`IdempotencyInProgressError`**: Raised by `coordinator.coordinate()` and the decorator when another call with the same key is still running its action — at once with `in_flight="raise"`, after a whole lease of waiting with `in_flight="wait"`. Map it to HTTP 409.
- **`IdempotencyKeyReuseError`**: Raised by `coordinator.coordinate()` and the decorator when the record under the key was made for a request with a different fingerprint. Carries both fingerprints. Map it to HTTP 422.
- **`IdempotencyRecordExpiredError`**: Raised by `service.validate_record()` if the record exists but its TTL has passed.
- **`IdempotencyInvalidTTLError`**: Raised by `service.create_record()` if the requested TTL is outside the allowed range (configured in `IdempotencyDomainService`).
- **`IdempotencyValidationError`**: Raised by `service.create_record()` if validation of `operation` or `idempotency_key` fails (e.g., empty string or too long).
Expand All @@ -390,8 +466,9 @@ The library defines several exceptions to handle various idempotency scenarios:
1. **Natural Keys**: Use natural unique identifiers as idempotency keys if possible (e.g., `order_id`, `message_id`).
2. **Atomic Operations**: Always save the result to the cache *after* the business logic has successfully completed.
3. **Lease Longer Than the Action**: Set `in_flight_lease_seconds` above the longest the action can take, timeouts and retries included; a reservation that expires mid-run lets the next caller run the action again.
4. **Pydantic Support**: The library works best with Pydantic models. Use `model_dump(mode="json")` when saving and `**cached.result` when restoring.
5. **Graceful Degradation**: Decide whether your service should fail if idempotency storage is down. For most high-availability services, it's better to log an error and proceed (at-least-once delivery) than to crash (exactly-once requirement).
4. **Fingerprint the Request**: Name the parameters that identify the request in `fingerprint_params`, so a key reused by mistake for a different request is refused with `IdempotencyKeyReuseError` rather than answered with the first result.
5. **Pydantic Support**: The library works best with Pydantic models. Use `model_dump(mode="json")` when saving and `**cached.result` when restoring.
6. **Graceful Degradation**: Decide whether your service should fail if idempotency storage is down. For most high-availability services, it's better to log an error and proceed (at-least-once delivery) than to crash (exactly-once requirement).

## Production Examples

Expand Down
4 changes: 4 additions & 0 deletions idempotency_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
IdempotencyInProgressError,
IdempotencyInvalidTTLError,
IdempotencyKeyCollisionError,
IdempotencyKeyReuseError,
IdempotencyRecordExpiredError,
IdempotencyStorageError,
IdempotencyValidationError,
)
from .core.fingerprint import fingerprint_of
from .core.models.entities import IdempotencyIdentifiers, IdempotencyRecord
from .core.protocols.adapter import ResultAdapter
from .core.protocols.aio.repository import AsyncIdempotencyRepository
Expand All @@ -31,6 +33,7 @@
"IdempotencyInProgressError",
"IdempotencyInvalidTTLError",
"IdempotencyKeyCollisionError",
"IdempotencyKeyReuseError",
"IdempotencyMetricsProtocol",
"IdempotencyRecord",
"IdempotencyRecordExpiredError",
Expand All @@ -42,4 +45,5 @@
"ResultAdapter",
"VoidResultAdapter",
"async_idempotent",
"fingerprint_of",
]
36 changes: 36 additions & 0 deletions idempotency_kit/core/decorators/aio/idempotent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar

from idempotency_kit.core.fingerprint import fingerprint_of
from idempotency_kit.core.protocols.adapter import ResultAdapter
from idempotency_kit.core.services.aio.coordinator import AsyncIdempotencyCoordinator

Expand All @@ -26,12 +27,40 @@ def _positional_index(func: Callable[..., Any], key_param: str) -> int | None:
return None


def _fingerprint_resolver(
func: Callable[..., Any], fingerprint_params: tuple[str, ...] | None
) -> Callable[[tuple[Any, ...], dict[str, Any]], str] | None:
"""Build the function that fingerprints a call from the named parameters, or ``None`` when there are none.

Resolved at decoration time so that a parameter the function does not have, or a
signature ``inspect`` cannot describe, fails at import rather than on the first call.
"""
if not fingerprint_params:
return None
try:
signature = inspect.signature(func)
except (TypeError, ValueError) as e:
raise TypeError(f"fingerprint_params needs a signature inspect can describe; {func!r} has none") from e
unknown = [name for name in fingerprint_params if name not in signature.parameters]
if unknown:
raise TypeError(f"fingerprint_params names parameters {func.__qualname__} does not have: {unknown}")

def resolve(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
# Defaults applied, so an argument passed at its default and one left out agree.
bound = signature.bind(*args, **kwargs)
bound.apply_defaults()
return fingerprint_of(**{name: bound.arguments[name] for name in fingerprint_params})

return resolve


def async_idempotent(
operation: str,
adapter: ResultAdapter[T],
ttl_seconds: int | None = None,
key_param: str = "idempotency_key",
infra_param: str | None = None,
fingerprint_params: tuple[str, ...] | None = None,
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
"""Decorator for asynchronous idempotent operations.

Expand All @@ -48,10 +77,15 @@ def async_idempotent(
be passed positionally.
infra_param: Optional name of the argument or attribute containing AsyncIdempotencyCoordinator.
If not provided, searches for AsyncIdempotencyCoordinator by type.
fingerprint_params: Names of the parameters whose values identify the request. Their
bound values, defaults applied, are hashed with ``fingerprint_of`` and stored with
the record; a later call under the same key with a different fingerprint raises
``IdempotencyKeyReuseError``. ``None`` means the key alone is the identity.
"""

def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
key_index = _positional_index(func, key_param)
fingerprint = _fingerprint_resolver(func, fingerprint_params)

@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> T:
Expand All @@ -78,6 +112,8 @@ async def wrapper(*args: Any, **kwargs: Any) -> T:
return await func(*args, **kwargs)

# 3. Delegate to coordinator
if fingerprint is not None:
kwargs = {"idempotency_fingerprint": fingerprint(args, kwargs), **kwargs}
return await coordinator.coordinate(
operation,
idempotency_key,
Expand Down
14 changes: 14 additions & 0 deletions idempotency_kit/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,17 @@ def __init__(self, operation: str, key: str) -> None:
self.operation = operation
self.key = key
super().__init__(f"Idempotency record for operation '{operation}', key '{key}' is still in flight")


class IdempotencyKeyReuseError(IdempotencyError):
"""Raised when a key is reused for a request with a different fingerprint."""

def __init__(self, operation: str, key: str, stored_fingerprint: str, fingerprint: str) -> None:
self.operation = operation
self.key = key
self.stored_fingerprint = stored_fingerprint
self.fingerprint = fingerprint
super().__init__(
f"Idempotency key '{key}' for operation '{operation}' was used for a different request: "
f"stored fingerprint '{stored_fingerprint}', got '{fingerprint}'"
)
Loading