Idempotency: a webhook that retries
Run a real systemNeeds Python
Problem
A client that times out does not know whether its request was processed. If it retries, a slow but successful request is processed twice. An idempotency key, chosen by the client once per logical request and sent on every retry, lets the server recognise a repeat and return the first result instead of acting again. The server now has to store the keys, and decide for how long.
Write a tiny HTTP server that "charges" a customer and responds slowly (3 seconds) on every third request. Write a client with a 1-second timeout that retries up to 3 times. Count charges. Then add an Idempotency-Key header and a dictionary on the server that remembers keys, and count again.
The question. How many charges do 60 requests produce, without and with an idempotency key?
Predict
Without the key, some requests are charged more than once. With the key, the number of charges equals the number of distinct requests. Note the new cost: the server must now store keys, and decide how long to keep them.
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
idempotency-a-webhook-that-retries.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 """Idempotency: a webhook that retries. A server that charges a customer and answers slowly (3 s) on every third request; a client with a 1 s timeout that retries up to 3 times. Charges without and with an Idempotency-Key, and the keys the server must keep. """ import argparse, json, logging, threading, time, uuid import requests from flask import Flask, request from werkzeug.serving import make_server p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--requests", type=int, default=60, help="Distinct charges.") p.add_argument("--slow-every", type=int, default=3, help="Every n-th request is slow.") p.add_argument("--port", type=int, default=8142, help="Port.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() logging.getLogger("werkzeug").setLevel(logging.ERROR) app = Flask(__name__); st = {"charges": 0, "n": 0, "keys": {}}; lock = threading.Lock() @app.post("/charge") def charge(): key = request.headers.get("Idempotency-Key") with lock: st["n"] += 1; slow = st["n"] % args.slow_every == 0 if key and key in st["keys"]: return st["keys"][key] st["charges"] += 1 if key: st["keys"][key] = "charged" if slow: time.sleep(3) return "charged" 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" rows = [] for use_key in (False, True): with lock: st["charges"], st["n"] = 0, 0; st["keys"].clear() s = requests.Session() for i in range(args.requests): key = str(uuid.uuid4()) for attempt in range(4): try: s.post(url, headers={"Idempotency-Key": key} if use_key else {}, timeout=1); break except requests.exceptions.Timeout: pass time.sleep(3.5) with lock: rows.append({"client": "with Idempotency-Key" if use_key else "no key", "requests": args.requests, "charges": st["charges"], "keys_stored": len(st["keys"])}) print(f"{rows[-1]['client']:<22} {args.requests} requests -> {rows[-1]['charges']} charges keys stored {rows[-1]['keys_stored']}") srv.shutdown() if args.json: with open(args.json, "w") as f: json.dump({"tables": {"clients": 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 idempotency-a-webhook-that-retries.py uv run idempotency-a-webhook-that-retries.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --requests60 | Distinct charges. |
| --slow-every3 | Every n-th request is slow. |
| --port8142 | Port. |
Our run
60 requests
| Client | Requests | Charges | Keys stored |
|---|---|---|---|
| no key | 60 | 89 | 0 |
| with Idempotency-Key | 60 | 60 | 60 |
no key 60 requests -> 89 charges keys stored 0
with Idempotency-Key 60 requests -> 60 charges keys stored 60What we found
Without a key, 60 requests produced 89 charges: every slow request was charged again by its retries. With the key, 60 charges, and the server held 60 keys.
Read it
Retries are only safe if the receiver can tell a retry from a new request, and only the sender knows which is which: hence a key minted by the client. The stored keys are a dedup window; a retry that arrives after its key has expired is a new charge again.