Два редактора в FastAPI: защищаем PUT заголовком If-Match

Два сотрудника открыли карточку товара с названием Desk. Первый изменил цену и сохранил. Второй исправил название в старой форме и отправил весь объект. Его PUT успешно перезаписал новую цену прежней. Оба запроса были корректны по схеме, но второй опирался на состояние, которого уже нет.

Версия связывает чтение с записью

Сервер возвращает ETag карточки при GET. При сохранении клиент отправляет эту метку в If-Match: выполнить изменение можно только для той версии, которую он редактировал. Если версия успела измениться, сервер отвечает 412 Precondition Failed. Пользователь должен перечитать карточку и решить, как совместить правки.

Ключевой шаг находится в базе. Сначала прочитать версию, сравнить её в Python, а потом выполнить обычный UPDATE недостаточно: между сравнением и записью придёт другой запрос. В условие самого UPDATE включаются id и ожидаемая версия. База меняет строку и увеличивает счётчик одной операцией. Нулевое число изменённых строк означает, что предпосылка уже не выполнена.

В примере используется собственный строгий договор: If-Match содержит одну сильную метку вида "v1". Стандарт допускает также списки и звёздочку; здесь API их явно отклоняет как неподдерживаемую форму. Слабые метки W/ тоже не подходят для такой проверки. Отсутствие обязательной предпосылки получает 428, неверный формат — 400, устаревшая версия — 412.

Воспроизведём потерю обновления без гонки потоков

Для теста достаточно двух последовательных PUT с одной исходной меткой: они представляют две формы, открытые до первого сохранения. Нужны FastAPI, HTTPX и SQLAlchemy 2. SQLite хранится в памяти через StaticPool для локального примера. Условный UPDATE выполняется внутри транзакции, и версия возвращается только после её успешного завершения.

Тело модели содержит новое название. В реальном PUT перечислите весь заменяемый ресурс, а для частичной правки используйте PATCH с тем же условием версии. Заголовок решает конфликт между редактированиями, а метод определяет смысл передаваемых полей.

import re
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
from sqlalchemy import create_engine, text
from sqlalchemy.pool import StaticPool

engine = create_engine("sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False})
with engine.begin() as db:
    db.execute(text("CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT, version INTEGER NOT NULL)"))
    db.execute(text("INSERT INTO item VALUES (1, 'Desk', 1)"))
app = FastAPI()
class Edit(BaseModel):
    name: str

@app.get("/items/1")
def read():
    with engine.connect() as db:
        row = db.execute(text("SELECT name, version FROM item WHERE id=1")).one()
    return JSONResponse({"name": row.name}, headers={"ETag": f'"v{row.version}"'})

@app.put("/items/1")
def edit(data: Edit, if_match: Annotated[str | None, Header()] = None):
    if if_match is None:
        raise HTTPException(428, "If-Match required")
    match = re.fullmatch(r'"v([1-9][0-9]*)"', if_match)
    if match is None:
        raise HTTPException(400, "send one strong version tag")
    version = int(match[1])
    with engine.begin() as db:
        result = db.execute(text("UPDATE item SET name=:name, version=version+1 WHERE id=1 AND version=:v"), {"name": data.name, "v": version})
        if result.rowcount != 1:
            raise HTTPException(412, "reload before editing")
    return JSONResponse({"name": data.name}, headers={"ETag": f'"v{version+1}"'})

with TestClient(app) as client:
    tag = client.get("/items/1").headers["etag"]
    first = client.put("/items/1", json={"name": "Chair"}, headers={"If-Match": tag})
    stale = client.put("/items/1", json={"name": "Lamp"}, headers={"If-Match": tag})
    assert first.status_code == 200 and first.headers["etag"] == '"v2"'
    assert stale.status_code == 412
    assert client.get("/items/1").json() == {"name": "Chair"}
    assert client.put("/items/1", json={"name": "Lamp"}).status_code == 428
print(first.status_code, stale.status_code, client.get("/items/1").json())
engine.dispose()

Что должен сделать интерфейс после 412

Не повторяйте тот же PUT автоматически с новой меткой. Так клиент подтвердит чужое состояние, которого пользователь не видел, и защита потеряет смысл. Покажите актуальную карточку и локальную правку, затем предложите сохранить осознанное объединение. Для простого поля достаточно попросить перечитать форму; для сложного документа нужен интерфейс сравнения.

В общем маршруте с произвольным id добавьте различение отсутствующей строки и устаревшей версии согласно договору API. Проверка существования не должна менять атомарность записи. Сначала попытайтесь выполнить условный UPDATE, а дальнейшее чтение используйте лишь для выбора ответа. В примере ресурс заранее создан и всегда существует, чтобы сосредоточиться на конкуренции.

Версия должна увеличиваться на всех путях изменения карточки: через API, административную задачу и импорт. Если один путь оставляет счётчик прежним, старый клиент сможет перезаписать результат. Полезно вынести изменение в общий слой либо обеспечить правило на уровне базы. Права доступа проверяются дополнительно и включают допустимый ресурс пользователя.

Проверка двух клиентов не требует случайного воспроизведения гонки. Оба читают одну версию, первый сохраняет, второй получает отказ. Такой тест устойчиво проверяет смысл механизма. Отдельные конкурентные испытания понадобятся для выбранного уровня изоляции и конкретной базы, если приложение использует более сложные транзакции.

Следующая практика

Развейте обычный CRUD в обновление с проверкой версии. В бесплатной программе FastAPI вы соедините маршруты, SQLAlchemy 2 и тесты — эти части нужны, чтобы проверить два сохранения одной карточки. Перейдите к бесплатному курсу FastAPI.

Продолжить на практике