Movsar Tsakharov
← Home

Idempotency Keys: Making Retries Safe

Here's a question with no good answer: your client sends POST /charges, and the connection times out. Did the charge go through?

You don't know. The request might have failed before reaching the server — safe to retry. Or it might have succeeded, with only the response lost on the way back — in which case retrying charges the customer twice. At-least-once delivery is the only guarantee the network offers, and "at least once" is a problem for anything that moves money.

Idempotency keys

The fix is to let the client label each logical operation with a unique key:

POST /charges
Idempotency-Key: 9f8b2c1a-1d3e-4a5b-8c7d-2e1f0a9b8c7d

The server promises: for a given key, the operation runs at most once, and every request carrying that key gets the same response — whether it's the first attempt or the fifth retry.

The client generates the key once (a UUID) and reuses it for every retry of that one operation. A genuinely new operation gets a new key.

The server-side flow

Step Action
1 Look up the key. A hit with a stored response? Return it verbatim.
2 On a miss, insert the key in an in-progress state (unique constraint).
3 Do the work — charge the card — inside a transaction.
4 Store the response body and status against the key.
5 Return the response.

The unique constraint in step 2 is what makes this safe under concurrency. If two retries race, only one wins the insert; the loser sees the row already exists and returns (or waits for) the winner's result instead of charging again.

func (h *Handler) Charge(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        http.Error(w, "Idempotency-Key required", http.StatusBadRequest)
        return
    }
    if saved, ok := h.store.Get(key); ok {
        writeJSON(w, saved.Status, saved.Body) // replay the stored result
        return
    }
    if !h.store.Claim(key) { // atomic insert; false if another request won
        http.Error(w, "request already in progress", http.StatusConflict)
        return
    }
    resp := h.charge(r) // the real work, exactly once
    h.store.Save(key, resp)
    writeJSON(w, resp.Status, resp.Body)
}

The details that bite

Idempotency keys don't make an operation idempotent by magic. They give you a place to remember that it already happened — and remembering is enough.

Stripe popularized the Idempotency-Key header, and it's since become the default pattern for payments, provisioning, and any write where "just run it again" isn't a safe answer.