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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Ensure operations execute exactly once, even when called multiple times with the
- **Type-Safe** — full type hints with Pydantic validation
- **Async First** — built for asyncio applications
- **Graceful Degradation** — high availability over strict exactly-once
- **Collision Handling** — automatic resolution of concurrent requests
- **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
- **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 Expand Up @@ -110,12 +110,12 @@ return order

### 4. Concurrent requests handled

If two requests arrive simultaneously:
If two requests arrive while the first is still executing:

- First request: cache miss → execute → save
- Second request: collision on savefetch first result → return ✅
- First request: reserves the key → execute → write the result over the reservation
- Second request: finds the reservationwaits for the first result → return ✅ (or a 409 with `in_flight="raise"`)

Both requests get the **same result** - idempotency guaranteed!
Both requests get the **same result**, and the business logic ran once.

## Use cases

Expand Down
185 changes: 127 additions & 58 deletions docs/agents.md

Large diffs are not rendered by default.

28 changes: 25 additions & 3 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ A frozen Pydantic model representing an idempotency result. Inherits from `Idemp
- `idempotency_key` (str): Unique key for this instance.
- `result` (JsonValue): The cached result — any JSON value (mapping, list, scalar); `null` for a void result.
- `created_at` (datetime): When the record was created.
- `expires_at` (datetime): When the record will expire.
- `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.
- **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`.
- `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 @@ -36,14 +39,31 @@ Service for creating and validating records.
- *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.
- `validate_record(record)`: Validates that a record is still usable, raising `IdempotencyRecordExpiredError` if not. The coordinator calls it on every record it reads.

### AsyncIdempotencyCoordinator
The flow: reserve the key, run the action, store the result; replay a stored result; hand a second concurrent caller the first one's result.

- **Constructor**:
- `repository` (AsyncIdempotencyRepository): Storage for records.
- `domain_service` (IdempotencyDomainService): Record factory and TTL bounds.
- `operation_ttls` (dict[str, int], optional): Per-operation TTLs in seconds; win over the decorator's `ttl_seconds`.
- `metrics` (IdempotencyMetricsProtocol, optional): Metrics collector.
- `enabled` (bool, default: True): `False` runs the action and nothing else.
- `in_flight` (`"wait" | "raise" | "run"`, default: `"wait"`): What a second caller gets while the first one's action is still running under the same key — the first caller's result once it lands, `IdempotencyInProgressError` at once, or a run of its own with the winner's result adopted on collision.
- `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.

### AsyncIdempotencyRepository (Protocol)
Interface for idempotency storage.

- **Methods**:
- `get(operation, idempotency_key)`: Returns `IdempotencyRecord` or `None`.
- `save(record)`: Saves a record with NX (not exists) guarantee.
- `replace(record)`: Writes a record whether or not the key is there; how a reservation becomes a result.
- `delete(operation, idempotency_key)`: Deletes a record. Returns `True` if deleted.
- `get_many(operation, idempotency_keys)`: Returns `dict[str, IdempotencyRecord]`.
- `save_many(records, *, rollback_on_error=False)`: Saves multiple records. NOT atomic unless `rollback_on_error=True`.
Expand All @@ -55,12 +75,12 @@ Interface for metrics collection.
- **Methods**:
- `record_hit(operation)`
- `record_miss(operation)`
- `record_collision(operation)`
- `record_collision(operation)`: Two callers on one key at the same time.
- `record_error(operation, error_type)`
- `record_latency(operation, method, duration_seconds)`
- `record_bulk_hit(operation, count)`: For bulk operations.
- `record_bulk_miss(operation, count)`: For bulk operations.
- **Who records what**: the coordinator records hit, miss, collision and the latency of `get` and `save`; the repository
- **Who records what**: the coordinator records hit, miss, collision and the latency of `get`, `reserve` and `save`; the repository
records errors, the bulk hit and miss counts of `get_many`, and the latency of `delete` and `get_many`. One collector
handed to both — which is what the Dishka providers do — therefore counts each operation once.

Expand All @@ -73,13 +93,15 @@ Redis implementation of the repository protocol.
- `redis` (`redis.asyncio.Redis`): Any async Redis client, including a subclass such as an instrumented or fake one.
- `key_prefix` (str, default: "idempotency:"): Prefix for all Redis keys. (**keyword-only**)
- `metrics` (IdempotencyMetricsProtocol, optional): Metrics collector. (**keyword-only**)
- **Storage**: `SET key value EX ttl NX` for `save`, the same without `NX` for `replace`, `GET` for `get`.

## Exceptions

- **`IdempotencyError(message)`**: Base library exception.
- **`IdempotencyKeyCollisionError(operation, key)`**: Raised when a key already exists.
- `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.
- **`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
89 changes: 67 additions & 22 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,34 +43,77 @@ sequenceDiagram
UseCase-->>Client: Cached Result
```

### Cache Miss and Save
### Cache Miss: Reserve, Execute, Complete

The coordinator reserves the key before the action runs. The reservation is the record in
its pending state, written with `SET NX` and the in-flight lease as its TTL; the completed
record is written over it afterwards with a plain `SET`.

```mermaid
sequenceDiagram
participant Client
participant UseCase
participant Coordinator
participant Repository
participant DomainService
participant Storage

Client->>UseCase: execute(idempotency_key)
UseCase->>Repository: get(operation, key)
Repository->>Storage: GET key
Storage-->>Repository: None
Repository-->>UseCase: None
Client->>Coordinator: coordinate(operation, key, ...)
Coordinator->>DomainService: create_pending_record(operation, key, lease)
DomainService-->>Coordinator: IdempotencyRecord(status="pending")
Coordinator->>Repository: save(pending)
Repository->>Storage: SET key pending NX EX lease
Storage-->>Repository: OK

UseCase->>UseCase: execute business logic
UseCase->>DomainService: create_record(operation, key, result)
DomainService-->>UseCase: IdempotencyRecord
Coordinator->>Coordinator: execute business logic
Coordinator->>DomainService: create_record(operation, key, result)
DomainService-->>Coordinator: IdempotencyRecord(status="completed")

UseCase->>Repository: save(record)
Repository->>Storage: SET key NX EX
Coordinator->>Repository: replace(record)
Repository->>Storage: SET key record EX ttl
Storage-->>Repository: OK

UseCase-->>Client: New Result
Coordinator-->>Client: New Result
```

### Concurrent Collision Scenario
When the reservation fails, the coordinator reads the key: a completed record is a hit and is
returned. An action that raises deletes its reservation, so the retry runs it again.

### Concurrent Callers

With `in_flight="wait"` (the default) the second caller finds the reservation and polls the
key until the first caller's record replaces it. With `in_flight="raise"` it raises
`IdempotencyInProgressError` as soon as it sees the reservation, which an HTTP layer maps to
409. Either way the action ran once.

```mermaid
sequenceDiagram
participant Caller1
participant Caller2
participant Storage

Caller1->>Storage: SET key pending NX EX lease -> OK
Caller1->>Caller1: execute logic

Caller2->>Storage: SET key pending NX EX lease -> Fails (Already exists)
Caller2->>Storage: GET key -> pending
Caller2->>Caller2: wait 50 ms (or raise IdempotencyInProgressError)
Caller2->>Storage: GET key -> pending

Caller1->>Storage: SET key record EX ttl -> OK
Caller2->>Storage: GET key -> record
Caller2-->>Caller2: Use result from Caller 1
```

The wait is bounded by the lease: a pending record past its lease counts as absent, so a
worker that crashed mid-action cannot hold the key for longer, and a waiter that has spent a
whole lease raises `IdempotencyInProgressError` rather than waiting forever. The other side of
that bound is that the lease has to outlive the action; a reservation that expires mid-run
lets the next caller run the action again.

### Concurrent Callers with `in_flight="run"`

The flow from before reservations existed, for actions that are safe to repeat: both callers
run the action, the loser's `SET NX` collides, and it adopts the winner's result.

```mermaid
sequenceDiagram
Expand Down Expand Up @@ -112,16 +155,16 @@ The library provides `IdempotencyMetricsProtocol` for observability:

- `record_hit` - cache hit
- `record_miss` - cache miss or not found
- `record_collision` - duplicate key on save
- `record_collision` - two callers on one key at the same time: the second one found a reservation, or (`in_flight="run"`) its save collided
- `record_error` - storage/serialization error
- `record_latency` - operation duration
- `record_bulk_hit` - multiple hits in bulk operation
- `record_bulk_miss` - multiple misses in bulk operation

Each metric has one owner, so a single collector can be handed to both layers without
double counting: the coordinator records hit, miss, collision and the latency of `get` and
`save`; the repository records errors, the bulk hit and miss counts of `get_many`, and the
latency of `delete` and `get_many`.
double counting: the coordinator records hit, miss, collision and the latency of `get`,
`reserve` and `save`; the repository records errors, the bulk hit and miss counts of
`get_many`, and the latency of `delete` and `get_many`.

```python
metrics = PrometheusMetrics()
Expand All @@ -131,11 +174,13 @@ coordinator = AsyncIdempotencyCoordinator(repo, IdempotencyDomainService(), metr

The library follows the **Idempotency Key Pattern**:
1. Client provides a unique key for an operation.
2. Server checks if a result for this key is already cached.
3. If found, returns the cached result immediately.
4. If not found, executes the operation, caches the result, and returns it.
2. Server reserves the key; if the key is already taken, it returns the stored result, or waits for the caller that holds it.
3. If the reservation succeeds, it executes the operation, writes the result over the reservation, and returns it.

This ensures **at-most-once** or **exactly-once** semantics depending on how the application handles storage failures (see Graceful Degradation in User Guide).
What this gives is one execution per key while storage is up and the action finishes within
its lease, and at-least-once when storage is down, because the coordinator then runs the
action unreserved rather than failing the request (see Graceful Degradation in the User
Guide). It is not exactly-once: the reservation is a lease, not a lock.

## Redis Key Format

Expand Down
8 changes: 4 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,12 @@ return order

### 4. Concurrent Requests Handled

If two requests arrive simultaneously:
If two requests arrive while the first is still executing:

- First request: cache miss → execute → save
- Second request: collision on savefetch first result → return ✅
- First request: reserves the key → execute → write the result over the reservation
- Second request: finds the reservationwaits for the first result → return ✅ (or a 409 with `in_flight="raise"`)

Both requests get the **same result** - idempotency guaranteed!
Both requests get the **same result**, and the business logic ran once.

## Why idempotency-kit?

Expand Down
6 changes: 6 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ Request B --key--> Check (miss) --> Execute --> Save ❌ (collision)
Get A's result
```

That is the hand-rolled flow above, where both requests execute and only the responses are
deduplicated. `AsyncIdempotencyCoordinator` and `@async_idempotent` reserve the key before
executing, so request B waits for A's result — or is refused with
`IdempotencyInProgressError` — instead of executing too. See
[In-flight requests](user_guide.md#in-flight-requests) in the User Guide.

## Key Concepts

### Idempotency Key
Expand Down
Loading