Skip to content
RelPrim docsIdempotency

Correctness

Idempotency

Coordinate duplicate calls of the same logical operation, replay successful results, and understand exactly what RelPrim idempotency does and does not guarantee.

Callers retry requests, queues redeliver messages, and concurrent workers pick up the same job. RelPrim idempotency gives one logical operation a key. While the process can observe an execution for that key, duplicate calls join it; after it succeeds, later calls replay its result instead of running the operation again.

Usage

from relprim import resilient


def payment_key(request_id: str, amount_cents: int) -> str:
    return f"create-payment:{request_id}"


@resilient(retries=2, timeout=10, idempotency_key=payment_key, idempotency_ttl=3600)
async def create_payment(request_id: str, amount_cents: int) -> str:
    return await payment_gateway.create(request_id=request_id, amount_cents=amount_cents)


first = await create_payment("request-123", 2499)    # executes the operation
second = await create_payment("request-123", 2499)   # replays the first result

The key factory receives the same arguments as the operation and must return a non-blank string.

Option Default Meaning
idempotency_key (required) Key factory, called with the operation’s arguments.
idempotency_store a new InMemoryIdempotencyStore Where executions are coordinated.
idempotency_ttl 3600 Seconds a successful result is replayed. Finite, greater than 0.

With the simple options, each decorated function gets its own in-memory store. idempotency_store and idempotency_ttl require idempotency_key.

Owner, joined and replayed calls

Every idempotent call ends in one of three statuses, recorded in the report metadata as idempotency_status:

Status The caller…
executed owned the key and ran the operation.
joined arrived while the owner was running and received the owner’s outcome.
replayed arrived after a successful execution and received its stored result.
first, second = await asyncio.gather(
    create_payment("request-123", 2499),
    create_payment("request-123", 2499),
)
# One is "executed", the other "joined"; the operation ran once for both.

Report metadata also contains idempotency_enabled (True) and idempotency_cache_hit (True for joined and replayed). Joined and replayed results carry the report of the original execution, and their value is the same object the owner returned, so do not mutate shared values. They emit no structured events, because they did not execute anything.

The key itself is never copied into reports, events or exception messages. Keys often contain customer, order or payment identifiers, and reports and events are designed to be logged and exported.

Idempotency wraps the whole execution

The key covers the complete lifecycle: every retry, timeout, circuit-breaker check, validation and fallback. A replayed call starts no second retry or fallback flow; it receives the outcome of the original execution as a whole.

Successes are cached, failures are not

  • Success. The result is stored and replayed until the TTL expires. The TTL is measured from the moment the execution succeeded: with ttl_seconds=3600, calls made less than 3600 seconds later replay it, and from exactly 3600 seconds on, the next call executes again.
  • Failure. Nothing is stored. The owner and every joined caller receive the same exception, the key is released, and a later call executes again.

Choose a TTL that covers the retry and redelivery window of the surrounding system.

Cancellation

  • A joined caller is cancelled. Only that caller receives asyncio.CancelledError. The shared execution continues, and everyone else still receives the result.
  • The owner is cancelled. Its execution is cancelled and the key is released immediately; nothing is cached. Joined callers are not cancelled: one of them becomes the new owner and executes the operation again, and the others join it.
  • A later call after any cancellation executes normally.

The owner hand-off keeps waiting callers from failing because of somebody else’s cancellation. It also means that if the cancelled execution had already performed an external side effect, the new owner performs it again, which is the case described in the next section.

Side effects and exactly-once

RelPrim can only act on what it observes. A side effect can still happen twice when:

  1. the owning call successfully performs an external side effect, such as charging a card;
  2. the call is cancelled, or the process crashes or restarts, before RelPrim observes and caches the successful result;
  3. another caller later executes the operation again with the same key.

No local coordination around an external operation can rule this out: the external system may complete an operation whose outcome never reaches the caller. The same applies when an operation fails after part of its work already took effect, because failures are not cached.

For external mutations, combine the RelPrim key with the provider’s own idempotency key, derived from the same logical request:

def payment_key(request_id: str, amount_cents: int) -> str:
    return f"create-payment:{request_id}"


@resilient(retries=2, idempotency_key=payment_key)
async def create_payment(request_id: str, amount_cents: int) -> str:
    return await payment_gateway.create(
        amount_cents=amount_cents,
        idempotency_key=payment_key(request_id, amount_cents),
    )

Where the provider offers no such mechanism, use another durable coordination mechanism, such as a unique constraint or an outbox table in your own database. RelPrim then avoids redundant concurrent work and replays results, and the provider or database guarantees that the side effect happens once.

Designing keys

A key must identify one logical operation with one payload.

  • Namespace keys by operation, such as create-payment:{request_id}, especially when several operations share a store. A bare request_id can collide.
  • Do not reuse a key for a different request or payload. RelPrim assumes callers keep this invariant and does not compare arguments.
  • Do not derive keys from values that change between duplicates: timestamps taken during the call, random UUIDs generated in the key factory, process identifiers or mutable object representations. If the key changes, the calls cannot be deduplicated.

The key factory runs before the operation. If it raises, or returns a blank or non-string key, that error propagates unchanged (TypeError or ValueError for bad keys) and is not wrapped in OperationExecutionError.

Stores and their limits

InMemoryIdempotencyStore coordinates calls within one Python process and one event loop. It is not a distributed lock, it is not shared between processes, containers or hosts, and it does not survive restarts. It is suitable for tests, local applications and single-process services, but not as the only idempotency guarantee for a multi-instance service.

Expired entries are removed lazily, on the next call, purge_expired() or entry_count(); there is no background cleanup.

Share one store between operations only when their keys are namespaced:

from relprim import InMemoryIdempotencyStore, idempotency_policy, resilient

store = InMemoryIdempotencyStore()
payment_idempotency = idempotency_policy(payment_key, store=store, ttl_seconds=3600)


@resilient(idempotency=payment_idempotency)
async def create_payment(request_id: str, amount_cents: int) -> str:
    return await payment_gateway.create(request_id=request_id, amount_cents=amount_cents)

idempotency= is the advanced decorator option, and the builder API accepts the same policy with .with_idempotency(payment_idempotency), where the key factory is also type-checked against the operation’s parameters.

A shared or persistent store implements the IdempotencyStore protocol. An implementation must execute an operation at most once at a time per key, let concurrent callers share an in-flight outcome, replay successful results until their TTL expires, and never cache failures or cancellations.

Recursive reuse of a key

If the task that owns a key calls an operation with the same key again, for example because the same policy was applied at two nested layers, RelPrim raises IdempotencyReentryError instead of deadlocking. The error propagates unchanged and its .key attribute holds the key.

Detection covers the owning task itself. If the operation starts a separate task that uses the same key and waits for it, that task joins the execution it is waiting for, which never completes. Do not reuse an operation’s own key inside it.