Skip to content
About
Main content

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 , chosen by the client once per logical request and sent on every , 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 an idempotency key, how many charges do 60 requests produce when every third one is slow?

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.

    idempotency-a-webhook-that-retries.py55 linesDownload
    View 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)
    
  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 idempotency-a-webhook-that-retries.py
    uv run idempotency-a-webhook-that-retries.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--requests60 Distinct charges.
--slow-every3 Every n-th request is slow.
--port8142 Port.

Our run

60 requests

60 requests
ClientRequestsChargesKeys stored
no key60890
with Idempotency-Key606060
Reference runApple M2 · 8 cores · 8 GB
Output · uv run idempotency-a-webhook-that-retries.py
no key                 60 requests -> 89 charges   keys stored 0
with Idempotency-Key   60 requests -> 60 charges   keys stored 60
macOS 26.6.2 · Python 3.13.3 · flask 3.1.3 · requests 2.34.2 · run 2026-10-20

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