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
37 changes: 23 additions & 14 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ that hides the whole flow, and Dishka providers that wire the pieces together.
**It does not** derive the key — the caller supplies it, and the request body is not part of
it unless you name the parameters that fingerprint the request; it does not roll anything
back; it does not cache failures; it does not retry; it has no sync API; and it stores
nothing but JSON. The reservation is a lease, not a lock: an action
that outlives its lease can run twice, and when storage is down the action runs unreserved.
nothing but JSON. The reservation is a lease, not a lock: the owner renews it while the
action runs, but an owner that cannot renew it for a whole lease (a blocked event loop, a
store that is down) loses it and the action can run twice, and when storage is down the
action runs unreserved.
It is a result cache with an in-flight reservation, not a distributed transaction.

## Mental model
Expand All @@ -64,8 +66,9 @@ Four nouns and one flow.
* **`ResultAdapter`** — `encode` a return value into JSON, `decode` it back. Three ship:
`PydanticResultAdapter(model_class)`, `JsonResultAdapter()`, `VoidResultAdapter()`.
* **`AsyncIdempotencyCoordinator`** — the flow: reserve the key by writing a pending
record under `SET NX` with the lease as its TTL. If that succeeds, run the action, encode
the result and `replace` the reservation with the completed record. If it fails, read
record under `SET NX` with the lease as its TTL. If that succeeds, run the action while
renewing the reservation every third of a lease, encode the result and `replace` the
reservation with the completed record. If it fails, read
what holds the key: a record whose `fingerprint` differs from the caller's raises
`IdempotencyKeyReuseError`, pending or not; a completed record is decoded and returned; a
pending one means another caller is in flight, and `in_flight` decides — `"wait"` polls
Expand Down Expand Up @@ -240,7 +243,7 @@ second caller records on finding the pending record: a waiter then records a hit
result arrives, a refused caller records nothing more and raises. In `"run"` mode it is the
loser's `SET NX` failing after both ran, as before. The error types the coordinator reports
are `storage_get_error`, `storage_reserve_error`, `storage_save_error`,
`storage_release_error`, `record_validation_error` and `key_reuse`.
`storage_release_error`, `storage_renew_error`, `record_validation_error` and `key_reuse`.

### Settings and Dishka

Expand Down Expand Up @@ -306,8 +309,13 @@ fields existed is read as enabled, `"wait"` and 30 seconds.
before reservations existed: both run, the loser's `SET NX` collides and it adopts the
winner's result; the callers see one result and the side effect happened twice, so keep
it for actions that are genuinely safe to repeat. The reservation is a lease, not a lock:
a pending record past its lease counts as absent and the next caller runs the action, so
`in_flight_lease_seconds` has to be longer than the action can ever take.
while the action runs, the owner rewrites the pending record every third of a lease with
its expiry a lease ahead, and a pending record past its lease counts as absent and the
next caller runs the action. So the lease does not have to outlive the action; it is how
long a dead owner holds the key, and how long a waiter waits. Renewal runs on the event
loop: an action that blocks the loop for a whole lease loses the key. A failed renewal is
counted as `storage_renew_error` and the next one tries again; a store down for a whole
lease loses the reservation, and the action carries on.
3. **Declare the key keyword-only anyway.** The decorator reads `key_param` from the
keyword arguments, and from the positional arguments when the parameter can be passed
that way. Keyword-only is still the shape to write: nothing can land in it by position,
Expand Down Expand Up @@ -459,15 +467,16 @@ except IdempotencyInProgressError:
```

```python
# WRONG — a lease the action can outlive: the reservation expires mid-run, a retry at
# second six finds the key free, and the provider is charged twice after all
coordinator = AsyncIdempotencyCoordinator(repository, service, in_flight_lease_seconds=5)

# WRONG — a blocking call inside the action: the event loop cannot renew the reservation,
# it expires after a lease, a retry finds the key free, and the provider is charged twice
@async_idempotent(operation="payment.charge", adapter=PydanticResultAdapter(ChargeDTO))
async def charge(self, dto, *, idempotency_key: str | None = None) -> ChargeDTO:
return await self._psp.charge(dto, timeout=20)
return self._psp_sync_client.charge(dto, timeout=60)

# RIGHT — the lease outlives the action's worst case, timeouts and retries included
coordinator = AsyncIdempotencyCoordinator(repository, service, in_flight_lease_seconds=60)
# RIGHT — keep the loop free while the action waits
@async_idempotent(operation="payment.charge", adapter=PydanticResultAdapter(ChargeDTO))
async def charge(self, dto, *, idempotency_key: str | None = None) -> ChargeDTO:
return await asyncio.to_thread(self._psp_sync_client.charge, dto, timeout=60)
```

```python
Expand Down
2 changes: 1 addition & 1 deletion docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The flow: reserve the key, run the action, store the result; replay a stored res
- `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.
- `in_flight_lease_seconds` (int, default: 30): How long a reservation outlives its owner before it counts as abandoned, and the longest a waiting caller waits. Must be at least 1. The owner renews the reservation every third of a lease while the action runs, so the action may take longer.
- *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, 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.
Expand Down
13 changes: 8 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,12 @@ sequenceDiagram

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.
whole lease raises `IdempotencyInProgressError` rather than waiting forever. A live owner
does not lose the key that way: while its action runs, a task beside it rewrites the pending
record every third of a lease with its expiry a lease ahead. The task is stopped and waited
for, not cancelled, before the result is written or the reservation deleted, so a renewal
still on its way to the store cannot land after either. Expiry therefore means the owner is
gone, or could not reach the store or run its event loop for a whole lease.

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

Expand Down Expand Up @@ -179,8 +182,8 @@ The library follows the **Idempotency Key Pattern**:
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.

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
What this gives is one execution per key while storage is up and the owner keeps renewing
its reservation, 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.

Expand Down
26 changes: 17 additions & 9 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,20 @@ async def charge(dto: ChargeDTO, idempotency_key: str | None = Header(None, alia
raise HTTPException(409, detail="a request with this Idempotency-Key is still being processed")
```

**The lease.** `in_flight_lease_seconds` (default 30) is how long the reservation is held.
A pending record past its lease counts as absent, so a worker that crashed mid-action cannot
wedge the key; it is also how long a waiting caller waits before it gives up with
`IdempotencyInProgressError`. The flip side is that the lease has to be longer than the action
can ever take, timeouts and internal retries included: a reservation that expires while the
action is still running lets the next caller run it again.
**The lease.** `in_flight_lease_seconds` (default 30) is how long the reservation outlives
its owner. While the action runs, the owner renews it every third of a lease, pushing its
expiry a lease ahead, so an action may take longer than the lease. A pending record past its
lease counts as absent, so a worker that crashed mid-action holds the key for at most one
lease; it is also how long a waiting caller waits before it gives up with
`IdempotencyInProgressError`. Pick it for those two: how long a retry should be blocked by a
dead worker, and how long a duplicate should wait for the original's result.

Renewal is a task on the event loop. An action that blocks the loop (a synchronous client, a
CPU-bound step) for a whole lease stops it, the reservation expires, and the next caller runs
the action again; run such work with `asyncio.to_thread`. A renewal the store rejects is
logged and counted as `storage_renew_error`, and the next one, a third of a lease later,
tries again; a store down for a whole lease loses the reservation, and the action carries on
regardless.

**Failures.** An action that raises, or is cancelled, deletes its reservation before the
exception propagates, so the retry runs the action again — failures are not cached, and
Expand Down Expand Up @@ -412,7 +420,7 @@ coordinator = AsyncIdempotencyCoordinator(
metrics=custom_metrics, # Optional metrics collector
enabled=True, # False runs the action and nothing else
in_flight="wait", # "wait" | "raise" | "run", see In-flight requests
in_flight_lease_seconds=30, # How long a reservation is held; must outlive the action
in_flight_lease_seconds=30, # How long a reservation outlives a dead owner; renewed while the action runs
)
```

Expand All @@ -428,7 +436,7 @@ from settings and you get the same numbers.
| `MIN_TTL_SECONDS` | 60 | the floor every TTL is raised to |
| `MAX_TTL_SECONDS` | 2592000 | the ceiling, thirty days |
| `DEFAULT_IN_FLIGHT_MODE` | `"wait"` | what a second caller gets while the first is in flight |
| `DEFAULT_IN_FLIGHT_LEASE_SECONDS` | 30 | how long a reservation is held, and the longest a caller waits |
| `DEFAULT_IN_FLIGHT_LEASE_SECONDS` | 30 | how long a reservation outlives its owner, and the longest a caller waits |
| `IN_FLIGHT_POLL_INTERVAL_SECONDS` | 0.05 | how often a waiting caller re-reads the key |

`MAX_KEY_LENGTH`, `MAX_OPERATION_LENGTH` and the `InFlightMode` type live beside them.
Expand Down Expand Up @@ -473,7 +481,7 @@ 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.
3. **Keep the Event Loop Free**: The reservation is renewed on the event loop while the action runs; an action that blocks the loop for a whole lease loses it and lets the next caller run the action again. Push blocking work to `asyncio.to_thread`.
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).
Expand Down
4 changes: 2 additions & 2 deletions idempotency_kit/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
# instead of producing a second one.
DEFAULT_IN_FLIGHT_MODE: InFlightMode = "wait"

# How long a reservation is held before it counts as abandoned (30 seconds). It has to
# outlive the action; a waiting caller gives up after the same span.
# How long a reservation outlives its owner before it counts as abandoned (30 seconds).
# The owner renews it while the action runs; a waiting caller gives up after the same span.
DEFAULT_IN_FLIGHT_LEASE_SECONDS: int = 30

# How often a waiting caller re-reads the key.
Expand Down
Loading
Loading