TRENDING
Wooden two-dial chess clock with brass-rimmed white faces showing different times
October 10, 2026
A CNCF Post on NIS2 and DORA Turns Compliance Into a Backlog and Leaves the Classification Call Unowned
A hand holding an egg against a bright light in a dark room, with the light shining through the shell to show what is inside
October 10, 2026
Anthropic Launches OSS Scanner to Email Open-Source Maintainers AI Bug Reports No Human Has Reviewed
Two orange safety relief valves on grey pressure vessels in an industrial plant
October 10, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
October 10, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
A lugworm lying on wet sand and mud at low tide
October 10, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
10 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A green classroom chalkboard wiped almost clean, with pale smears of chalk where earlier writing was erased
How to Prevent Lost Updates in a FastAPI API With ETag and If-Match
October 9, 2026
Back of an Exabyte Mammoth data cartridge, a tape cassette made for computer backups, shown on a white background
Ahsay Says Version 10.3.4 Fixes Two Exploited AhsayCBS Flaws, and Huntress Says It Does Not
October 9, 2026
An 1840 Mulready postal envelope with a red Leicester postmark dated 4 May 1840 and a handwritten address
How to Audit SPF, DKIM, and DMARC in Python to Stop Spoofed Email From Using Your Domain
October 9, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 233 Posts
News 236 Posts
Learning Hub 206 Posts
Home/Learning Hub/How to Prevent Lost Updates in a FastAPI API With ETag and If-Match
Learning Hub

How to Prevent Lost Updates in a FastAPI API With ETag and If-Match

Two editors read the same note and save in turn, and the second save silently erases the first. This FastAPI and SQLite tutorial fixes it with ETag, If-Match, 412 and 428, then tests the race, the...

October 9, 2026 39 Min Read
8

What you will build, and why lost updates matter

Two people open the same note. Alice adds a line about the queue depth, Bob adds a line about paging the on-call engineer, and both press save. The server accepts both requests with a friendly 200 OK, and Bob’s save quietly erases Alice’s line. Nothing logs an error and no test fails. Someone finds out weeks later, when the runbook step that Alice wrote is missing during an outage.

Table Of Content

  • What you will build, and why lost updates matter
  • Four terms to know before we start
  • Prerequisites
  • Step 1: Create the project and the storage layer
  • Create the environment
  • Write the storage layer
  • A helper that runs the API inside your script
  • Step 2: Build the unprotected API and lose an update
  • Step 3: Learn the HTTP pieces that close the gap
  • ETag, If-Match, 412 and 428
  • The label: ETag
  • The condition: If-Match
  • The refusals: 412 and 428
  • Strong and weak tags
  • Write the tag parser
  • Step 4: Add ETag and If-Match to the API
  • What the server checks, in order
  • Run the same two-editor scenario
  • See it on the wire with curl
  • Step 5: Find the hole in the first fix
  • Make the race happen on demand
  • How often does it happen without the hook?
  • Step 6: Close the hole with compare-and-swap
  • Step 7: Make clients recover from a 412
  • Eight editors, two APIs
  • When the response gets lost
  • Step 8: Conditional GET, and why Last-Modified is not enough
  • Why not just use Last-Modified?
  • Step 9: Protect DELETE and test the edge cases
  • The limit of a version counter
  • Step 10: Let a browser app use the API (CORS)
  • Two CORS rules that affect ETags
  • The response side
  • The request side
  • Test it in a real browser
  • Step 11: Lock it in with tests
  • Confirm that it all works end to end
  • Common mistakes to watch for
  • Where to go next

That failure has a name, the lost update problem, and any API that lets a client read a resource, change it, and write it back can have it. HTTP already contains the cure. RFC 9110, the HTTP semantics standard published in June 2022, says that conditional requests can be applied to state-changing methods such as PUT and DELETE to stop “one client accidentally overwriting the work of another client that has been acting in parallel”.

In this tutorial you will build a small notes API with FastAPI and SQLite and make it safe in the standard way: the server labels every version of a note with an ETag, the client sends that label back in an If-Match header when it saves, and the server refuses the save with 412 Precondition Failed if the label is stale. You will then break your first version with a real race, fix it with one SQL statement, teach a client to recover from a refusal, add cheaper reads with conditional GET, protect DELETE, make the whole thing usable from a browser, and lock it in with tests. Every script below was run, and every output was captured from a real run.

Four terms to know before we start

Lost update. Two clients read the same version of a resource, each writes back a change based on that version, and the second write overwrites the first without either client noticing.

Optimistic concurrency. Instead of locking a record while someone edits it, which would block everyone else for as long as a person takes to think, you let everyone read freely and check at write time that nothing changed since they read. You are optimistic that conflicts are rare, and you handle the rare ones when they happen.

Entity tag (ETag). A short label the server attaches to one version of a resource. A strong tag has to change whenever a change would be visible in the content of a GET response. Clients treat it as an opaque string and only ever hand it back.

Precondition. A condition carried by a request, such as If-Match: "1", which means only do this if the note is still version 1. The server must test it before doing the work.

Prerequisites

  • Python 3.10 or newer. The code uses the str | None type syntax. Everything here was run on Python 3.13.14 on Windows 11; macOS and Linux work the same apart from the command that activates the virtual environment.
  • SQLite 3.35 or newer. The storage code uses the RETURNING clause, and the SQLite documentation says “The RETURNING syntax has been supported by SQLite since version 3.35.0 (2021-03-12).” Step 1 prints the version your Python uses.
  • Basic comfort with Python functions and with what GET, PUT and DELETE mean. You do not need to know FastAPI; each piece is explained when it appears.
  • A terminal. curl is optional (Step 4 shows it), and so is a Chromium-based browser such as Chrome or Edge (Step 10 uses one).
  • About 45 minutes.

Step 1: Create the project and the storage layer

Create the environment

Make a folder and a virtual environment, then install five packages: FastAPI for the API, uvicorn to serve it, requests to call it, pytest for the tests, and httpx2, which FastAPI’s test client needs in this version. Keep every file from this tutorial in that one folder.

mkdir etag-lab
cd etag-lab
python -m venv .venv
.venv\Scripts\activate
python -m pip install fastapi==0.143.0 uvicorn==0.54.0 requests==2.34.2 pytest==9.1.1 httpx2==2.13.1

On macOS or Linux, activate with source .venv/bin/activate instead. Next, save this small script as step01_versions.py and run it with python step01_versions.py to see what you have.

# step01_versions.py
import sqlite3
import sys
from importlib.metadata import version

print("python", sys.version.split()[0])
print("sqlite", sqlite3.sqlite_version)
for name in ["fastapi", "uvicorn", "requests", "pytest", "httpx2"]:
    print(name, version(name))
python 3.13.14
sqlite 3.50.4
fastapi 0.143.0
uvicorn 0.54.0
requests 2.34.2
pytest 9.1.1
httpx2 2.13.1

Your numbers can be newer. The two that matter are Python 3.10 or later and SQLite 3.35 or later. If either is older, upgrade before you continue.

Write the storage layer

Save the next listing as store.py. It is the only file that touches the database. Notes live in one SQLite table with a version column that starts at 1 and goes up by one on every write. That counter is what we will turn into an ETag.

# store.py
import sqlite3
import time
from contextlib import closing

DB_PATH = "notes.db"


def connect():
    conn = sqlite3.connect(DB_PATH, timeout=5)
    conn.row_factory = sqlite3.Row
    return conn


def reset(body=""):
    """Drop and recreate the table with one seed note: id 1, version 1."""
    with closing(connect()) as conn, conn:
        conn.execute("DROP TABLE IF EXISTS notes")
        conn.execute(
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL, "
            "body TEXT NOT NULL, version INTEGER NOT NULL, updated_at REAL NOT NULL)"
        )
        conn.execute(
            "INSERT INTO notes (id, title, body, version, updated_at) VALUES (1, 'Runbook', ?, 1, ?)",
            (body, time.time()),
        )


def get_note(note_id):
    with closing(connect()) as conn:
        row = conn.execute("SELECT * FROM notes WHERE id = ?", (note_id,)).fetchone()
    return dict(row) if row else None


def overwrite_note(note_id, title, body):
    """Unconditional write: whoever writes last wins."""
    with closing(connect()) as conn, conn:
        rows = conn.execute(
            "UPDATE notes SET title = ?, body = ?, version = version + 1, updated_at = ? WHERE id = ? RETURNING *",
            (title, body, time.time(), note_id),
        ).fetchall()
    return dict(rows[0]) if rows else None

A few details are worth understanding before you move on.

connect() opens a new connection on every call. Python’s sqlite3 module raises ProgrammingError by default “if the database connection is used by a thread other than the one that created it”, and FastAPI runs ordinary def endpoints in worker threads (its documentation says “it is run in an external threadpool”), so short-lived per-call connections are the simplest safe pattern.

The odd-looking with closing(connect()) as conn, conn: does two jobs. closing closes the connection when the block ends, and the second conn makes the block one transaction that commits on success and rolls back on an exception. You need both because, as the Python documentation puts it, “The context manager neither implicitly opens a new transaction nor closes the connection.”

Finally, overwrite_note is the last writer wins write that we are about to blame for the lost update. It increments the version inside the same UPDATE and uses RETURNING * to hand back the row exactly as that statement wrote it, so the version we report is never one that a later writer produced.

A helper that runs the API inside your script

Every demo below starts the API and calls it from the same script. Save this as labserver.py. It runs uvicorn in a background thread, waits until the server reports that it has started, and shuts it down when the with block ends.

# labserver.py
import threading
import time

import uvicorn


class LiveServer:
    """Run a FastAPI app on 127.0.0.1 in a background thread for the demos."""

    def __init__(self, app, port):
        config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="critical")
        self.server = uvicorn.Server(config)
        self.thread = threading.Thread(target=self.server.run, daemon=True)
        self.url = f"http://127.0.0.1:{port}"

    def __enter__(self):
        self.thread.start()
        while not self.server.started:
            if not self.thread.is_alive():
                raise RuntimeError("the server did not start (is the port already in use?)")
            time.sleep(0.02)
        return self

    def __exit__(self, *exc):
        self.server.should_exit = True
        self.thread.join(timeout=5)

If the port is already taken, the helper raises an error instead of waiting forever. Each demo uses its own port number, so they never collide.

Step 2: Build the unprotected API and lose an update

Start with the API most tutorials would show: one GET that returns a note and one PUT that replaces it. Save this as app_v1.py.

# app_v1.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

import store

app = FastAPI()


class NoteIn(BaseModel):
    title: str
    body: str


@app.get("/notes/{note_id}")
def read_note(note_id: int):
    note = store.get_note(note_id)
    if note is None:
        raise HTTPException(404, "no such note")
    return note


@app.put("/notes/{note_id}")
def replace_note(note_id: int, incoming: NoteIn):
    note = store.overwrite_note(note_id, incoming.title, incoming.body)
    if note is None:
        raise HTTPException(404, "no such note")
    return note

FastAPI turns the NoteIn model into the JSON request body and validates it. A PUT means replace the whole resource with what I send, so the endpoint writes the title and body it receives. It never asks which version of the note the client was looking at, because the client never says.

Now let two people use it. In step02_lost_update.py, Alice and Bob each read the note, each add one sentence, and each save. Run it with python step02_lost_update.py.

# step02_lost_update.py
import requests

import store
from app_v1 import app
from labserver import LiveServer

store.reset(body="Restart order: db, api, web.")

with LiveServer(app, 8701) as srv:
    url = f"{srv.url}/notes/1"
    alice = requests.get(url).json()
    bob = requests.get(url).json()
    print("alice reads version", alice["version"], "| bob reads version", bob["version"])

    edit = {"title": "Runbook", "body": alice["body"] + " Check the queue depth."}
    r = requests.put(url, json=edit)
    print("alice saves ->", r.status_code, "| version", r.json()["version"])

    edit = {"title": "Runbook", "body": bob["body"] + " Page the on-call."}
    r = requests.put(url, json=edit)
    print("bob saves   ->", r.status_code, "| version", r.json()["version"])

    print("final body  ->", requests.get(url).json()["body"])
alice reads version 1 | bob reads version 1
alice saves -> 200 | version 2
bob saves   -> 200 | version 3
final body  -> Restart order: db, api, web. Page the on-call.

Read the output carefully. Both saves returned 200, and the version went from 1 to 3, so the database really did apply two writes. The final body contains only Bob’s sentence. Alice’s Check the queue depth. is gone. Nobody received an error, because there was none: every individual request was valid. The bug lives in the gap between each client’s read and its write. The server cannot see that gap, so it cannot tell that Bob’s edit was based on a note that had already moved on.

Step 3: Learn the HTTP pieces that close the gap

Four small pieces of HTTP, defined in RFC 9110 and in RFC 6585, let the server see that gap.

ETag, If-Match, 412 and 428

The label: ETag

The ETag response header is the label. RFC 9110 (section 8.8.3) says “An entity tag is an opaque validator for differentiating between multiple representations of the same resource” and that “An entity tag consists of an opaque quoted string, possibly prefixed by a weakness indicator.” The quotes are part of the value, so a tag for version 3 looks like "3", quotes included.

The condition: If-Match

The If-Match request header (section 13.1.1) is the precondition. The standard says it “is most often used with state-changing methods (e.g., POST, PUT, DELETE) to prevent accidental overwrites when multiple user agents might be acting in parallel on the same resource”. A client reads the note, remembers its tag, and sends that tag in If-Match with its PUT.

The refusals: 412 and 428

If the tag no longer matches, the server must not do the work: “An origin server that evaluates an If-Match condition MUST NOT perform the requested method if the condition evaluates to false.” It answers 412 Precondition Failed, which section 15.5.13 describes as meaning that “one or more conditions given in the request header fields evaluated to false when tested on the server”.

The last status code is 428 Precondition Required from RFC 6585 (section 3): “The 428 status code indicates that the origin server requires the request to be conditional.” A server uses it to refuse a PUT that arrives with no If-Match at all. The same section says its typical use is the lost-update situation “where a client GETs a resource’s state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict”. The security considerations in section 7.1 of that RFC add that “The 428 status code is optional”, so a client cannot assume that every server enforces it. This API will.

Here is the whole conversation we are about to implement, with Alice saving first and Bob arriving second:

Alice                      Server                       Bob
  GET /notes/1 ---------->
  <---------- 200, ETag "1"
                            <----------- GET /notes/1
                            200, ETag "1" ---------->
  PUT, If-Match "1" ----->
  <---------- 200, ETag "2"
                            <----- PUT, If-Match "1"
                            412 Precondition Failed ->

Strong and weak tags

A tag can carry a W/ prefix, as in W/"1", which marks it weak. RFC 9110 explains that a weak validator “might not change for every change to the representation data”, so it is good enough for validating a cached copy but not for proving that nothing changed. Tags without the prefix are strong. The distinction matters because the standard defines two ways to compare tags. In a strong comparison “two entity tags are equivalent if both are not weak and their opaque-tags match character-by-character”, and in a weak comparison “two entity tags are equivalent if their opaque-tags match character-by-character”, whatever the W/ markers say.

The rule that matters for this tutorial is this one: “An origin server MUST use the strong comparison function when comparing entity tags for If-Match”, the standard explains, “since the client intends this precondition to prevent the method from being applied if there have been any changes to the representation data.” So a weak tag can never satisfy If-Match. You will see that happen in Step 9.

Write the tag parser

An If-Match value is not always one tag. The grammar allows a list such as "1", "2" or the single character *. Splitting on commas looks like the obvious way to read a list, and it is wrong, because the grammar allows a comma inside a tag. Save the first half of etags.py now: a regular expression for one tag and a parser built on it.

# etags.py
import re

# RFC 9110, section 8.8.3: entity-tag = [ "W/" ] DQUOTE *etagc DQUOTE
# etagc is any visible ASCII character except the double quote (so a comma is allowed), or obs-text (0x80-0xFF).
_ITEM = re.compile(r'[ \t]*(W/)?"([!#-~\x80-\xff]*)"[ \t]*(?:,|$)')


def tag_of(version):
    """The (weak, opaque) pair for a note version. This API only ever makes strong tags."""
    return (False, str(version))


def make_etag(version):
    weak, opaque = tag_of(version)
    return ("W/" if weak else "") + f'"{opaque}"'


def parse_tag_list(header):
    """Turn an If-Match or If-None-Match value into "*" or a list of (weak, opaque) pairs."""
    text = header.strip()
    if text == "*":
        return "*"
    tags, pos = [], 0
    while pos < len(text):
        item = _ITEM.match(text, pos)
        if item is None:
            raise ValueError(f"expected a quoted entity tag at offset {pos}")
        tags.append((item.group(1) is not None, item.group(2)))
        pos = item.end()
    if not tags:
        raise ValueError("expected at least one entity tag")
    return tags

The pattern [!#-~\x80-\xff]* spells out the standard’s rule that a tag may contain any visible ASCII character except the double quote, plus the bytes 0x80 to 0xFF. The parser returns either the string * or a list of (is_weak, opaque) pairs, and raises ValueError for anything else, which the API will turn into a 400. The functions tag_of and make_etag turn a note version into a strong tag: version 3 becomes the pair (False, "3") and the header text "3".

Now add the two comparison rules and the two header checks. Append this to etags.py.

# etags.py (continued)
def strong_equal(a, b):
    """Strong comparison: neither tag is weak and the opaque parts are identical."""
    return not a[0] and not b[0] and a[1] == b[1]


def weak_equal(a, b):
    """Weak comparison: the opaque parts are identical, whatever the W/ prefixes say."""
    return a[1] == b[1]


def if_match_ok(header, current):
    tags = parse_tag_list(header)
    return tags == "*" or any(strong_equal(tag, current) for tag in tags)


def if_none_match_hit(header, current):
    tags = parse_tag_list(header)
    return tags == "*" or any(weak_equal(tag, current) for tag in tags)

if_match_ok is what a PUT or DELETE will call. It passes for *, or when any listed tag is strongly equal to the note’s current tag. if_none_match_hit is what a conditional GET will call in Step 8, and it uses the weak rule, because the standard says “A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match”.

Check the code against the standard rather than trusting it. Save step03_comparison.py. It reproduces the four-row comparison table from RFC 9110 section 8.8.3.2, shows what a naive comma split does to a tag that contains a comma, and feeds the parser three malformed values. Run it with python step03_comparison.py.

# step03_comparison.py
from etags import parse_tag_list, strong_equal, weak_equal

PAIRS = [('W/"1"', 'W/"1"'), ('W/"1"', 'W/"2"'), ('W/"1"', '"1"'), ('"1"', '"1"')]


def verdict(flag):
    return "match" if flag else "no match"


print(f"{'ETag 1':8}{'ETag 2':8}{'strong':10}weak")
for left, right in PAIRS:
    a, b = parse_tag_list(left)[0], parse_tag_list(right)[0]
    print(f"{left:8}{right:8}{verdict(strong_equal(a, b)):10}{verdict(weak_equal(a, b))}")

header = '"a,b", W/"c"'
print()
print("naive split on commas:", header.split(","))
print("parse_tag_list       :", parse_tag_list(header))
for bad in ["4", '"4" "5"', ""]:
    try:
        parse_tag_list(bad)
    except ValueError as exc:
        print(f"{bad!r:9} -> ValueError: {exc}")
ETag 1  ETag 2  strong    weak
W/"1"   W/"1"   no match  match
W/"1"   W/"2"   no match  no match
W/"1"   "1"     no match  match
"1"     "1"     match     match

naive split on commas: ['"a', 'b"', ' W/"c"']
parse_tag_list       : [(False, 'a,b'), (True, 'c')]
'4'       -> ValueError: expected a quoted entity tag at offset 0
'"4" "5"' -> ValueError: expected a quoted entity tag at offset 0
''        -> ValueError: expected at least one entity tag

These are the same four rows, with the same verdicts, as the table in the RFC. Only the last pair matches under the strong rule. The first and third pairs also match under the weak rule, because their opaque parts are equal, and the second pair matches under neither because 1 and 2 differ. A comma inside a tag breaks the naive split into three pieces, while the parser keeps a,b together. And malformed values fail loudly instead of being guessed at.

Step 4: Add ETag and If-Match to the API

Copy app_v1.py to app_v2.py and rewrite it as the two listings below. The first holds the shared pieces, including the function that decides whether a request may proceed.

# app_v2.py
from fastapi import FastAPI, Header, HTTPException, Response
from pydantic import BaseModel

import etags
import store

app = FastAPI()


class NoteIn(BaseModel):
    title: str
    body: str


def between_check_and_write():
    """Does nothing. Step 4 swaps in a function that holds every request right here."""


def require_current(note_id, if_match):
    """Return the note if If-Match names its current version, otherwise raise the matching HTTP error."""
    if if_match is None:
        raise HTTPException(428, "send If-Match with the ETag you last read")
    note = store.get_note(note_id)
    if note is None:
        raise HTTPException(404, "no such note")
    try:
        matches = etags.if_match_ok(if_match, etags.tag_of(note["version"]))
    except ValueError as exc:
        raise HTTPException(400, f"bad If-Match header: {exc}")
    if not matches:
        raise HTTPException(412, "the note changed since you read it: GET it again and reapply your edit")
    return note

What the server checks, in order

require_current runs four checks, cheapest and most informative first. No If-Match header at all gets 428. A missing note gets 404, because there is nothing to compare against and a 404 tells the client more than a 412 would. A header that is not a valid tag list gets 400. A valid header that does not match the note’s current tag gets 412. Only a request that passes all four gets the note back.

Two small things deserve a mention. The parameter if_match reads the If-Match header, because FastAPI’s header parameter documentation explains that Header converts underscores in the parameter name to hyphens. And between_check_and_write is an empty function. It does nothing now. Step 5 uses it to expose a flaw on purpose.

The second listing holds the two routes.

# app_v2.py (continued)
@app.get("/notes/{note_id}")
def read_note(note_id: int, response: Response):
    note = store.get_note(note_id)
    if note is None:
        raise HTTPException(404, "no such note")
    response.headers["ETag"] = etags.make_etag(note["version"])
    return note


@app.put("/notes/{note_id}")
def replace_note(note_id: int, incoming: NoteIn, response: Response, if_match: str | None = Header(default=None)):
    require_current(note_id, if_match)
    between_check_and_write()
    updated = store.overwrite_note(note_id, incoming.title, incoming.body)
    response.headers["ETag"] = etags.make_etag(updated["version"])
    return updated

read_note now returns the tag in an ETag header, and replace_note refuses to write unless require_current says the caller saw the latest version. A successful write returns the new tag, so a client that saves again right away does not need to GET first.

Run the same two-editor scenario

Save step04_conditional_put.py. It repeats Step 2, except that each client now sends the tag it read, and it adds one extra request with no header. Run it with python step04_conditional_put.py.

# step04_conditional_put.py
import requests

import store
from app_v2 import app
from labserver import LiveServer

store.reset(body="Restart order: db, api, web.")

with LiveServer(app, 8703) as srv:
    url = f"{srv.url}/notes/1"
    alice = requests.get(url)
    bob = requests.get(url)
    print("alice reads ETag", alice.headers["ETag"], "| bob reads ETag", bob.headers["ETag"])

    edit = {"title": "Runbook", "body": alice.json()["body"] + " Check the queue depth."}
    r = requests.put(url, json=edit, headers={"If-Match": alice.headers["ETag"]})
    print("alice saves ->", r.status_code, "| new ETag", r.headers["ETag"])

    edit = {"title": "Runbook", "body": bob.json()["body"] + " Page the on-call."}
    r = requests.put(url, json=edit, headers={"If-Match": bob.headers["ETag"]})
    print("bob saves   ->", r.status_code, "|", r.json()["detail"])

    r = requests.put(url, json=edit)
    print("no header   ->", r.status_code, "|", r.json()["detail"])

    print("final body  ->", requests.get(url).json()["body"])
alice reads ETag "1" | bob reads ETag "1"
alice saves -> 200 | new ETag "2"
bob saves   -> 412 | the note changed since you read it: GET it again and reapply your edit
no header   -> 428 | send If-Match with the ETag you last read
final body  -> Restart order: db, api, web. Check the queue depth.

This time Alice’s save succeeds and moves the note to tag "2". Bob still holds tag "1", so his save is refused with a 412 and a message that tells his client what to do, and the final body keeps Alice’s sentence. The third request, with no header at all, is refused with 428. Nothing was lost.

See it on the wire with curl

If you prefer to watch raw HTTP, step04b_curl.py runs curl against the same API and prints only the status line, the ETag and the body. The lines that start with a dollar sign are labels for you to read. The script passes the arguments to curl directly, without a shell, so you do not need to worry about quoting. Run it with python step04b_curl.py.

# step04b_curl.py
import subprocess

import store
from app_v2 import app
from labserver import LiveServer

store.reset(body="Restart order: db, api, web.")
JSON = ["-H", "Content-Type: application/json", "-d", '{"title":"Runbook","body":"Restart order: db, api, web. Check the queue."}']


def curl(label, *args):
    out = subprocess.run(["curl", "-si", *args], capture_output=True, text=True, encoding="utf-8").stdout
    keep = [ln.rstrip() for ln in out.splitlines() if ln.lower().startswith(("http/", "etag:")) or ln.startswith("{")]
    print(f"$ curl {label}")
    print("\n".join(keep))
    print()


with LiveServer(app, 8710) as srv:
    url = f"{srv.url}/notes/1"
    curl("-i " + "http://127.0.0.1:8710/notes/1", url)
    curl("-i -X PUT -d '{...}' http://127.0.0.1:8710/notes/1", "-X", "PUT", *JSON, url)
    curl("-i -X PUT -H 'If-Match: \"1\"' -d '{...}' http://127.0.0.1:8710/notes/1", "-X", "PUT", "-H", 'If-Match: "1"', *JSON, url)
    curl("-i -X PUT -H 'If-Match: \"1\"' -d '{...}' http://127.0.0.1:8710/notes/1", "-X", "PUT", "-H", 'If-Match: "1"', *JSON, url)
$ curl -i http://127.0.0.1:8710/notes/1
HTTP/1.1 200 OK
etag: "1"
{"id":1,"title":"Runbook","body":"Restart order: db, api, web.","version":1,"updated_at":1791578752.7954705}

$ curl -i -X PUT -d '{...}' http://127.0.0.1:8710/notes/1
HTTP/1.1 428 Precondition Required
{"detail":"send If-Match with the ETag you last read"}

$ curl -i -X PUT -H 'If-Match: "1"' -d '{...}' http://127.0.0.1:8710/notes/1
HTTP/1.1 200 OK
etag: "2"
{"id":1,"title":"Runbook","body":"Restart order: db, api, web. Check the queue.","version":2,"updated_at":1791578752.940765}

$ curl -i -X PUT -H 'If-Match: "1"' -d '{...}' http://127.0.0.1:8710/notes/1
HTTP/1.1 412 Precondition Failed
{"detail":"the note changed since you read it: GET it again and reapply your edit"}

Every rule is visible in one place. The GET carries etag: "1". The PUT with no header gets 428 Precondition Required. The PUT with If-Match: "1" succeeds and returns etag: "2". The same PUT repeated gets 412 Precondition Failed. This server sends the header name in lowercase, which is fine, because RFC 9110 says “Field names are case-insensitive”.

Step 5: Find the hole in the first fix

The API now refuses stale writes, so it is tempting to stop. Don’t, because replace_note has a flaw that a one-editor-at-a-time test can never reveal. Look at the order of operations: it reads the note and compares the tag (require_current), and only then writes (overwrite_note). Those are two separate database operations, and another request can write in between. Both requests pass the check against the same old version, and then both write. The classic name for this is a time-of-check to time-of-use race.

You might expect the database to save you. SQLite does allow only one writer at a time (its transaction documentation says it supports multiple simultaneous readers “but only one simultaneous write transaction”), but that only puts the two writes in a line. Each one is still a valid transaction, and the second one overwrites the first.

Make the race happen on demand

In real traffic this window is short, so it would be hard to demonstrate on demand. Here is the trick. between_check_and_write sits exactly in the gap, so a test can replace it with a function that makes every request wait there until all eight have passed their check. This widens the gap deliberately so that the interleaving happens every time. Save step05_race.py, which sends eight simultaneous saves that are all based on version 1, and run it with python step05_race.py app_v2.

# step05_race.py
import importlib
import sys
import threading

import requests

import store
from labserver import LiveServer

module = importlib.import_module(sys.argv[1])  # app_v2 or app_v3
EDITORS = 8

store.reset(body="start")
# Hold every request in the gap between the version check and the write until all 8 have passed the check.
module.between_check_and_write = threading.Barrier(EDITORS, timeout=20).wait
statuses = []


def editor(n, base):
    url = f"{base}/notes/1"
    etag = requests.get(url).headers["ETag"]
    edit = {"title": "Runbook", "body": f"edit from editor {n}"}
    reply = requests.put(url, json=edit, headers={"If-Match": etag}, timeout=30)
    statuses.append(reply.status_code)


with LiveServer(module.app, 8704) as srv:
    threads = [threading.Thread(target=editor, args=(n, srv.url)) for n in range(EDITORS)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    final = requests.get(f"{srv.url}/notes/1").json()

print(f"{sys.argv[1]}: {statuses.count(200)} saved, {statuses.count(412)} refused (all {EDITORS} editors had read version 1)")
print(f"final version {final['version']}")
app_v2: 8 saved, 0 refused (all 8 editors had read version 1)
final version 9

All eight saves succeeded, although every one of those editors had read version 1, and the version counter ended at 9, so eight separate writes landed on top of each other. Only the last one survives, and which one it is depends on timing. The first fix is not a fix under concurrency.

How often does it happen without the hook?

The barrier makes the problem certain, but it would be fair to ask whether the race can happen by itself. step05b_natural_race.py leaves the hook as the do-nothing function, makes eight editors read the note and then save at the same moment, and repeats that for 200 rounds, counting the rounds in which more than one save succeeded. Run it with python step05b_natural_race.py app_v2.

# step05b_natural_race.py
import importlib
import sys
import threading

import requests

import store
from labserver import LiveServer

module = importlib.import_module(sys.argv[1])  # app_v2 or app_v3, with the do-nothing between_check_and_write
ROUNDS, EDITORS = 200, 8
double_saves = 0  # rounds in which more than one editor got a 200

with LiveServer(module.app, 8705) as srv:
    url = f"{srv.url}/notes/1"
    for _ in range(ROUNDS):
        store.reset(body="start")
        gate = threading.Barrier(EDITORS, timeout=20)
        statuses = []

        def editor(n):
            etag = requests.get(url).headers["ETag"]
            gate.wait()  # every editor has read version 1 before anyone writes
            reply = requests.put(url, json={"title": "Runbook", "body": f"edit {n}"}, headers={"If-Match": etag})
            statuses.append(reply.status_code)

        threads = [threading.Thread(target=editor, args=(n,)) for n in range(EDITORS)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        if statuses.count(200) > 1:
            double_saves += 1

print(f"{sys.argv[1]}: more than one save succeeded in {double_saves} of {ROUNDS} rounds")
app_v2: more than one save succeeded in 140 of 200 rounds

In three runs on my machine, more than one save succeeded in 140, 138 and 146 of 200 rounds. Your numbers will differ with your hardware and load. These eight editors are deliberately synchronized, which is the worst case, so real traffic overlaps less often, but the point stands: a window exists whenever two requests for the same note are in flight at once.

Step 6: Close the hole with compare-and-swap

The cure is to make the check and the write one indivisible step. SQL gives you that: put the version in the WHERE clause of the UPDATE. The database then changes the row only if its version is still the one the client saw, and it reports whether it did. This is called compare-and-swap. Add this function to the end of store.py.

# store.py (continued)
def update_if_version(note_id, expected_version, title, body):
    """Compare-and-swap: the version check and the write are one atomic SQL statement."""
    with closing(connect()) as conn, conn:
        rows = conn.execute(
            "UPDATE notes SET title = ?, body = ?, version = version + 1, updated_at = ? "
            "WHERE id = ? AND version = ? RETURNING *",
            (title, body, time.time(), note_id, expected_version),
        ).fetchall()
    return dict(rows[0]) if rows else None

If another writer got there first, the WHERE clause matches no row, RETURNING produces no row, and the function returns None. If we won, it returns the row exactly as written, with the new version. Nothing can slip in between the comparison and the swap, because they are the same statement.

Now make replace_note use it. Copy app_v2.py to app_v3.py, change the comment on its first line to match, and replace the replace_note function with this one. Everything else stays the same.

# app_v3.py (copy app_v2.py, replace replace_note)
@app.put("/notes/{note_id}")
def replace_note(note_id: int, incoming: NoteIn, response: Response, if_match: str | None = Header(default=None)):
    note = require_current(note_id, if_match)
    between_check_and_write()
    updated = store.update_if_version(note_id, note["version"], incoming.title, incoming.body)
    if updated is None:
        raise HTTPException(412, "the note changed while your request was running: GET it again and reapply your edit")
    response.headers["ETag"] = etags.make_etag(updated["version"])
    return updated

We still call require_current first. It is cheap, and it gives each failure its own status code and message. The compare-and-swap then closes the race: it uses the version that require_current just checked, and if the write loses, the function answers 412 with a message saying the note changed while the request was running.

Run the same eight-editor test against the new version with python step05_race.py app_v3.

app_v3: 1 saved, 7 refused (all 8 editors had read version 1)
final version 2

Exactly one editor saved, seven were refused, and the version moved from 1 to 2, which is one write. Now run the unsynchronized 200-round test against the new version with python step05b_natural_race.py app_v3.

app_v3: more than one save succeeded in 0 of 200 rounds

There was no round in which more than one save succeeded. The same script that counted 140 double saves a moment ago now counts none.

Step 7: Make clients recover from a 412

A 412 is not an error to hide from users. It is information: the note changed, so the client’s edit was based on something out of date. The right reaction is to read the fresh note, apply the user’s intent to it, and try again. The wrong reaction is to resend the old body, which would overwrite the other person’s change and bring the lost update straight back.

Save this as client.py. Its edit parameter is a function that takes the current title and body and returns the new ones, so each retry re-applies the intent (for example, append a line) to whatever the note looks like now.

# client.py
import requests


class GaveUp(Exception):
    pass


def update_note(base_url, note_id, edit, attempts=10, before_put=None):
    """Read, edit, write with If-Match. On 412, read the fresh copy and apply the edit to it again."""
    url = f"{base_url}/notes/{note_id}"
    for attempt in range(1, attempts + 1):
        got = requests.get(url, timeout=20)
        got.raise_for_status()
        note = got.json()
        title, body = edit(note["title"], note["body"])
        if before_put is not None:
            before_put(attempt)
        sent = requests.put(url, json={"title": title, "body": body}, headers={"If-Match": got.headers["ETag"]}, timeout=20)
        if sent.status_code == 412:
            continue
        sent.raise_for_status()
        return sent.json(), attempt
    raise GaveUp(f"still conflicting after {attempts} attempts")

The loop is bounded by attempts. The before_put hook does nothing in normal use. The next demo uses it to line editors up so that they collide.

Eight editors, two APIs

step07_retry.py lets eight editors each append one line to the same note. It runs first against the unprotected API from Step 2 with plain PUTs, then against the protected API with this client. Run it with python step07_retry.py.

# step07_retry.py
import threading

import requests

import app_v1
import app_v3
import client
import store
from labserver import LiveServer

EDITORS = 8


def naive(base, n, gate):
    note = requests.get(f"{base}/notes/1").json()
    gate.wait()
    requests.put(f"{base}/notes/1", json={"title": note["title"], "body": note["body"] + f" line {n}."})
    return 1


def careful(base, n, gate):
    _, attempt = client.update_note(
        base, 1, lambda title, body: (title, body + f" line {n}."),
        before_put=lambda attempt: gate.wait() if attempt == 1 else None,
    )
    return attempt


def run(label, server_app, port, worker):
    store.reset(body="")
    gate = threading.Barrier(EDITORS, timeout=20)
    attempts = [0] * EDITORS

    def job(n, base):
        attempts[n] = worker(base, n, gate)

    with LiveServer(server_app, port) as srv:
        threads = [threading.Thread(target=job, args=(n, srv.url)) for n in range(EDITORS)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        final = requests.get(f"{srv.url}/notes/1").json()
    kept = sum(f"line {n}." in final["body"] for n in range(EDITORS))
    print(f"{label}: kept {kept} of {EDITORS} lines, version {final['version']}, attempts per editor {sorted(attempts)}")


run("unprotected API, plain PUT   ", app_v1.app, 8706, naive)
run("If-Match API, retrying client", app_v3.app, 8706, careful)
unprotected API, plain PUT   : kept 1 of 8 lines, version 9, attempts per editor [1, 1, 1, 1, 1, 1, 1, 1]
If-Match API, retrying client: kept 8 of 8 lines, version 9, attempts per editor [1, 2, 3, 3, 4, 5, 6, 7]

The unprotected API kept one line out of eight. The protected API kept all eight, and the version ended at 9 because there were eight real writes. The list at the end of each line shows how many attempts each editor needed, sorted. Your list will differ from mine, but the smallest number is always 1, the editor who won the first collision, and nobody can need more than 8: every refusal means another editor’s write landed in between, and each of the other seven editors writes only once.

When the response gets lost

One more case is worth seeing before you trust a retry loop. Suppose a save reaches the server and is applied, but the response never reaches the client, perhaps because of a timeout. The client has no idea whether the save happened, so it starts over. It reads the note, which now includes its own earlier write, and applies its edit again. RFC 9110 acknowledges this situation: a server that sees “a state-changing operation that appears to have already been applied to the selected representation” may answer with a 2xx status instead of a 412. This server cannot tell, so the client has to be careful. step07b_lost_response.py reproduces the case with two kinds of edit function. Run it with python step07b_lost_response.py.

# step07b_lost_response.py
import requests

import client
import store
from app_v3 import app
from labserver import LiveServer

LINE = "Check the queue depth."


def append_always(title, body):
    return title, (body + " " + LINE).strip()


def append_once(title, body):
    return title, body if LINE in body else (body + " " + LINE).strip()


def scenario(label, edit, base):
    store.reset(body="Restart order: db, api, web.")
    url = f"{base}/notes/1"
    first = requests.get(url)
    # This save is applied by the server, but pretend its response never reached the client.
    requests.put(url, json={"title": "Runbook", "body": first.json()["body"] + " " + LINE},
                 headers={"If-Match": first.headers["ETag"]})
    client.update_note(base, 1, edit)  # the client retries the whole read-edit-write
    print(f"{label}: {requests.get(url).json()['body']}")


with LiveServer(app, 8708) as srv:
    scenario("retry with append_always", append_always, srv.url)
    scenario("retry with append_once  ", append_once, srv.url)
retry with append_always: Restart order: db, api, web. Check the queue depth. Check the queue depth.
retry with append_once  : Restart order: db, api, web. Check the queue depth.

The edit that always appends wrote its sentence twice. The edit that first checks whether the sentence is already there left a single copy. Write edits as idempotent functions of the current note, meaning that applying them twice gives the same result as applying them once, and your retry loop stays safe even when a response goes missing. Retrying here is also different from the transient failures in our exponential backoff tutorial: a 412 does not need a delay, it needs a fresh read.

Step 8: Conditional GET, and why Last-Modified is not enough

The same tag helps readers too. A client that already holds a note can send its tag in If-None-Match on a GET. If the note has not changed, the server answers 304 Not Modified with no body, and the client keeps using its copy. That is cheaper for everyone, and some APIs reward it. GitHub’s documentation says “Most endpoints return an etag header, and many endpoints return a last-modified header” and that “Making a conditional request does not count against your primary rate limit if a 304 response is returned and the request was made while correctly authorized with an Authorization header.”

Copy app_v3.py to app.py, change the comment on its first line to match, and replace read_note with this version. Note that it uses the weak comparison, through if_none_match_hit.

# app.py (copy app_v3.py, replace read_note)
@app.get("/notes/{note_id}")
def read_note(note_id: int, response: Response, if_none_match: str | None = Header(default=None)):
    note = store.get_note(note_id)
    if note is None:
        raise HTTPException(404, "no such note")
    etag = etags.make_etag(note["version"])
    if if_none_match is not None:
        try:
            unchanged = etags.if_none_match_hit(if_none_match, etags.tag_of(note["version"]))
        except ValueError as exc:
            raise HTTPException(400, f"bad If-None-Match header: {exc}")
        if unchanged:
            return Response(status_code=304, headers={"ETag": etag})
    response.headers["ETag"] = etag
    return note

The 304 response still carries the ETag header and an empty body. Now save step08_conditional_get.py, which stores a note of about 56 kilobytes so the difference is visible, and run it with python step08_conditional_get.py.

# step08_conditional_get.py
import requests

import app
import store
from labserver import LiveServer

store.reset(body="runbook line\n" * 4000)

with LiveServer(app.app, 8707) as srv:
    url = f"{srv.url}/notes/1"
    first = requests.get(url)
    etag = first.headers["ETag"]
    print(f"first GET                    -> {first.status_code}, {len(first.content):>6} body bytes, ETag {etag}")
    tries = [("same ETag", etag), ("weak form", "W/" + etag), ("list with it", '"99", ' + etag),
             ("star", "*"), ("an old ETag", '"99"')]
    for label, value in tries:
        r = requests.get(url, headers={"If-None-Match": value})
        print(f"If-None-Match: {label:13} -> {r.status_code}, {len(r.content):>6} body bytes")
    requests.put(url, json={"title": "Runbook", "body": "short"}, headers={"If-Match": etag})
    r = requests.get(url, headers={"If-None-Match": etag})
    print(f"same ETag after someone edits -> {r.status_code}, {len(r.content):>6} body bytes, ETag {r.headers['ETag']}")
first GET                    -> 200,  56080 body bytes, ETag "1"
If-None-Match: same ETag     -> 304,      0 body bytes
If-None-Match: weak form     -> 304,      0 body bytes
If-None-Match: list with it  -> 304,      0 body bytes
If-None-Match: star          -> 304,      0 body bytes
If-None-Match: an old ETag   -> 200,  56080 body bytes
same ETag after someone edits -> 200,     85 body bytes, ETag "2"

The first GET transfers 56,080 bytes of body. Every revalidation with a matching tag, whether in its strong form, its weak form, inside a list, or as *, returns 304 and zero bytes. A tag that does not match gets the full note again. So does the same tag after someone edits the note, and that response carries the new tag "2" and, here, an 85-byte body because the edit replaced the long text with the single word short.

Why not just use Last-Modified?

HTTP also has date-based validators: a Last-Modified response header and an If-Unmodified-Since request header that works like If-Match. They are tempting because the database already has an updated_at column, but dates have a built-in weakness. RFC 9110 says “An entity tag can be more reliable for validation than a modification date in situations where it is inconvenient to store modification dates, where the one-second resolution of HTTP-date values is not sufficient, or where modification dates are not consistently maintained.”

The one-second resolution is the problem for a busy API. step08b_lastmod_gap.py waits for the start of a fresh second, lets Alice read the note, lets Bob save 0.3 seconds later, and then asks both validators whether Alice’s copy is still current. Run it with python step08b_lastmod_gap.py.

# step08b_lastmod_gap.py
import time
from email.utils import formatdate, parsedate_to_datetime

import etags
import store

time.sleep(1 - time.time() % 1 + 0.05)  # start just after a second boundary so both writes share one second
store.reset(body="v1")
alice = store.get_note(1)
alice_date = formatdate(alice["updated_at"], usegmt=True)
alice_etag = etags.make_etag(alice["version"])

time.sleep(0.3)
bob = store.overwrite_note(1, "Runbook", "bob saved this 0.3 seconds later")
bob_date = formatdate(bob["updated_at"], usegmt=True)

print("alice read the note at:", alice_date, "| ETag", alice_etag)
print("bob saved it at       :", bob_date, "| ETag", etags.make_etag(bob["version"]))
unmodified = parsedate_to_datetime(bob_date) <= parsedate_to_datetime(alice_date)
print("If-Unmodified-Since: alice's date says unchanged ->", unmodified)
print("If-Match: alice's ETag says unchanged            ->", etags.if_match_ok(alice_etag, etags.tag_of(bob["version"])))
alice read the note at: Fri, 09 Oct 2026 20:41:10 GMT | ETag "1"
bob saved it at       : Fri, 09 Oct 2026 20:41:10 GMT | ETag "2"
If-Unmodified-Since: alice's date says unchanged -> True
If-Match: alice's ETag says unchanged            -> False

Both dates print as the same second, so the date-based check says nothing changed and would let Alice overwrite Bob. The tag-based check knows better. Use ETags to protect writes, and treat a modification date as a hint at best.

Step 9: Protect DELETE and test the edge cases

A DELETE is a state-changing request like any other: deleting a note that someone just edited throws their work away. Add the compare-and-swap version of a delete to the end of store.py, and the endpoint to the end of app.py.

# store.py (continued)
def delete_if_version(note_id, expected_version):
    with closing(connect()) as conn, conn:
        cur = conn.execute("DELETE FROM notes WHERE id = ? AND version = ?", (note_id, expected_version))
    return cur.rowcount == 1
# app.py (continued)
@app.delete("/notes/{note_id}", status_code=204)
def delete_note(note_id: int, if_match: str | None = Header(default=None)):
    note = require_current(note_id, if_match)
    if not store.delete_if_version(note_id, note["version"]):
        raise HTTPException(412, "the note changed while your request was running: GET it again and decide again")
    return Response(status_code=204)

The status code 204 means success with no body, so the endpoint returns a bare Response with that status.

Now run a table of requests through the finished API. step09_edge_cases.py uses FastAPI’s test client, so it needs no server, and after each request it prints the status and the note’s tag. Run it with python step09_edge_cases.py.

# step09_edge_cases.py
from fastapi.testclient import TestClient

import app
import store

store.reset(body="start")
api = TestClient(app.app)
edit = {"title": "Runbook", "body": "edited"}


def etag_now():
    return api.get("/notes/1").headers.get("ETag", "(gone)")


def put(label, header=None, note_id=1):
    headers = {} if header is None else {"If-Match": header}
    r = api.put(f"/notes/{note_id}", json=edit, headers=headers)
    print(f"PUT    {label:32} -> {r.status_code}   ETag afterwards: {etag_now()}")


def delete(label, header):
    r = api.delete("/notes/1", headers={"If-Match": header})
    print(f"DELETE {label:32} -> {r.status_code}   ETag afterwards: {etag_now()}")


put("no If-Match header")
put('If-Match: "1"', '"1"')
put('If-Match: "1" again', '"1"')
put('If-Match: W/"2" (weak)', 'W/"2"')
put('If-Match: "1", "2" (a list)', '"1", "2"')
put("If-Match: *", "*")
put("If-Match: 4 (no quotes)", "4")
put('If-Match: "1" on note 99', '"1"', note_id=99)
delete('If-Match: "1" (stale)', '"1"')
delete('If-Match: "4" (current)', '"4"')
PUT    no If-Match header               -> 428   ETag afterwards: "1"
PUT    If-Match: "1"                    -> 200   ETag afterwards: "2"
PUT    If-Match: "1" again              -> 412   ETag afterwards: "2"
PUT    If-Match: W/"2" (weak)           -> 412   ETag afterwards: "2"
PUT    If-Match: "1", "2" (a list)      -> 200   ETag afterwards: "3"
PUT    If-Match: *                      -> 200   ETag afterwards: "4"
PUT    If-Match: 4 (no quotes)          -> 400   ETag afterwards: "4"
PUT    If-Match: "1" on note 99         -> 404   ETag afterwards: "4"
DELETE If-Match: "1" (stale)            -> 412   ETag afterwards: "4"
DELETE If-Match: "4" (current)          -> 204   ETag afterwards: (gone)

Here is how to read each row.

Request Status Why
PUT with no If-Match 428 The API insists on a conditional request.
PUT with "1" (current) 200 The tag matches, and the note moves to tag "2".
PUT with "1" again 412 The tag is stale, and nothing was written.
PUT with W/"2" 412 The tag value is current, but a weak tag cannot satisfy If-Match.
PUT with "1", "2" 200 Any listed tag that matches strongly is enough.
PUT with * 200 The note exists, so the wildcard is satisfied.
PUT with 4 400 The value has no quotes, so it is not a tag.
PUT to note 99 404 There is no note to compare against.
DELETE with a stale tag 412 The same rule as PUT.
DELETE with the current tag 204 Deleted, so the next GET finds nothing and the last column prints (gone).

Two rows deserve a second look. The weak tag is the one that surprises people: the note really is at that version, the client sent exactly the tag it had been given, and the server still refuses, because a weak tag can never satisfy If-Match. The practical advice is to hand out strong tags for any resource you protect this way. And If-Match: * means any existing note will do, but this server still uses compare-and-swap underneath, so if another write lands in that tiny gap it answers 412 and the client simply retries. A server that wanted * to always win would run an unconditional update for that case.

The limit of a version counter

One more weakness is worth seeing, because it comes from the tag design rather than from the code. RFC 9110 says “A strong validator is unique across all versions of all representations associated with a particular resource over time.” A counter that can start over breaks that promise. step09b_version_reset.py simulates what happens if a note is deleted and recreated under the same id, or restored from an old backup. Run it with python step09b_version_reset.py.

# step09b_version_reset.py
import etags
import store

store.reset(body="the original note")
old_tag = etags.make_etag(store.get_note(1)["version"])
print("a client reads the note and keeps its ETag:", old_tag)

store.reset(body="a different note that reuses id 1")  # stands in for a delete and re-create, or a restore from an old backup
note = store.get_note(1)
print("id 1 now holds:", note["body"], "| ETag", etags.make_etag(note["version"]))
print("the old If-Match is still accepted:", etags.if_match_ok(old_tag, etags.tag_of(note["version"])))
a client reads the note and keeps its ETag: "1"
id 1 now holds: a different note that reuses id 1 | ETag "1"
the old If-Match is still accepted: True

The note is a different note, but its tag is "1" again, so a client holding the old tag passes the precondition and overwrites content it never saw. Whether this can happen to you depends on your data. If ids are never reused and you never restore old rows, a counter is enough. If they can be, use a tag that cannot repeat; options include a counter kept in a separate table that only moves forward, or a hash of the stored content. This lab does not build either, so treat it as the one open limit of the design.

Step 10: Let a browser app use the API (CORS)

Everything so far used Python clients. A web page served from a different origin than the API runs into a browser rule that has nothing to do with HTTP preconditions but can quietly break them: cross-origin resource sharing, or CORS. Two parts of it matter here.

Two CORS rules that affect ETags

The response side

MDN says in its Access-Control-Expose-Headers reference that “Only the CORS-safelisted response headers are exposed by default” and that “For clients to be able to access other headers, the server must list them using the Access-Control-Expose-Headers header.” The safelisted names are Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. ETag is not among them, so a script on another origin reads it as null unless the server exposes it.

The request side

MDN’s list of CORS-safelisted request headers contains Accept, Accept-Language, Content-Language, Content-Type and Range. If-Match is not on it, so a request that carries one cannot skip the preflight (a PUT would need one anyway), and the server has to approve the header by name. MDN says that “When containing only these headers (and values that meet the additional requirements laid out below), a request doesn’t need to send a preflight request in the context of CORS.” Starlette’s CORSMiddleware, which FastAPI re-exports as fastapi.middleware.cors, answers the preflight. Its documentation describes allow_headers as “A list of HTTP request headers that should be supported for cross-origin requests.” and expose_headers as “Indicate any response headers that should be made accessible to the browser.”

Test it in a real browser

Save the page below as cors_page.html. It reads the note, looks at the ETag header the way a script would, and, if it can see one, saves with If-Match.

<!doctype html>
<meta charset="utf-8">
<pre id="out">running</pre>
<script>
const api = "http://127.0.0.1:" + new URLSearchParams(location.search).get("api");
const lines = [];
async function main() {
  try {
    const got = await fetch(api + "/notes/1");
    const etag = got.headers.get("ETag");
    lines.push("GET " + got.status + ", ETag as the page sees it: " + etag);
    if (etag === null) {
      lines.push("no ETag to send back, so no safe PUT is possible");
    } else {
      try {
        const put = await fetch(api + "/notes/1", {
          method: "PUT",
          headers: {"Content-Type": "application/json", "If-Match": etag},
          body: JSON.stringify({title: "Runbook", body: "saved from a browser page"}),
        });
        lines.push("PUT " + put.status + ", new ETag " + put.headers.get("ETag"));
      } catch (e) {
        lines.push("PUT blocked by the browser: " + e.name + " (" + e.message + ")");
      }
    }
  } catch (e) {
    lines.push("GET blocked by the browser: " + e.name);
  }
  document.getElementById("out").textContent = lines.join("\n");
}
main();
</script>

Then save step10_cors.py. It starts three copies of the same API with different CORS settings, asks each one a preflight question directly, and loads the page in headless Edge to see what a browser script experiences. The path in EDGE is for Edge on Windows, and I ran it with Microsoft Edge 154. Another Chromium-based browser should work if you point EDGE at your own Chrome or Chromium on macOS or Linux. Run it with python step10_cors.py.

# step10_cors.py
import html
import re
import subprocess
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import requests
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

import app as notes
import store
from labserver import LiveServer

EDGE = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"  # any Chromium-based browser works
PAGE_PORT = 8800
PAGE_ORIGIN = f"http://127.0.0.1:{PAGE_PORT}"
PAGE = Path(__file__).with_name("cors_page.html").read_bytes()


class PageHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(PAGE)

    def log_message(self, *args):
        pass


def make_api(**cors):
    api = FastAPI()
    api.router.routes.extend(notes.app.router.routes)  # same endpoints, different CORS settings
    api.add_middleware(CORSMiddleware, allow_origins=[PAGE_ORIGIN], allow_methods=["GET", "PUT", "DELETE"], **cors)
    return api


CONFIGS = [
    ("A: defaults, nothing exposed", 8801, dict(allow_headers=["Content-Type"])),
    ("B: ETag exposed, If-Match not allowed", 8802, dict(allow_headers=["Content-Type"], expose_headers=["ETag"])),
    ("C: ETag exposed, If-Match allowed", 8803, dict(allow_headers=["Content-Type", "If-Match", "If-None-Match"],
                                                    expose_headers=["ETag"])),
]

page_server = ThreadingHTTPServer(("127.0.0.1", PAGE_PORT), PageHandler)
threading.Thread(target=page_server.serve_forever, daemon=True).start()
profile = tempfile.mkdtemp(prefix="edge-profile-")

for label, port, cors in CONFIGS:
    store.reset(body="start")
    with LiveServer(make_api(**cors), port):
        pre = requests.options(f"http://127.0.0.1:{port}/notes/1", headers={
            "Origin": PAGE_ORIGIN, "Access-Control-Request-Method": "PUT",
            "Access-Control-Request-Headers": "content-type,if-match"})
        shown = pre.headers.get("Access-Control-Allow-Headers")
        print(f"== {label}")
        print(f"   preflight for PUT with If-Match -> {pre.status_code} {pre.text.strip()[:40]} | allow-headers: {shown}")
        run = subprocess.run([EDGE, "--headless=new", "--disable-gpu", "--no-first-run", f"--user-data-dir={profile}",
                              "--virtual-time-budget=8000", "--dump-dom", f"{PAGE_ORIGIN}/page.html?api={port}"],
                             capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=90)
        found = re.search(r'<pre id="out">(.*?)</pre>', run.stdout, flags=re.S)
        for line in html.unescape(found.group(1)).splitlines() if found else ["(no output from the page)"]:
            print("   page:", line)
page_server.shutdown()
== A: defaults, nothing exposed
   preflight for PUT with If-Match -> 400 Disallowed CORS headers | allow-headers: Accept, Accept-Language, Content-Language, Content-Type
   page: GET 200, ETag as the page sees it: null
   page: no ETag to send back, so no safe PUT is possible
== B: ETag exposed, If-Match not allowed
   preflight for PUT with If-Match -> 400 Disallowed CORS headers | allow-headers: Accept, Accept-Language, Content-Language, Content-Type
   page: GET 200, ETag as the page sees it: "1"
   page: PUT blocked by the browser: TypeError (Failed to fetch)
== C: ETag exposed, If-Match allowed
   preflight for PUT with If-Match -> 200 OK | allow-headers: Accept, Accept-Language, Content-Language, Content-Type, If-Match, If-None-Match
   page: GET 200, ETag as the page sees it: "1"
   page: PUT 200, new ETag "2"

Configuration A is what you get with the basics. The GET works, but the page sees null for the ETag, so it has nothing to send back. In B the ETag is exposed, but If-Match is not allowed, so the preflight is refused (the server prints 400 Disallowed CORS headers) and the browser blocks the PUT with a vague TypeError. In C both settings are right, and the save goes through. The settings that matter are the two lists in the last configuration: allow_headers must include If-Match, and If-None-Match too if your page sets that header itself, since it is not on the safelist either, and expose_headers must include ETag.

Step 11: Lock it in with tests

Everything above ran as scripts. Turn the behavior into tests so it stays true. Save test_notes.py in three parts. The first part sets up a fresh database for every test and tests the parser and the comparison rules.

# test_notes.py
import threading

import pytest
import requests
from fastapi.testclient import TestClient

import app as app_module
import client
import etags
import store
from labserver import LiveServer

EDIT = {"title": "Runbook", "body": "edited"}


@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
    monkeypatch.setattr(store, "DB_PATH", str(tmp_path / "notes.db"))
    store.reset(body="start")


@pytest.fixture()
def api():
    return TestClient(app_module.app)


def test_tag_list_keeps_commas_inside_a_tag():
    assert etags.parse_tag_list('"a,b", W/"c"') == [(False, "a,b"), (True, "c")]


@pytest.mark.parametrize("bad", ["", "4", '"4" "5"', 'W/ "4"'])
def test_tag_list_rejects_bad_syntax(bad):
    with pytest.raises(ValueError):
        etags.parse_tag_list(bad)


def test_comparison_table_from_the_rfc():
    weak1, weak2, strong1 = (True, "1"), (True, "2"), (False, "1")
    assert not etags.strong_equal(weak1, weak1) and etags.weak_equal(weak1, weak1)
    assert not etags.strong_equal(weak1, weak2) and not etags.weak_equal(weak1, weak2)
    assert not etags.strong_equal(weak1, strong1) and etags.weak_equal(weak1, strong1)
    assert etags.strong_equal(strong1, strong1) and etags.weak_equal(strong1, strong1)

The second part tests every response in the table from Step 9 through FastAPI’s test client.

# test_notes.py (continued)
def test_get_sends_the_etag(api):
    r = api.get("/notes/1")
    assert r.status_code == 200 and r.headers["ETag"] == '"1"'


def test_put_without_if_match_is_428_and_changes_nothing(api):
    assert api.put("/notes/1", json=EDIT).status_code == 428
    assert store.get_note(1)["version"] == 1


def test_put_with_the_current_etag_saves_and_returns_the_next_one(api):
    r = api.put("/notes/1", json=EDIT, headers={"If-Match": '"1"'})
    assert r.status_code == 200 and r.headers["ETag"] == '"2"' and r.json()["body"] == "edited"


def test_put_with_a_stale_etag_is_412_and_changes_nothing(api):
    api.put("/notes/1", json=EDIT, headers={"If-Match": '"1"'})
    r = api.put("/notes/1", json={"title": "Runbook", "body": "late"}, headers={"If-Match": '"1"'})
    assert r.status_code == 412
    assert store.get_note(1)["body"] == "edited"


def test_a_weak_tag_never_satisfies_if_match(api):
    assert api.put("/notes/1", json=EDIT, headers={"If-Match": 'W/"1"'}).status_code == 412


def test_star_and_lists(api):
    assert api.put("/notes/1", json=EDIT, headers={"If-Match": '"9", "1"'}).status_code == 200
    assert api.put("/notes/1", json=EDIT, headers={"If-Match": "*"}).status_code == 200


def test_bad_header_is_400_and_missing_note_is_404(api):
    assert api.put("/notes/1", json=EDIT, headers={"If-Match": "1"}).status_code == 400
    assert api.put("/notes/99", json=EDIT, headers={"If-Match": '"1"'}).status_code == 404


def test_conditional_get(api):
    for value, expected in [('"1"', 304), ('W/"1"', 304), ('"9", "1"', 304), ("*", 304), ('"9"', 200)]:
        r = api.get("/notes/1", headers={"If-None-Match": value})
        assert r.status_code == expected, value
        assert r.headers["ETag"] == '"1"'
    assert api.get("/notes/1", headers={"If-None-Match": '"1"'}).content == b""


def test_delete_needs_the_current_etag(api):
    assert api.delete("/notes/1").status_code == 428
    assert api.delete("/notes/1", headers={"If-Match": '"7"'}).status_code == 412
    assert api.delete("/notes/1", headers={"If-Match": '"1"'}).status_code == 204
    assert api.get("/notes/1").status_code == 404


def test_compare_and_swap_refuses_a_stale_version():
    assert store.update_if_version(1, 1, "Runbook", "first") is not None
    assert store.update_if_version(1, 1, "Runbook", "second") is None
    assert store.get_note(1)["body"] == "first"

The last two tests start a real server and use threads. The first repeats the eight-editor race against the finished API. The second runs six retrying clients and checks that every edit survives.

# test_notes.py (continued)
def test_exactly_one_of_eight_simultaneous_saves_wins(monkeypatch):
    monkeypatch.setattr(app_module, "between_check_and_write", threading.Barrier(8, timeout=20).wait)
    statuses = []

    def editor(n, base):
        etag = requests.get(f"{base}/notes/1").headers["ETag"]
        reply = requests.put(f"{base}/notes/1", json={"title": "Runbook", "body": f"editor {n}"},
                             headers={"If-Match": etag}, timeout=30)
        statuses.append(reply.status_code)

    with LiveServer(app_module.app, 8790) as srv:
        threads = [threading.Thread(target=editor, args=(n, srv.url)) for n in range(8)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
    assert sorted(statuses) == [200] + [412] * 7
    assert store.get_note(1)["version"] == 2


def test_retrying_clients_keep_every_edit():
    gate = threading.Barrier(6, timeout=20)

    def editor(n, base):
        client.update_note(base, 1, lambda title, body: (title, body + f" line {n}."),
                           before_put=lambda attempt: gate.wait() if attempt == 1 else None)

    with LiveServer(app_module.app, 8791) as srv:
        threads = [threading.Thread(target=editor, args=(n, srv.url)) for n in range(6)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
    note = store.get_note(1)
    assert note["version"] == 7
    assert all(f"line {n}." in note["body"] for n in range(6))

Run the suite with python -m pytest -q test_notes.py.

.................. [100%]
18 passed in 1.52s

Confirm that it all works end to end

You now have a protected API. Each of these checks should pass on your machine:

  1. python step02_lost_update.py still shows the lost update, because it targets the unprotected app on purpose.
  2. python step04_conditional_put.py shows 200, 412 and 428, and the final body keeps Alice’s sentence.
  3. python step05_race.py app_v3 prints one saved and seven refused, which means the compare-and-swap is working.
  4. python step07_retry.py keeps all eight lines on the protected API.
  5. python -m pytest -q test_notes.py reports 18 passed.
  6. python step10_cors.py ends with the PUT succeeding in configuration C.

If check 3 prints more than one saved, make sure app_v3.py contains the call to update_if_version from Step 6 and that store.py defines a function with that name.

Common mistakes to watch for

Handing out weak tags for protected resources. A tag that starts with W/ can never satisfy If-Match, so every save of that resource is refused even though nothing changed. Generate strong tags for anything you protect.

Splitting If-Match on commas. A comma is legal inside a tag. Parse the header with the grammar, as parse_tag_list does, and answer 400 when it does not parse.

Checking in Python and writing in SQL as separate steps. That is the race from Step 5. Put the version in the WHERE clause of the write, and treat zero rows changed as a lost race.

Retrying with the old body. After a 412, read the note again and apply the intent to the fresh copy. Resending the stale body brings the lost update back.

Accepting writes without a precondition. The protection only works if the server insists on it. This API refuses a PUT without If-Match. A server that accepts both lets any careless client bring the problem back.

Forgetting the browser rules. Without expose_headers a page cannot read the tag, and without allow_headers the browser blocks the save before it reaches your code.

Trusting a date or a counter that can repeat. A date has one-second resolution, and a counter that restarts hands out a tag that was already used.

Where to go next

Two more uses of the same machinery are worth knowing. The first is create-only writes. RFC 9110 says that If-None-Match with a value of * can be used “to prevent an unsafe request method (e.g., PUT) from inadvertently modifying an existing representation of the target resource when the client believes that the resource does not have a current representation”, which is the lost update problem for the moment of creation. The second is the creation response itself: the standard notes that “an ETag field in a 201 (Created) response communicates the entity tag of the newly created resource’s representation, so that the entity tag can be used as a validator in later conditional requests”. This lab seeds its only note, but if your API creates resources, return the tag with the 201.

Related reading on this site:

  • How to Prevent Race Conditions in Django With select_for_update() and F() Expressions locks a row inside one request. An ETag protects the whole read, edit and write cycle, which spans two requests and a person’s thinking time, so the two techniques complement each other.
  • How to Build Vector Clocks in Python to Detect Concurrent Writes in a Distributed System shows what to do when a single counter is not enough because data lives on several replicas.
  • How to Build a CRDT Counter in Python So Distributed Nodes Never Lose an Increment covers the other answer to a conflict: merge the two changes instead of refusing one.
  • How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App is a reminder that a valid tag proves the client saw the latest version, not that it is allowed to change the note.

You started with an API where two honest users could silently destroy each other’s work. You finish with one that labels every version, refuses stale writes with a clear status, closes the race in a single SQL statement, lets clients recover, saves bandwidth on reads, and works from a browser. The same four pieces, a tag, a precondition, a refusal and a retry, work for any resource you let people edit.

Tags:

API DesignConcurrencyFastAPIHTTPPython

Share

Back of an Exabyte Mammoth data cartridge, a tape cassette made for computer backups, shown on a white background
Previous Post

Ahsay Says Version 10.3.4 Fixes Two Exploited AhsayCBS Flaws, and Huntress Says It Does Not

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
09 Oct
How to Prevent Lost Updates in a FastAPI API With ETag and If-Match
09 Oct
Ahsay Says Version 10.3.4 Fixes Two Exploited AhsayCBS Flaws, and Huntress Says It Does Not
Trending
October 9, 2026
How to Prevent Lost Updates in a FastAPI API With ETag and If-Match
October 9, 2026
Ahsay Says Version 10.3.4 Fixes Two Exploited AhsayCBS Flaws, and Huntress Says It Does Not
October 9, 2026
How to Audit SPF, DKIM, and DMARC in Python to Stop Spoofed Email From Using Your Domain
October 9, 2026
A CNCF Post on NIS2 and DORA Turns Compliance Into a Backlog and Leaves the Classification Call Unowned
October 9, 2026
Anthropic Launches OSS Scanner to Email Open-Source Maintainers AI Bug Reports No Human Has Reviewed
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026