Concepts / Concurrency in one process / Idempotent operations

Idempotent operations

Making a repeated call harmless inside a single process too.

01The intuition

The problem it solves

A shop's checkout button charges the card. The network hiccups after the request is sent, the page shows an error, and the customer clicks again. The first version charges twice, because to the server each click is a new order.

class Checkout:
    def __init__(self) -> None:
        self.charges: list[tuple[str, int]] = []

    def pay(self, customer: str, amount: int) -> str:
        self.charges.append((customer, amount))      # every call charges
        return f"charge-{len(self.charges)}"

Retries are everywhere: a user double-clicks, a client library retries a timeout, a queue redelivers a message, a crashed process restarts and replays its last step. If repeating a call changes the outcome, every one of those becomes a bug. An operation is idempotent when doing it twice has the same effect as doing it once, and the trick is to make the important operations idempotent on purpose.

Build it one piece at a time

Step one: the caller sends a key that identifies the attempt, not the click. The client generates it once per intended payment and reuses it on every retry. The server remembers which keys it has seen and returns the original result for a repeat instead of acting again.

class Checkout:
    def __init__(self) -> None:
        self.charges: list[tuple[str, int]] = []
        self.seen: dict[str, str] = {}               # idempotency key -> charge id

    def pay(self, customer: str, amount: int, key: str) -> str:
        if key in self.seen:
            return self.seen[key]                    # replay: same answer, no new charge
        self.charges.append((customer, amount))
        charge_id = f"charge-{len(self.charges)}"
        self.seen[key] = charge_id
        return charge_id

Step two: the check and the act must be one step, or two concurrent retries both pass the check. Put them under a lock, and record the key before doing the side effect so a crash in between leaves a record to reconcile rather than a silent double charge.

import threading

class Checkout(Checkout):   # continued
    _lock = threading.Lock()

    def pay(self, customer: str, amount: int, key: str) -> str:
        with self._lock:
            if key in self.seen:
                return self.seen[key]
            self.seen[key] = "pending"              # claim the key first
        charge_id = self._charge(customer, amount)   # slow part outside the lock
        with self._lock:
            self.seen[key] = charge_id
        return charge_id

    def _charge(self, customer: str, amount: int) -> str:
        self.charges.append((customer, amount))
        return f"charge-{len(self.charges)}"

Step three: prefer operations that are idempotent by their shape, so no key is needed. "Set balance to 50" can be repeated; "add 10 to balance" cannot. "Mark order 17 as shipped" can be repeated; "ship the next order" cannot. Designing state changes as assignments to a named target removes a whole class of retry bugs.

class Order:
    def __init__(self) -> None:
        self.status = "new"

    def mark_shipped(self) -> None:
        self.status = "shipped"          # safe to call twice

class Counter:
    def __init__(self) -> None:
        self.value = 0
        self.applied: set[str] = set()

    def add(self, delta: int, op_id: str) -> None:
        if op_id in self.applied:        # an increment needs a key
            return
        self.applied.add(op_id)
        self.value += delta

Three calls with the same key, one of them racing:

Callseen beforeActionReturned
pay(k1), first clickabsentclaim k1, chargecharge-1
pay(k1), retry during chargependingno chargepending (caller polls or waits)
pay(k1), retry aftercharge-1no chargecharge-1
pay(k2), new orderabsentclaim k2, chargecharge-2

Why it works

The invariant is that each idempotency key produces at most one side effect, ever. Claiming the key before acting, under a lock, makes that true even with concurrent retries, and storing the result with the key makes the repeat return the same answer, which is what the retrying client needs. Operations shaped as assignments carry the invariant in their meaning, with no bookkeeping at all.

Where it breaks: the key store grows forever unless keys expire, so keep them for as long as a retry is plausible (hours or days) and no longer. A key reused for a different payload is a client bug; store a hash of the request with the key and reject mismatches. And a "pending" claim left behind by a crash must be reconciled with the real side effect, which is why the slow part should itself be checkable.

without keys: expected charges per intended payment = 1 + retry rate. With keys: exactly 1

The trade-off you must say out loud

Idempotency costs a key per operation, storage for seen keys, and the discipline of claiming before acting; it buys the freedom to retry anything, which is the foundation every reliable system rests on. Say which operations are naturally idempotent, which need a key, and how long keys live. The follow-up that usually comes next is "who generates the key", and the answer is the caller, as close to the user's intent as possible, so that a retry of the same intent reuses it.

Where it shows up in the 100 Google problems

02Used in these problems