Duplicates versus an idempotency key
Run a real systemNeeds Python
Problem
A client that loses a reply cannot tell whether its request was processed, so it retries, and if the request was processed, the retry is a duplicate. An idempotency key makes the retry safe, but only if it identifies the intent (this charge), not the attempt, and only if the server answers a repeat with the stored outcome of the first attempt. A server that merely refuses repeats protects the ledger and leaves the client unable to tell "done by me" from "failed".
Build a tiny "payment" HTTP service in Python with an in-memory ledger. Add a client that sends 1,000 charges and, with probability 0.2, drops the reply (the server still processes the request) and retries. First version: the client creates a new random key for every attempt. Second version: the key is the intent (result-<id>), the server stores key -> outcome and replays the stored outcome. Third version: the server only suppresses the write and returns an error on a repeat.
The question. How many ledger rows does each version write, and how many callers get an error?
Predict
Version 1 shows roughly 1,000 plus about 250 extra ledger rows (each drop causes one extra row; about 20% of 1,000 first attempts, plus the retries that also drop). Version 2 shows exactly 1,000 rows and the client always receives "ok". Version 3 shows 1,000 rows but about 200 callers get an error and cannot tell "done by me" from "done by someone else".
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
duplicates-versus-an-idempotency-key.py # /// script # requires-python = ">=3.10" # dependencies = ["flask", "requests"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Duplicates against an idempotency key. A payment service with an in-memory ledger; a client sends 1,000 charges and, with probability 0.2, the reply is lost (the server still processed the request) and the client retries. Version 1: a new random key per attempt. Version 2: the key is the intent, and the server replays the stored outcome. Version 3: the server suppresses the repeat but answers with an error. """ import argparse, json, logging, random, threading, time, uuid import requests from flask import Flask, request, jsonify from werkzeug.serving import make_server p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--charges", type=int, default=1000, help="Charges.") p.add_argument("--drop", type=float, default=0.2, help="Probability that a reply is lost.") p.add_argument("--port", type=int, default=8141, help="Port for the local server.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) logging.getLogger("werkzeug").setLevel(logging.ERROR) app = Flask(__name__) state = {"ledger": [], "seen": {}, "mode": 2} lock = threading.Lock() @app.post("/charge") def charge(): key = request.headers["Idempotency-Key"]; amount = request.json["amount"] with lock: if key in state["seen"]: if state["mode"] == 3: return jsonify({"status": "error", "reason": "duplicate"}), 409 return jsonify(state["seen"][key]) # replay the stored outcome state["ledger"].append(amount) state["seen"][key] = {"status": "ok", "n": len(state["ledger"])} out = state["seen"][key] if random.random() < args.drop: time.sleep(0.6) # the reply is lost: it arrives after the client gives up return jsonify(out) srv = make_server("127.0.0.1", args.port, app, threaded=True); threading.Thread(target=srv.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{args.port}/charge" def run(mode): with lock: state["ledger"].clear(); state["seen"].clear(); state["mode"] = 2 if mode == 1 else mode s, errors = requests.Session(), 0 for i in range(args.charges): intent = f"result-{i}" while True: key = str(uuid.uuid4()) if mode == 1 else intent try: r = s.post(url, json={"amount": 10}, headers={"Idempotency-Key": key}, timeout=0.3) if r.status_code != 200: errors += 1 break except requests.exceptions.Timeout: pass with lock: rows = len(state["ledger"]) return {"version": {1: "1: new key per attempt", 2: "2: key = intent, replay outcome", 3: "3: suppress, answer with an error"}[mode], "ledger_rows": rows, "extra_rows": rows - args.charges, "client_errors": errors} rows = [run(m) for m in (1, 2, 3)] for r in rows: print(f"{r['version']:<34} ledger rows {r['ledger_rows']:>5} ({r['extra_rows']:+d}) errors returned to callers {r['client_errors']}") srv.shutdown() if args.json: with open(args.json, "w") as f: json.dump({"tables": {"versions": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run duplicates-versus-an-idempotency-key.py uv run duplicates-versus-an-idempotency-key.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --charges1000 | Charges. |
| --drop0.2 | Probability that a reply is lost. |
| --port8141 | Port for the local server. |
| --seed0 | Random seed. |
Our run
1,000 charges, 20% of replies lost
| Version | Ledger rows | Extra rows | Errors to callers |
|---|---|---|---|
| 1: new key per attempt | 1.26K | 263 | 0 |
| 2: key = intent, replay outcome | 1K | 0 | 0 |
| 3: suppress, answer with an error | 1K | 0 | 209 |
1: new key per attempt ledger rows 1263 (+263) errors returned to callers 0
2: key = intent, replay outcome ledger rows 1000 (+0) errors returned to callers 0
3: suppress, answer with an error ledger rows 1000 (+0) errors returned to callers 209What we found
A new key per attempt wrote 1.26K rows, 263 of them duplicates. The intent key with a replayed outcome wrote exactly 1K and returned 0 errors. Suppression also wrote 1K rows, but 209 callers received an error for a charge that had succeeded. All three matched the expectation.
Read it
Idempotency has two halves: the key must name the intent, and the server must remember the answer, not just the fact. Without the first, retries are new requests; without the second, a retry is safe for the ledger and confusing for the caller, who will escalate, retry by hand with a new key, or report a failure that did not happen.