
You tap Pay. The spinner spins. Nothing happens. After ten seconds you tap again.
Later your bank app shows two charges for the same order.
Nobody was careless. The app did what apps do, which is retry when it doesn't hear back. The trouble is that "didn't hear back" and "didn't work" look identical from the outside.
Idempotency keys fix that. They're why Stripe, Adyen and PayPal can tell you "just retry" without you getting charged twice.
And they aren't magic. They come down to one extra header and one careful database step.
This post covers why retries are dangerous, how the big providers handle them, and how to build the pattern yourself. The code is tested, including the nasty cases: two identical requests at the same instant, and a server that dies halfway through.
The problem: a timeout tells you nothing
Here's the classic failure:
sequenceDiagram
participant C as Client
participant S as Your API
participant B as Bank
C->>S: POST /charges ($25)
S->>B: charge $25
B-->>S: success
S--xC: response lost (timeout)
Note over C: Did it work? No way to know.
C->>S: POST /charges ($25) again
S->>B: charge $25
B-->>S: success
S-->>C: 201 Created
Note over B: The customer was charged twiceThe client sent the request. The bank charged the card. The response got lost on the way back.
From the client's seat, that looks exactly like a request that never arrived. Three different failures produce the same symptom:
What actually went wrong | Did money move? | What the client sees |
|---|---|---|
The request never reached the server | No | A timeout |
The server crashed before charging | No | A timeout or a 5xx |
The charge worked, but the response was lost | Yes | A timeout |
Same symptom, opposite truths. Retry after the third one and you've charged twice. Don't retry after the first and the customer never gets their order.
A good client wants to retry. It just needs a way to say "this is the same request as before".
"Idempotent" in plain words
An operation is idempotent if doing it once or ten times has the same effect. Setting a thermostat to 21 degrees is idempotent. Pressing "plus one degree" isn't.
HTTP already builds this in. Per the HTTP spec (RFC 9110), some methods are idempotent and some aren't:
Method | Idempotent? | Why |
|---|---|---|
| Yes | They only read |
| Yes | "Set this to X" twice still leaves X |
| Yes | Deleted stays deleted |
| No | "Create a charge" twice creates two charges |
Creating a charge is a POST. So HTTP gives you no protection exactly where money is involved.
Same effect, not same response. Delete a file twice and the second call may return 404 instead of 204. That's still idempotent. What counts is the effect on the server, not the status code.
The idea: the client names the operation
An idempotency key is a unique value the client makes up for each logical operation, sent in a header:
POST /charges HTTP/1.1
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json
{"amount": 2500, "card": "tok_visa"}The server remembers the result against that key. If the same key shows up again, it returns the saved result instead of doing the work a second time.
This is the decision the server makes on every request:
flowchart TD
A["POST with an Idempotency-Key"] --> B{"Key present and valid?"}
B -->|no| E1["400 Bad Request"]
B -->|yes| C{"Seen this key before?"}
C -->|no| D["Claim the key, do the work, save the result"]
D --> R1["Return the result"]
C -->|yes| F{"Same request body?"}
F -->|no| E2["422 Unprocessable Content"]
F -->|yes| G{"Did the first request finish?"}
G -->|no| E3["409 Conflict, try again shortly"]
G -->|yes| R2["Replay the saved response"]The word "logical" matters. "Charge this customer $25 for order 1042" gets one key. Every retry of that charge sends the same key. A different charge gets a different key.
How Stripe, Adyen and PayPal do it
Most big payment providers have some version of this. The details differ:
Provider | Where the key goes | Key length | How long it's kept |
|---|---|---|---|
Stripe |
| Up to 255 characters | May be removed after at least 24 hours (API v1) |
Adyen |
| Up to 64 characters | Valid for at least 7 days |
PayPal |
| Not stated on its requests page | Stored for up to 45 days |
Square takes a different route and wants an idempotency_key field in the request body instead of a header.
A few details are worth knowing, because they show the design choices:
Stripe saves the first result, good or bad. For API v1 it stores the status code and body once the endpoint starts running, and replays it for the same key. That includes 500 errors.
Stripe compares parameters. Reuse a key with different parameters and you get an error, which catches accidental misuse.
Some failures aren't saved. If the request fails validation, or collides with another one still running, Stripe doesn't save a result, and you can retry.
Adyen tells you when to retry. If a request hits a transient problem, the response carries a
transient-errorheader set totrue. Retry with the same key only in that case.PayPal recommends the header on every call that creates or changes data.
Stripe API v2 works differently. Stripe's docs say that with v2, retries don't repeat successful operations, and Stripe retries failed or partly failed work to completion when it can. Check which API version you're on before assuming v1 behavior.
Is it a standard?
Almost. There's an IETF draft called The Idempotency-Key HTTP Header Field. The newest revision, draft -08, was published on October 22, 2026. It's still a draft, not an RFC.
It suggests three status codes that line up with the flowchart above:
400 when the key is missing on an operation that requires one
422 when the key is reused with a different payload
409 when the original request is still being processed
One small difference from common practice: the draft defines the value as a quoted string, like "8e03978e-...". Stripe's docs show it unquoted. Pick one format, document it, and validate it.
What makes a good key
The key is the client's job, so it's worth getting right:
Random, not guessable. A UUID v4 is the usual choice, and the draft recommends a UUID or similar random value.
One per logical operation. Create it when the user starts the action, and reuse it on every retry. A fresh key inside the retry loop gives you the header and none of the protection.
Saved with the pending operation. If the app restarts mid-payment, the retry must find the same key. Store it next to the pending order.
Never reused for a different request. Same key plus different parameters is a bug, and a good server will tell you so.
If you use Stripe's official libraries, Stripe says you can turn on automatic retries and it will generate keys and repeat requests for you when that's safe.
Let's build it
We'll build the server side, because that's where the hard parts live. It's a tiny payments API with five files:
File | What it does |
|---|---|
| The key store: claim, replay, expire |
| A fake bank, plus the charge logic that uses the store |
| A FastAPI endpoint wrapping it |
| A client that retries the right way |
| Tests, including races and crashes |
You need Python 3.10 or newer. The store uses SQLite so there's nothing to install, but the ideas carry over to Postgres.
mkdir payments-demo && cd payments-demo
python3 -m venv venv
source venv/bin/activate
pip install fastapi "uvicorn[standard]" pytestOn Windows PowerShell, activate with .\venv\Scripts\Activate.ps1.
Step 1: The key store
Create idempotency.py:
import hashlib
import json
import sqlite3
import time
from contextlib import closing
from dataclasses import dataclass
@dataclass
class Claim:
kind: str # "new", "replay", "in_progress" or "mismatch"
status_code: int | None = None
body: dict | None = None
def fingerprint(payload: dict) -> str:
"""Hash the request body so we can tell whether a key is being reused for something else."""
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode()).hexdigest()
class IdempotencyStore:
def __init__(self, path, ttl=24 * 3600, lock_timeout=30, clock=time.time):
self.path, self.ttl, self.lock_timeout, self.clock = path, ttl, lock_timeout, clock
with closing(self._connect()) as db:
db.execute(
"""CREATE TABLE IF NOT EXISTS idempotency_keys (
account TEXT NOT NULL,
key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status_code INTEGER, -- NULL while the first request is still running
body TEXT,
locked_at REAL NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (account, key)
)"""
)
def _connect(self):
return sqlite3.connect(self.path, timeout=10, isolation_level=None)
def claim(self, account: str, key: str, request_hash: str) -> Claim:
now = self.clock()
db = self._connect()
try:
db.execute("BEGIN IMMEDIATE") # one writer at a time, so check-and-insert is atomic
db.execute(
"DELETE FROM idempotency_keys WHERE account = ? AND key = ? AND created_at < ?",
(account, key, now - self.ttl),
)
inserted = db.execute(
"INSERT OR IGNORE INTO idempotency_keys"
" (account, key, request_hash, locked_at, created_at) VALUES (?, ?, ?, ?, ?)",
(account, key, request_hash, now, now),
).rowcount
if inserted:
outcome = Claim("new")
else:
saved_hash, status, body, locked_at = db.execute(
"SELECT request_hash, status_code, body, locked_at FROM idempotency_keys"
" WHERE account = ? AND key = ?",
(account, key),
).fetchone()
if saved_hash != request_hash:
outcome = Claim("mismatch")
elif status is not None:
outcome = Claim("replay", status, json.loads(body))
elif now - locked_at > self.lock_timeout:
# The first attempt looks dead (crashed?). Let this request take over.
db.execute(
"UPDATE idempotency_keys SET locked_at = ? WHERE account = ? AND key = ?",
(now, account, key),
)
outcome = Claim("new")
else:
outcome = Claim("in_progress")
db.execute("COMMIT")
return outcome
except BaseException:
if db.in_transaction:
db.execute("ROLLBACK")
raise
finally:
db.close()
def complete(self, account: str, key: str, status_code: int, body: dict) -> None:
with closing(self._connect()) as db:
db.execute(
"UPDATE idempotency_keys SET status_code = ?, body = ? WHERE account = ? AND key = ?",
(status_code, json.dumps(body), account, key),
)
def release(self, account: str, key: str) -> None:
"""Forget a key whose request never finished, so the client can retry."""
with closing(self._connect()) as db:
db.execute(
"DELETE FROM idempotency_keys WHERE account = ? AND key = ? AND status_code IS NULL",
(account, key),
)This is the heart of it. Everything else is plumbing around claim.
Each call to claim follows the same path:
Start a transaction that takes the write lock.
BEGIN IMMEDIATEmeans only one request at a time can be in this step.Drop this key's record if it's past retention. Then an old key can be reused as new.
Try to insert a row.
INSERT OR IGNOREwith a primary key on(account, key)means exactly one request can succeed. If it did, it's the first, and the answer isnew.Otherwise, read the row and decide. Different body means
mismatch. A finished one meansreplay. One still running meansin_progress.
The whole design hangs on step 3. The database decides who's first, so your code doesn't have to guess.
The mistake to avoid: checking first and inserting later.
# DON'T: two requests can both pass this check before either inserts row = db.execute("SELECT 1 FROM idempotency_keys WHERE key = ?", (key,)).fetchone() if row is None: do_the_work() # real handlers do other work here, which widens the gap db.execute("INSERT INTO idempotency_keys ...")With a gap between the check and the insert, simultaneous requests can all get through. In a small demo the gap is only microseconds, so the race is rare. In a real service that gap holds a query or a network call, and it gets much wider.
Three smaller details:
The fingerprint.
fingerprint()hashes the body with sorted keys, so field order doesn't matter. Same key plus a different hash means someone's reusing a key for a different request.The lock timeout. If a worker dies after claiming a key, the row would look busy forever. After 30 seconds a retry is allowed to take over.
The account. The key is unique per
(account, key), so one customer's key can never match another's.
In Postgres, the same step is INSERT ... ON CONFLICT (account, key) DO NOTHING, followed by a check of how many rows were inserted.
Step 2: The payment logic
Create payments.py:
import threading
import time
from idempotency import IdempotencyStore, fingerprint
class CardDeclined(Exception):
pass
class Bank:
"""A pretend payment processor. Like real ones, it also dedupes on the idempotency key."""
def __init__(self, delay: float = 0.0):
self.delay = delay
self.calls = 0 # how many times we were asked to charge
self.charges: list[dict] = [] # how many times money actually moved
self.lose_next_response = False # charge the card, then "lose" the reply
self._by_key: dict[str, dict] = {}
self._lock = threading.Lock()
def charge(self, amount: int, card: str, idempotency_key: str) -> dict:
time.sleep(self.delay)
with self._lock:
self.calls += 1
if idempotency_key in self._by_key:
return self._by_key[idempotency_key] # same key, same charge, no second debit
if card == "tok_declined":
raise CardDeclined("card_declined")
charge = {"id": f"ch_{len(self.charges) + 1}", "amount": amount}
self.charges.append(charge)
self._by_key[idempotency_key] = charge
lose, self.lose_next_response = self.lose_next_response, False
if lose:
raise TimeoutError("the bank charged the card, but the reply never arrived")
return charge
class PaymentService:
def __init__(self, store: IdempotencyStore, bank: Bank):
self.store, self.bank = store, bank
def create_charge(self, account: str, key: str | None, payload: dict):
"""Returns (status_code, body, extra_headers)."""
if not key or len(key) > 255:
return 400, {"error": "Send an Idempotency-Key header of 1 to 255 characters"}, {}
claim = self.store.claim(account, key, fingerprint(payload))
if claim.kind == "replay":
return claim.status_code, claim.body, {"Idempotent-Replayed": "true"}
if claim.kind == "mismatch":
return 422, {"error": "This Idempotency-Key was already used with different parameters"}, {}
if claim.kind == "in_progress":
return 409, {"error": "A request with this Idempotency-Key is still being processed"}, {"Retry-After": "1"}
try:
# Pass our key down too, so the bank can dedupe if we have to try again.
charge = self.bank.charge(payload["amount"], payload["card"], idempotency_key=f"{account}:{key}")
status, body = 201, {"id": charge["id"], "status": "succeeded", "amount": charge["amount"]}
except CardDeclined:
status, body = 402, {"error": "card_declined"} # a definite answer: safe to replay
except Exception:
self.store.release(account, key) # outcome unknown: let the client retry
raise
self.store.complete(account, key, status, body)
return status, body, {}Start with Bank. It's a stand-in for a real payment processor, with test hooks to make it slow, make it decline, or "lose" a reply after charging.
Notice that it dedupes on a key too. Real processors do this, and it matters a lot, as you'll see in a moment.
Now create_charge, in order:
Reject bad keys with a 400, before touching anything.
Claim the key. Replays, mismatches and in-progress requests return early, with 201 or 402 as saved, then 422 and 409.
Do the work. Call the bank.
Save the outcome, then return it.
What gets saved is a design choice:
Outcome | What we do | Why |
|---|---|---|
Charge succeeded (201) | Save and replay | The obvious case |
Card declined (402) | Save and replay | A definite answer. A retry with the same key should get the same decline, and a new attempt should use a new key |
Unexpected error or timeout | Release the key and re-raise | We don't know what happened, so the client must be able to try again |
That last row is only safe because of one line:
self.bank.charge(..., idempotency_key=f"{account}:{key}")We pass our key down to the bank. If we release the key and the client retries, the bank sees the same key and returns the existing charge instead of making a second one.
Idempotency has to go all the way down. A layer that dedupes requests but calls a non-idempotent dependency is only protecting half the path.
Stripe v1 saves even 500s. We don't, on purpose. Replaying a 500 is simple, but it also locks in a failure that might have been temporary. We can afford to re-run because the bank dedupes. If your downstream call has no idempotency support, saving the failure may be the safer choice.
Step 3: The FastAPI endpoint
Create main.py:
from fastapi import FastAPI, Header, Response
from pydantic import BaseModel, Field
from idempotency import IdempotencyStore
from payments import Bank, PaymentService
app = FastAPI(title="payments-demo")
bank = Bank(delay=0.6) # slow on purpose, so a retry can overlap with the first request
service = PaymentService(IdempotencyStore("idempotency.db"), bank)
class ChargeRequest(BaseModel):
amount: int = Field(gt=0, description="Amount in cents")
card: str
@app.post("/charges")
def create_charge(
body: ChargeRequest,
response: Response,
idempotency_key: str | None = Header(default=None),
x_account: str = Header(default="acct_demo"), # demo only: use your authenticated account instead
):
status, payload, headers = service.create_charge(x_account, idempotency_key, body.model_dump())
response.status_code = status
for name, value in headers.items():
response.headers[name] = value
return payload
@app.get("/charges")
def list_charges():
"""Every time money actually moved. Handy for checking that retries didn't double-charge."""
return bank.chargesThe endpoint is thin. It reads the header, calls the service, and copies the status and headers onto the response.
Two things to notice. FastAPI rejects bad bodies with its own 422 before our code runs, so invalid requests never claim a key. And the body we fingerprint is the validated one, so defaults and field order can't cause false mismatches.
The service sets an Idempotent-Replayed: true header on replays, so clients and logs can tell a replay from fresh work.
Don't take the account from a header in real life.
X-Accountkeeps the demo small. In a real API, use the authenticated caller, like the account behind the API key. The IETF draft warns that low-entropy or unscoped keys let attackers fetch other clients' saved responses.
Step 4: A client that retries correctly
Create client.py:
import json
import os
import time
import urllib.error
import urllib.request
import uuid
URL = os.environ.get("URL", "http://localhost:8000/charges")
RETRYABLE = {409, 429, 500, 502, 503, 504}
def charge(amount, card, attempts=5, timeout=0.25):
key = str(uuid.uuid4()) # one key per logical charge, reused by every retry below
data = json.dumps({"amount": amount, "card": card}).encode()
for attempt in range(1, attempts + 1):
request = urllib.request.Request(
URL,
data=data,
method="POST",
headers={"Content-Type": "application/json", "Idempotency-Key": key},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
replayed = response.headers.get("Idempotent-Replayed") == "true"
print(f"attempt {attempt}: {response.status}" + (" (replayed)" if replayed else ""))
return json.load(response)
except urllib.error.HTTPError as error:
print(f"attempt {attempt}: {error.code}")
if error.code not in RETRYABLE:
return json.load(error)
except (TimeoutError, urllib.error.URLError) as error:
print(f"attempt {attempt}: no answer ({type(error).__name__})") # did it work? Unknown.
time.sleep(0.2 * 2 ** (attempt - 1)) # back off before retrying with the SAME key
raise RuntimeError(f"gave up on idempotency key {key}")
if __name__ == "__main__":
print(charge(2500, "tok_visa"))
print("charges on file:", json.load(urllib.request.urlopen(URL)))The rule that makes it work is on one line: the key is created once, outside the loop, and every retry reuses it.
The client retries when it gets no answer, and on 409, 429 and 5xx responses. It backs off between tries. It does not retry other 4xx errors, because those mean the request itself needs fixing.
Start the server and run the client:
uvicorn main:app --port 8000In a second terminal:
python client.pyThe fake bank takes 0.6 seconds and the client's timeout is 0.25, so the first attempt gives up early. You should see something like this:
attempt 1: no answer (TimeoutError)
attempt 2: 409
attempt 3: 201 (replayed)
{'id': 'ch_1', 'status': 'succeeded', 'amount': 2500}
charges on file: [{'id': 'ch_1', 'amount': 2500}]Exact timings can shift the attempt numbers a little, but the ending shouldn't: one charge on file.
Here's what happened:
sequenceDiagram
participant C as Client
participant S as Your API
participant B as Bank
C->>S: POST (key abc)
S->>B: charge (key acct:abc)
Note over C: Gives up after 250 ms
C->>S: POST (key abc), retry
S-->>C: 409, still processing
B-->>S: success
S->>S: save the result under abc
C->>S: POST (key abc), retry
S-->>C: 201, replayedThe client never learned whether the first attempt worked. It didn't need to. It kept asking with the same key until it got a definite answer.
Step 5: Test the ugly cases
Create test_payments.py:
import os
import tempfile
import threading
from idempotency import IdempotencyStore
from payments import Bank, PaymentService
PAYMENT = {"amount": 2500, "card": "tok_visa"}
class FakeClock:
def __init__(self):
self.now = 1_000_000.0
def __call__(self):
return self.now
def make_service(bank_delay=0.0):
clock = FakeClock()
store = IdempotencyStore(os.path.join(tempfile.mkdtemp(), "keys.db"), clock=clock)
bank = Bank(delay=bank_delay)
return PaymentService(store, bank), bank, store, clock
def test_retry_with_the_same_key_charges_once():
service, bank, _, _ = make_service()
first = service.create_charge("acct_1", "key-1", PAYMENT)
second = service.create_charge("acct_1", "key-1", PAYMENT)
assert first[0] == 201 and second[0] == 201
assert second[1] == first[1] # same body back
assert second[2] == {"Idempotent-Replayed": "true"}
assert len(bank.charges) == 1 and bank.calls == 1 # the bank was only asked once
def test_new_key_means_new_charge():
service, bank, _, _ = make_service()
service.create_charge("acct_1", "key-1", PAYMENT)
service.create_charge("acct_1", "key-2", PAYMENT)
assert len(bank.charges) == 2
def test_missing_key_is_rejected():
service, bank, _, _ = make_service()
status, _, _ = service.create_charge("acct_1", None, PAYMENT)
assert status == 400 and bank.calls == 0
def test_oversized_key_is_rejected():
service, bank, _, _ = make_service()
status, _, _ = service.create_charge("acct_1", "k" * 256, PAYMENT)
assert status == 400 and bank.calls == 0
def test_same_key_with_different_body_is_rejected():
service, bank, _, _ = make_service()
service.create_charge("acct_1", "key-1", PAYMENT)
status, _, _ = service.create_charge("acct_1", "key-1", {"amount": 9900, "card": "tok_visa"})
assert status == 422 and len(bank.charges) == 1
def test_a_decline_is_replayed_not_retried():
service, bank, _, _ = make_service()
declined = {"amount": 2500, "card": "tok_declined"}
first = service.create_charge("acct_1", "key-1", declined)
second = service.create_charge("acct_1", "key-1", declined)
assert first[0] == 402 and second[0] == 402
assert second[2] == {"Idempotent-Replayed": "true"}
assert bank.calls == 1 # the second attempt never reached the bank
def test_keys_are_scoped_to_the_account():
service, bank, _, _ = make_service()
service.create_charge("acct_1", "key-1", PAYMENT)
status, _, headers = service.create_charge("acct_2", "key-1", PAYMENT)
assert status == 201 and headers == {} and len(bank.charges) == 2
def test_eight_simultaneous_requests_charge_once():
service, bank, _, _ = make_service(bank_delay=0.2)
barrier = threading.Barrier(8)
results = []
def attempt():
barrier.wait() # start all eight at the same moment
results.append(service.create_charge("acct_1", "key-1", PAYMENT))
threads = [threading.Thread(target=attempt) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
did_the_work = [r for r in results if r[0] == 201 and not r[2]]
assert len(did_the_work) == 1 # exactly one request ran the charge
assert all(r[0] in (201, 409) for r in results) # the rest waited (409) or got the saved answer
assert len(bank.charges) == 1 and bank.calls == 1 # and only one of them reached the bank
# Once it finishes, a retry just gets the saved answer.
assert service.create_charge("acct_1", "key-1", PAYMENT)[2] == {"Idempotent-Replayed": "true"}
def test_lost_response_then_retry_charges_once():
service, bank, _, _ = make_service()
bank.lose_next_response = True
try:
service.create_charge("acct_1", "key-1", PAYMENT)
raise AssertionError("expected a timeout")
except TimeoutError:
pass
status, _, _ = service.create_charge("acct_1", "key-1", PAYMENT) # the client retries
assert status == 201
assert len(bank.charges) == 1 and bank.calls == 2 # asked twice, charged once
def test_crash_after_charging_is_recovered_once_the_lock_expires():
service, bank, store, clock = make_service()
# Pretend a worker claimed the key, charged the card, then died before saving anything.
store.claim("acct_1", "key-1", fingerprint_of(PAYMENT))
bank.charge(2500, "tok_visa", idempotency_key="acct_1:key-1")
assert service.create_charge("acct_1", "key-1", PAYMENT)[0] == 409 # still looks busy
clock.now += store.lock_timeout + 1
assert service.create_charge("acct_1", "key-1", PAYMENT)[0] == 201 # takes over
assert len(bank.charges) == 1
def test_keys_expire_after_the_ttl():
_, _, store, clock = make_service()
store.claim("acct_1", "key-1", "hash")
store.complete("acct_1", "key-1", 201, {"ok": True})
assert store.claim("acct_1", "key-1", "hash").kind == "replay"
clock.now += store.ttl + 1
assert store.claim("acct_1", "key-1", "hash").kind == "new"
def fingerprint_of(payload):
from idempotency import fingerprint
return fingerprint(payload)Run it:
pytest -qThese are plain functions with no fixtures, so they run under pytest as they are.
The ones to read closely:
test_eight_simultaneous_requests_charge_oncestarts eight threads at the same moment with the same key. Exactly one does the work, and only one request ever reaches the bank. Swap the claim for the check-then-insert version from Step 1, add a 10 ms pause between the check and the insert, and this test fails every time.test_lost_response_then_retry_charges_oncehas the bank charge the card and then "lose" the reply. The service releases the key, the retry goes through, and the bank dedupes. The bank was asked twice and charged once.test_crash_after_charging_is_recovered_once_the_lock_expiresfakes a worker that charged the card and died. The retry gets a 409 at first, then takes over after the lock timeout.
The clock is a fake you control, so expiry and lock timeouts are tested without sleeping.
What can still go wrong
This design handles the common failures. Here's the full map, including where it stops helping:
Situation | What happens here |
|---|---|
Client times out but the server did the work | The retry gets the saved response, or a 409 until it's saved |
Two identical requests at once | One claims the key. The other gets 409 |
Same key, different body | 422, and nothing runs |
Server dies mid-request | The key looks busy. After the lock timeout a retry takes over, and the bank dedupes by key |
Bank times out, outcome unknown | The key is released. The retry reuses the bank key, so there's no second debit |
Your downstream call has no idempotency support | This layer can't save you |
That last row is the hard one. If a dependency can't dedupe, a crash between "charged" and "saved" can leave you not knowing what happened.
The standard answer, described in Brandur Leach's well-known write-up on Stripe-style idempotency keys, is to split the work into atomic phases. Each phase is a database transaction.
After each one you record a "recovery point", so a retry resumes from the last known step instead of starting over. Side effects you can't roll back, like calling an outside API, happen between phases.
If you can't do that, plan for reconciliation: a job that compares your records with the provider's.
Before you ship it
A checklist for taking the pattern to production:
Scope keys to the authenticated account, as in the note under Step 3.
Validate the key. Enforce a length limit and a format, and prefer random UUIDs.
Match retention to your clients' retry window. Providers range from 24 hours to 45 days. Run a background job to delete old rows, because the demo only deletes a key when it's touched again.
Keep sensitive data out of saved responses. The store holds a copy of what you returned, so don't save anything you wouldn't want sitting in a table.
Share a transaction when you can. If the business write and the key record live in the same database, commit them together.
Watch the counters. Count replays, 409s and 422s. A spike usually means a client bug.
Retry with backoff and jitter. And cap the attempts. When a client gives up, log the key so support can look the operation up.
Test the races. A happy-path test will pass on a broken implementation.
Recap
A timeout doesn't tell the client whether the work happened. Retrying blindly can repeat it.
An idempotency key lets the client say "this is the same operation", and lets the server answer from memory.
One key per logical operation, reused on every retry.
The server must claim the key atomically. Check-then-insert has a race.
Save definite outcomes. For unknown ones, release the key.
Pass the key to whatever you call next, so the protection goes all the way down.
Checked against the Stripe, Adyen and PayPal documentation and the IETF draft (revision -08) on October 26, 2026. Provider limits change, so confirm key length and retention in their current docs before you rely on them.
Sources
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!