Examples
Example: idempotent payment request
A complete, runnable example of RelPrim idempotency for a payment request. Duplicate calls join or replay the first execution, and what this does not guarantee for external side effects.
Duplicate requests are normal: users double-click, clients retry after a lost response, queues redeliver. This example gives one logical payment a stable idempotency key so duplicates reuse the first execution instead of charging again.
The problem
A checkout service calls create_payment(request_id, amount_cents). The client
generates request_id once per logical payment and sends it with every attempt.
The same request can arrive twice at the same moment, or again a few seconds
later because the client never saw the response. Each call that reaches the
gateway charges the card.
Implementation
The demo gateway counts charges instead of contacting a real provider. The key function derives the idempotency key from the logical request ID only.
import asyncio
from relprim import InMemoryIdempotencyStore, resilient
class DemoPaymentGateway:
"""Stands in for a payment provider: every call would charge the card."""
def __init__(self) -> None:
self.charges = 0
async def create_payment(self, request_id: str, amount_cents: int) -> str:
await asyncio.sleep(0.05) # network round trip
self.charges += 1
return f"pay_{self.charges:04d}"
gateway = DemoPaymentGateway()
payments = InMemoryIdempotencyStore()
def payment_key(request_id: str, amount_cents: int) -> str:
# request_id identifies one logical payment and is chosen by the client.
return f"create-payment:{request_id}"
@resilient(
retries=2,
timeout=10,
idempotency_key=payment_key,
idempotency_store=payments,
idempotency_ttl=3600,
)
async def create_payment(request_id: str, amount_cents: int) -> str:
return await gateway.create_payment(request_id, amount_cents)
async def main() -> None:
# A double-clicked button: the same request arrives twice at once.
first, duplicate = await asyncio.gather(
create_payment("order-1001", 2499),
create_payment("order-1001", 2499),
)
# The client retries later because it never saw the response.
retried = await create_payment("order-1001", 2499)
# A different logical payment runs normally.
other = await create_payment("order-1002", 900)
for label, result in [
("first", first),
("duplicate", duplicate),
("retried", retried),
("other", other),
]:
status = result.report.metadata["idempotency_status"]
print(f"{label:<9} {result.value} {status}")
print(f"gateway charges: {gateway.charges}")
asyncio.run(main())
It prints:
first pay_0001 executed
duplicate pay_0001 joined
retried pay_0001 replayed
other pay_0002 executed
gateway charges: 2
What happens
- The first two calls arrive together with the same key. The first one runs
(
executed). The second finds an execution for that key in progress and waits for it (joined), receiving the same value. - The later call with the same key finds the stored successful result and
returns it without calling the gateway (
replayed). order-1002has a different key, so it runs normally.
The gateway is called twice, once per logical payment, although
create_payment was called four times. The status is in
result.report.metadata["idempotency_status"]; the key itself never appears in
reports or events.
Only successful results are replayed. If the first execution fails, the next call with the same key runs the operation again.
Why this matters
Retry logic, double submissions and redeliveries can share one rule: “this
logical request runs once while we can see it”. The key is chosen by the
application, so it reflects business identity (request_id) rather than
arguments that might differ between duplicates.
Production note
The coordination above lives in the process, in an InMemoryIdempotencyStore.
It cannot help when:
- two instances of the service receive the same request;
- the process restarts after charging the card but before storing the result;
- the gateway charged the card, but the response was lost and the attempt
timed out.
retries=2retries the timed-out attempt inside the same execution, which charges again.
For payments, orders and other external mutations, also pass a stable key to
the provider’s own idempotency mechanism (for example as an idempotency
header derived from request_id), or enforce uniqueness in durable storage
that you control. RelPrim then reduces duplicate work; the provider or your
database guarantees the side effect happens once. A custom
IdempotencyStore can share results between processes, but it does not change
what happens to a side effect whose outcome was never observed.
Related documentation
- Idempotency: keys, stores, TTLs and the exact guarantees.
- Retry: when an operation runs again.
- Runnable repository example:
idempotency.py.
