GAP·MAP

Idempotency & exactly-once

[ middle depth ]

What idempotency means

An operation is idempotent when applying it once and applying it many times leave the same effect on server state. The definition is about state, not the response you get back. MDN frames it as: the intended effect on the server of one request equals the effect of several identical requests.

The canonical example is DELETE. The first DELETE /orders/42 returns 200 and removes the order; a repeated DELETE /orders/42 returns 404 because the order is already gone. The status codes differ, but the state effect (the order is absent) is identical, so DELETE is idempotent. An incidental side effect like an access-log write does not change that, since it was not the client's intended effect.

Keep idempotent separate from safe. A safe method changes no state at all (GET, HEAD, OPTIONS, TRACE). Every safe method is idempotent, and the reverse does not hold: PUT and DELETE change state yet are still idempotent.

Which HTTP methods are idempotent

  • GET, HEAD, OPTIONS, TRACE: safe and idempotent (read-only).
  • PUT: idempotent. It replaces the resource with a full representation, so repeating it writes the same final state.
  • DELETE: idempotent. However many times you call it, the end state (resource absent) is the same.
  • POST: not idempotent. The server decides what gets created, so repeating a POST may create duplicate resources.
  • PATCH: not idempotent by spec. A partial or relative update such as "increment balance by 1" is not repeatable.

Wrong "PATCH is like PUT, so it is idempotent." PUT sends a complete replacement to a fixed target state, which is why repeating it is safe. PATCH sends a change, and one that depends on the current value (increment, append) gives a different result each time. PATCH can be written to be idempotent, but the spec does not guarantee it. Right PUT and DELETE are idempotent; POST and PATCH are not.

Idempotency keys make a retry safe

When a POST times out, the client cannot tell whether the request was lost or only the response was lost, and re-sending a plain POST risks a second charge or a duplicate order. An idempotency key removes that risk. The client generates a unique key (Stripe uses the Idempotency-Key header) and sends it with the request. The server records the key together with the result it produced (status code and response body). On any retry carrying the same key, the server returns the stored result without running the operation again.

first  POST /charges  Idempotency-Key: K1  -> charge card, save K1 -> (200, charge_abc), return it
retry  POST /charges  Idempotency-Key: K1  -> found K1, return saved (200, charge_abc), no second charge

Stripe replays the saved status and body even when the first attempt returned a 500, and it keeps a key for at least 24 hours before the key may be pruned. Reusing a key with different request parameters returns an error, because the key is bound to the original request rather than acting as a free-floating token. A retried POST behind a flaky network is only safe when the operation is idempotent on its own or protected by a key.

[ senior depth ]

Exactly-once delivery is a myth

Split delivery from processing. Exactly-once delivery over an unreliable network is impossible. The sender cannot distinguish a lost message from a lost acknowledgement, so it must either never resend (at-most-once, which risks dropping the message) or resend on doubt (at-least-once, which risks duplicates). This is the well-known Two Generals problem, and no finite protocol escapes it.

What you can achieve is exactly-once processing: at-least-once delivery plus an idempotent consumer. The message may arrive several times, but the effect lands once because the consumer dedupes. When a vendor advertises "exactly-once," this combination is what they mean, a guarantee about the effect rather than the wire.

What Kafka's exactly-once covers

Kafka's exactly-once semantics (since 0.11) rests on two mechanisms:

  • Idempotent producer (enable.idempotence, default true since Kafka 3.0): the broker assigns each producer a PID and each record batch a per-partition sequence number, then drops any retried batch it has already seen, so a producer retry writes the record to the log once. Enabling it requires acks=all, retries greater than 0, and max.in.flight.requests.per.connection at most 5. The dedup scope is per partition, and it survives broker failover because the sequence lives in the replicated log.
  • Transactions: atomic writes across multiple partitions, with the consumer's offset commit included in the same transaction as the produced output. That makes the consume, transform, produce loop atomic. Consumers set isolation.level=read_committed to read only committed records.

The scope is the trap: these guarantees hold only inside Kafka, topic to topic. A database write, HTTP call, or email from the consumer sits outside the transaction (the dual-write problem) and can still duplicate on a retry.

Wrong "We turned on Kafka exactly-once, so our whole pipeline including the database is exactly-once." Right Kafka EOS covers Kafka-internal read-process-write; every external side effect needs its own idempotency, through idempotent writes, a transactional outbox, or a target system that is itself transactional.

Recording the key and the effect must be atomic

The dedup record and the mutation must commit as one unit. AWS states it directly: recording the idempotency token and all mutating operations must satisfy ACID. Charge the card first and save the key separately, and a crash in between lets the next retry charge again.

A naive check-then-act races: two concurrent requests with the same key both read "not seen," both proceed, and both apply the effect. Close it atomically, with a unique constraint on the key column so the second insert fails, or SET key val NX EX in Redis so only the first writer wins.

Dedup strategies

  • Dedup table: persist each seen key or message id with a TTL and the stored result, then skip on a repeat. It must be written under a unique constraint or in the same transaction as the effect.
  • Natural or business keys: derive dedup from data that is already unique, such as PUT /orders/{orderId} with a client-chosen id. A repeated insert violates the unique constraint and is treated as already done. This is why a PUT with a client-supplied id is naturally idempotent.
  • Sequence numbers: the consumer tracks the highest offset processed and discards anything at or below it. Cheap and ordered, though it needs a single ordered stream per key (Kafka producer sequence numbers, TCP, a database high-water mark).

Message brokers and webhook senders are at-least-once by default (Kafka, SQS standard queues, SNS), so the consumer is where exactly-once processing has to be enforced.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more API Design

was this useful?