Skip to content
About
Main content

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 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 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

With a new idempotency key on every attempt and 20% of responses dropped, how many extra ledger rows do 1,000 charges write?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    duplicates-versus-an-idempotency-key.py72 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

1,000 charges, 20% of replies lost
VersionLedger rowsExtra rowsErrors to callers
1: new key per attempt1.26K2630
2: key = intent, replay outcome1K00
3: suppress, answer with an error1K0209
Reference runApple M2 · 8 cores · 8 GB
Output · uv run duplicates-versus-an-idempotency-key.py
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 209
macOS 26.6.2 · Python 3.13.3 · flask 3.1.3 · requests 2.34.2 · run 2026-10-20

What 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.