Backend engineering · API design
Idempotency keys: making a FastAPI intake endpoint safe to retry
A network timeout does not tell the caller whether the write happened. Idempotency keys turn “did that go through?” from a guess into a lookup.
Any endpoint that creates something — a load record, an invoice, a support ticket — has the same failure mode. A client sends a request, the server processes it, and the response is lost on the way back: a proxy times out, a mobile connection drops, a worker restarts mid-response. The client only knows it did not get an answer. If it retries the same request, and the endpoint is not built to notice, the result is a duplicate row, not a retried write.
This shows up constantly in message-driven intake work, where a carrier’s WhatsApp message or a forwarded email becomes a structured record through an automated pipeline. If the extraction step reruns after a crash, or an upstream queue redelivers a message it already delivered, the endpoint that turns that message into a database row needs to know it has seen this exact request before — without a human checking for duplicates afterward.
What an idempotency key actually is
An idempotency key is a client-generated identifier, sent as a header, that names one logical write attempt rather than one HTTP request. The client generates it once — often a UUID — and sends the same value on every retry of that same logical operation. The server records which keys it has already processed and what the resulting response was, so a duplicate request produces the same stored response instead of doing the work twice.
The key is not the same thing as a resource ID, and it is not the same thing as a request ID a logging system assigns automatically. It has to be chosen by whoever might retry — usually the client — and it has to survive the retry unchanged. Any hidden randomness upstream, such as a message queue generating a new envelope ID on redelivery, has to be kept separate from the key or every redelivery looks new again.
A minimal shape in FastAPI
The pattern does not need a message broker or a distributed cache to be worth doing. A single table is enough to start:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INTEGER,
response_body JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
The endpoint checks the table before doing anything else:
@app.post("/loads")
def create_load(payload: LoadIntake, idempotency_key: str = Header(...)):
request_hash = hash_payload(payload)
existing = db.get_idempotency_record(idempotency_key)
if existing:
if existing.request_hash != request_hash:
raise HTTPException(409, "Idempotency key reused with a different payload")
return JSONResponse(existing.response_body, status_code=existing.response_status)
result = create_load_record(payload)
db.save_idempotency_record(idempotency_key, request_hash, 201, result)
return JSONResponse(result, status_code=201)
Two details do most of the work here. First, the stored hash of the original payload, checked against the retry's payload — without it, a client could accidentally reuse a key for a different request and silently get back the wrong stored response. Second, the record is written in the same transaction as the resource it protects, so a crash between "create the load" and "record the key" cannot leave the system in a state where a retry both re-creates the load and fails to find the key.
Where this goes wrong in practice
The most common mistake is checking for the key after the write instead of around it. If the sequence is "insert the row, then insert the idempotency record," a crash in between means the row exists but the key lookup says it does not — so a retry creates a second row. Wrapping both inserts in one database transaction closes that gap for a single-database system; a system split across services needs an explicit compensating step or a two-phase approach instead.
The second common mistake is scoping the key too broadly. A key that is unique per customer but not per operation means two different, legitimate requests from the same customer can collide if they happen to reuse an identifier. Scoping the key to a specific operation type, or requiring the client to generate a fresh UUID per logical write, avoids this without adding real complexity.
The third is treating every retry as identical to the first attempt. A payment endpoint retried five minutes after a slow success should return the original result, not attempt a second charge — but it also should not silently accept a completely different payload under the same key. Returning a 409 on a hash mismatch, rather than either processing it again or ignoring the mismatch, keeps the failure visible instead of papering over a client bug.
Expiring old keys
Idempotency records cannot be kept forever. A reasonable default is to expire them after the longest plausible client retry window — for a queue-backed intake system, that is usually measured in hours, not days, since a message that has not been retried within a few hours has likely failed for a reason a key will not fix. A background job or a database TTL can delete expired rows; what matters is that the expiration window is documented next to the endpoint, since a client relying on indefinite deduplication will eventually be surprised.
Testing the behavior, not just the happy path
The behavior worth testing directly is the three-way branch: no existing key, existing key with a matching payload, and existing key with a different payload. A fourth case — two requests with the same new key arriving concurrently — is worth a deliberate test if the database's unique constraint on the key column is what is supposed to prevent the race, rather than an application-level check that can itself be raced.
def test_retry_with_same_key_returns_original_response(client, db):
payload = {"origin": "San Jose, CA", "destination": "Reno, NV"}
key = "test-key-1"
first = client.post("/loads", json=payload, headers={"Idempotency-Key": key})
second = client.post("/loads", json=payload, headers={"Idempotency-Key": key})
assert first.json() == second.json()
assert db.count_load_records() == 1
That last assertion is the one that matters. A test that only checks the HTTP response can pass even if the endpoint quietly created two rows and returned the same JSON by coincidence. Asserting on the underlying state, not just the response, is what actually proves the write was not duplicated.
A short checklist
- Is the idempotency key generated by the caller, not derived from something the server controls?
- Is the payload hash checked, so a reused key with a different body fails loudly instead of silently?
- Are the resource write and the idempotency record write in the same transaction, or otherwise made atomic?
- Is there a documented, bounded expiration window for stored keys?
- Does at least one test assert on the underlying row count, not only the HTTP response?
This is the same discipline as the retry and timeout handling covered in my guide to third-party API failure modes — except applied to the side of the boundary that receives the retry rather than the side that sends it. The two guides meet in the middle: a client that retries safely and a server that accepts retries safely are the same problem, seen from opposite directions.