Duplicate Orders Are a Spec Bug, Not a Model Bug
You should freeze idempotency rules before a coding agent writes POST /orders, because retries will otherwise insert duplicate rows. Most agents invent a cache, a unique index, or a loose header, then mix those three mod
You should freeze idempotency rules before a coding agent writes POST /orders, because retries will otherwise insert duplicate rows. Most agents invent a cache, a unique index, or a loose header, then mix those three models under concurrent retries. This case study walks a tiny checkout service from a frozen contract through tests and a small reference handler. You can run the same loop on a laptop, or on a disposable server if you want isolation from your laptop.
Background
Checkout clients retry after timeouts, after network flaps, and after load balancers reset idle connections during a create call. If POST /orders inserts a new row on every attempt, you charge twice for a single cart. Coding agents often store the last response in a dictionary keyed only by Idempotency-Key and call the job done. That design collapses when two carts share a key, when the process restarts, and when two workers race.
You do not need a new model release to prevent that class of bug in a small service. You need a written contract, a stable fingerprint, and tests that fail the pull request when the handler drifts. The rest of this article treats that contract as the product and treats generated Python as disposable implementation.
Goal
You will specify one create-order endpoint for a single-shop demo rather than a multi-tenant marketplace platform. The handler must accept Idempotency-Key, persist the first successful response, and replay that payload for matching retries. Conflicting bodies under the same key must fail closed with 409, and missing keys must fail closed with 400. Silent uniqueness without a client key is how duplicate SKUs sneak into later import jobs.
The walkthrough stays small on purpose, with no payment processor, inventory reservation, or routing table. You are proving that an agent cannot invent a second idempotency model after the tests already exist. If the generated code cannot satisfy the table, you throw the file away and generate again against the same tests.
The frozen contract
Paste this file into the repo as contracts/idempotency_v1.md and refuse to merge handler code that disagrees with it. Agents optimize for looking complete, and they will not keep your replay semantics unless you freeze those semantics first. The contract below is the whole product surface for this demo shop, so treat amendments as API breaks.
Endpoint rules
- Method and path:
POST /v1/ordersonly. - Required header:
Idempotency-Keymatching^[A-Za-z0-9._-]{8,128}$. - Required header:
Authorization: Bearer <merchant_token>so keys are not global. - Required body fields:
sku(non-empty string),qty(integer 1..99),currency(USDonly in this demo). - Canonical fingerprint: SHA-256 of
METHOD\nPATH\nKEY\nMERCHANT_ID\nplus canonical JSON bytes. - Canonical JSON: UTF-8 object, keys sorted, no extra whitespace, integers left unquoted.
Stored record
Each accepted key stores merchant_id, key, fingerprint, status_code, response_body, and created_at for later replay. Replays must return the stored status and body bytes, not a newly rendered document with fresh timestamps. This demo caches only 201 responses, so a failed request leaves the key free for a corrected body. That choice is a product decision, and you should not let an agent cache 400s without a contract amendment.
Status mapping
- Missing or malformed
Idempotency-Key→400withcode=idempotency_key_required. - Unknown merchant token →
401withcode=unauthorized. - Same merchant, same key, same fingerprint, stored 201 → replay
201unchanged. - Same merchant, same key, different fingerprint →
409withcode=idempotency_key_reuse. - Same merchant, same key, in-flight lock → wait up to 5 seconds, then
409withcode=idempotency_in_progress. - New key and valid body → insert order, store record, return
201. - Other merchants may reuse the same key string, because uniqueness is
(merchant_id, key).
Decision table
| Incoming key | Fingerprint vs stored | Stored state | HTTP |
|---|---|---|---|
| absent | n/a | n/a | 400 |
| malformed | n/a | n/a | 400 |
| new | n/a | none | 201 |
| known | equal | completed 201 | 201 replay |
| known | different | completed 201 | 409 |
| known | equal | in progress | wait / 409 |
| known | n/a | other merchant | 201 (separate row) |
Do not let the agent add PUT semantics, GET replay, or auto-generated keys without a written amendment. Those extras change client duties and usually reintroduce the duplicates you were trying to prevent. If a prompt asks for convenience, you answer with a new row in the table, not with a silent branch.
Tests that refuse to merge
Save the following module as tests/test_idempotency_contract.py and keep it in the same pull request as any generated handler. The tests are a proposed runnable design for pytest, and you should execute them in your own environment before you trust the output. They encode the table, not a particular framework, so a failing assertion means the contract moved.
# tests/test_idempotency_contract.py
# Proposed pytest module: run it against your own app fixture.
import uuid
import pytest
@pytest.fixture
def auth_header(merchant_token):
return {"Authorization": f"Bearer {merchant_token}"}
def test_missing_key_is_400(client, auth_header):
body = {"sku": "SKU-1", "qty": 1, "currency": "USD"}
res = client.post("/v1/orders", json=body, headers=auth_header)
assert res.status_code == 400
assert res.json()["code"] == "idempotency_key_required"
def test_replay_returns_identical_201(client, auth_header):
key = str(uuid.uuid4())
headers = {**auth_header, "Idempotency-Key": key}
body = {"sku": "SKU-1", "qty": 2, "currency": "USD"}
first = client.post("/v1/orders", json=body, headers=headers)
second = client.post("/v1/orders", json=body, headers=headers)
assert first.status_code == 201
assert second.status_code == 201
assert first.json() == second.json()
assert first.json()["id"] == second.json()["id"]
def test_same_key_different_body_is_409(client, auth_header):
key = str(uuid.uuid4())
headers = {**auth_header, "Idempotency-Key": key}
a = {"sku": "SKU-1", "qty": 1, "currency": "USD"}
b = {"sku": "SKU-1", "qty": 2, "currency": "USD"}
first = client.post("/v1/orders", json=a, headers=headers)
conflict = client.post("/v1/orders", json=b, headers=headers)
assert first.status_code == 201
assert conflict.status_code == 409
assert conflict.json()["code"] == "idempotency_key_reuse"
def test_key_is_scoped_to_merchant(client, merchant_token, other_token):
key = str(uuid.uuid4())
body = {"sku": "SKU-1", "qty": 1, "currency": "USD"}
first = client.post(
"/v1/orders",
json=body,
headers={
"Authorization": f"Bearer {merchant_token}",
"Idempotency-Key": key,
},
)
second = client.post(
"/v1/orders",
json=body,
headers={
"Authorization": f"Bearer {other_token}",
"Idempotency-Key": key,
},
)
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["id"] != second.json()["id"]
Run the suite before the agent writes production code, and keep the suite red until the handler matches the table.
python -m venv .venv
source .venv/bin/activate
pip install pytest httpx fastapi
pytest tests/test_idempotency_contract.py -q
You should treat a green suite as the merge gate, not as a vibe check after the agent dumps a file. If a later prompt simplifies the unique index, these tests are the thing that still yells. A rename-only generation must pass the same four assertions, or you did not freeze anything.
Implementation notes for the handler
The FastAPI sketch below is an example, not a measured production service with real traffic or latency numbers. It shows the lock, the fingerprint, and the replay path the tests demand from the handler. Persistence uses SQLite so you can read the rows during review without standing up another datastore. Return JSONResponse with status 201, because FastAPI treats a bare dict as 200 and will fail the replay test.
# example_app/orders.py
# Example handler aligned to contracts/idempotency_v1.md
import hashlib
import json
import re
import sqlite3
import time
import uuid
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
DB = "orders_demo.sqlite3"
KEY_RE = re.compile(r"^[A-Za-z0-9._-]{8,128}$")
def canonical_json(obj: dict) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
def fingerprint(method: str, path: str, key: str, merchant: str, body: dict) -> str:
prefix = f"{method}\n{path}\n{key}\n{merchant}\n".encode("utf-8")
return hashlib.sha256(prefix + canonical_json(body)).hexdigest()
def merchant_from(authorization: str | None) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, {"code": "unauthorized"})
token = authorization.removeprefix("Bearer ").strip()
if not token:
raise HTTPException(401, {"code": "unauthorized"})
return token # demo only: token identity equals merchant_id
def key_ok(key: str) -> bool:
return bool(KEY_RE.fullmatch(key))
def validate_body(body: dict) -> None:
sku = body.get("sku")
qty = body.get("qty")
if not isinstance(sku, str) or not sku:
raise HTTPException(400, {"code": "invalid_body"})
if not isinstance(qty, int) or isinstance(qty, bool) or qty < 1 or qty > 99:
raise HTTPException(400, {"code": "invalid_body"})
if body.get("currency") != "USD":
raise HTTPException(400, {"code": "invalid_body"})
@app.on_event("startup")
def init_db() -> None:
con = sqlite3.connect(DB)
con.execute(
"""
CREATE TABLE IF NOT EXISTS idempotency (
merchant_id TEXT NOT NULL,
key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
status INTEGER,
body TEXT,
state TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (merchant_id, key)
)
"""
)
con.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL,
sku TEXT NOT NULL,
qty INTEGER NOT NULL,
currency TEXT NOT NULL
)
"""
)
con.commit()
con.close()
@app.post("/v1/orders")
async def create_order(
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
authorization: str | None = Header(default=None),
):
merchant = merchant_from(authorization)
if not idempotency_key or not key_ok(idempotency_key):
raise HTTPException(400, {"code": "idempotency_key_required"})
body = await request.json()
validate_body(body)
digest = fingerprint("POST", "/v1/orders", idempotency_key, merchant, body)
con = sqlite3.connect(DB, timeout=5, isolation_level="IMMEDIATE")
try:
row = con.execute(
"SELECT fingerprint, status, body, state FROM idempotency WHERE merchant_id=? AND key=?",
(merchant, idempotency_key),
).fetchone()
if row:
stored_fp, status, stored_body, state = row
if stored_fp != digest:
raise HTTPException(409, {"code": "idempotency_key_reuse"})
if state == "completed":
return JSONResponse(status_code=status, content=json.loads(stored_body))
raise HTTPException(409, {"code": "idempotency_in_progress"})
try:
con.execute(
"INSERT INTO idempotency(merchant_id, key, fingerprint, state, created_at) VALUES (?,?,?,?,?)",
(merchant, idempotency_key, digest, "in_progress", time.time()),
)
con.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, {"code": "idempotency_in_progress"})
order_id = str(uuid.uuid4())
payload = {
"id": order_id,
"sku": body["sku"],
"qty": body["qty"],
"currency": body["currency"],
}
con.execute(
"INSERT INTO orders(id, merchant_id, sku, qty, currency) VALUES (?,?,?,?,?)",
(order_id, merchant, body["sku"], body["qty"], body["currency"]),
)
con.execute(
"UPDATE idempotency SET status=?, body=?, state=? WHERE merchant_id=? AND key=?",
(201, json.dumps(payload), "completed", merchant, idempotency_key),
)
con.commit()
return JSONResponse(status_code=201, content=payload)
finally:
con.close()
Keep the helpers boring, and resist the extra cache layer the model will try to insert on the second pass. Agents love Redis, bloom filters, and eventual uniqueness, but your contract does not ask for those tools. If the generator adds a background sweeper for key TTL, you amend the markdown file first and then accept the code.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access and a free server option can host the agent and pytest runner on a machine you can throw away. You can point that server at this repository while you iterate on the handler. The contract, decision table, and tests remain the actual method even if you never use that sandbox.
Results from the walkthrough
This is a lab walkthrough, not a customer study, so there are no conversion rates or benchmark numbers to quote. When you generate a handler without the contract, you should expect four failure modes during the first review.
- In-memory maps that die on process restart and duplicate the order after deploy.
- Keys that are global across merchants, so two shops collide on
order-1. - Replays that re-execute side effects because only the key was stored, not the response.
- Same-key POST with a changed
qtythat silently updates the row instead of returning 409.
After the tests exist, those four paths fail the suite before you spend time reading the generated diff. That is the result that matters for this case: the agent can still write ugly code, but it cannot quietly change replay.
Limitations
Do not copy this exact contract onto every POST route in your service without reading the failure modes. Public POST /search endpoints should not require keys, and streaming uploads need byte-range rules this demo never states. SQLite IMMEDIATE transactions are a teaching lock, not a multi-region lease you can run across two continents. Five-second in-progress waits will hurt if order creation calls a slow payment network during the critical section.
You should not treat a cached 201 body as a substitute for a payment idempotency key at your processor. If a crash happens after the charge and before the row commit, you still need a processor key that matches yours. This article does not claim durability across disks, regions, or vendors, and you should not infer that claim.
Skip this approach when you already have an exactly-once outbox with a proven unique constraint and a client library. Also skip it when your orders are command messages on a broker that already has a documented dedupe window. Layering a second key model on top of that design usually creates split-brain replays that no dashboard will explain.
Lessons learned
Write the table first, because if you cannot fill the fingerprint column you are not ready for generated code. Keep the key namespace per merchant, store the response, and fail closed on reuse with a different body. Run the four tests on every generation attempt, including the attempt that only renames variables for style.
Your job is not to prompt until the handler looks elegant in a screenshot of the editor. Your job is to keep POST /orders boring under retry, process restart, and two merchants who both like the key checkout. Freeze that boredom in git, then let the agent fill in the functions against tests you already trust.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.