Skip to content
RelPrim docsIdempotent payment

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

  1. 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.
  2. The later call with the same key finds the stored successful result and returns it without calling the gateway (replayed).
  3. order-1002 has 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=2 retries 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.