Перейти к содержанию

Ключ повторного запроса на бронирование

Оглавление серии

PostgreSQL защищает кабинет от пересечений, но сетевой ответ может потеряться после успешной фиксации. Клиент не знает, создана ли бронь, и повторяет POST. Без отдельного договора повтор выглядит новой операцией и может получить конфликт с собственной первой бронью. Добавим Idempotency-Key: клиентский ключ одного намерения, позволяющий серверу вернуть сохранённый результат при повторной отправке тех же данных.

Идемпотентность здесь относится к созданию с заданным ключом, а не ко всем POST автоматически. Новый ключ означает новое намерение. Тот же ключ с другим содержимым является ошибкой key_reused. Мы сохраняем первоначальную карточку ответа, поэтому повтор не изображает новую бронь и не перечитывает изменённую позже карточку как исходный результат. Это важно после отмены: договор повтора создания и договор текущего состояния объекта различаются.

Область ключа и отпечаток

Ключ принадлежит владельцу: первичный ключ таблицы idempotency состоит из actor_id и request_key. На этой ступени владелец пока фиксирован сервером, а в следующей будет получен из учебного входа. Пользователь не может указать чужой actor_id в теле. Если два разных владельца выберут одинаковую строку request_key, это не должно связать их ответы. Область идентичности включена в механизм с самого начала, а не добавлена только на уровне интерфейса.

Допустимый ключ содержит до восьмидесяти букв ASCII, цифр, точки, подчёркивания или дефиса. Это конкретный учебный договор, а не универсальное требование всех API. Ограничение удерживает запись компактной и позволяет использовать строку без неожиданных разделителей. Пустой ключ не принимается. Клиент должен повторять тот же ключ после неопределённого сетевого результата, а создавать новый для нового намерения. Простое новое значение при каждом retry разрушило бы пользу механизма.

Fingerprint вычисляется из нормализованного BookingIn. Сначала model_dump(mode="json") делает сериализуемые значения, затем json.dumps сортирует ключи и задаёт фиксированные разделители. SHA-256 описывает эти данные. Это не подпись пользователя и не доказательство доверия: отпечаток нужен для сравнения двух намерений на сервере. Не вычисляем его из сырого текста HTTP, где порядок свойств и форма смещения могли бы отличаться без изменения смысла брони.

Полные файлы шага

Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-10/.

migrations/003_idempotency.sql

CREATE TABLE idempotency (
    actor_id integer NOT NULL REFERENCES actors(id),
    request_key varchar(80) NOT NULL,
    fingerprint char(64) NOT NULL,
    response_body jsonb NOT NULL,
    PRIMARY KEY (actor_id, request_key)
);

service.py

from hashlib import sha256
import json
import re
from uuid import uuid4

from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from errors import DomainError
from models import Booking, Resource
from schemas import BookingIn, BookingOut


def create_booking(
    db: Session, data: BookingIn, actor_id: int, request_key: str
) -> BookingOut:
    if re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", request_key) is None:
        raise DomainError("invalid_key", "Некорректный ключ запроса", 422)
    canonical = json.dumps(
        data.model_dump(mode="json"), sort_keys=True, separators=(",", ":")
    )
    fingerprint = sha256(canonical.encode()).hexdigest()
    try:
        db.execute(text(
            "SELECT pg_advisory_xact_lock(hashtextextended(:scope, 0))"
        ), {"scope": f"{actor_id}:{request_key}"})
        previous = db.execute(text(
            "SELECT fingerprint, response_body FROM idempotency "
            "WHERE actor_id = :actor AND request_key = :key"
        ), {"actor": actor_id, "key": request_key}).mappings().first()
        if previous:
            if previous["fingerprint"] != fingerprint:
                raise DomainError("key_reused", "Ключ уже связан с другим запросом")
            result = BookingOut.model_validate(previous["response_body"])
            db.commit()
            return result
        if db.get(Resource, data.resource_id) is None:
            raise DomainError("resource_missing", "Ресурс не найден", 404)
        row = Booking(**data.model_dump(), actor_id=actor_id)
        db.add(row)
        db.flush()
        result = BookingOut.model_validate(row)
        body = json.dumps(result.model_dump(mode="json"))
        db.execute(text(
            "INSERT INTO idempotency "
            "(actor_id, request_key, fingerprint, response_body) "
            "VALUES (:actor, :key, :fingerprint, CAST(:body AS jsonb))"
        ), {"actor": actor_id, "key": request_key,
            "fingerprint": fingerprint, "body": body})
        db.commit()
        return result
    except IntegrityError as error:
        db.rollback()
        constraint = getattr(getattr(error.orig, "diag", None), "constraint_name", None)
        if constraint == "bookings_no_overlap":
            raise DomainError("slot_busy", "Интервал уже занят") from error
        raise
    except Exception:
        db.rollback()
        raise

Ожидаемый результат

Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.

Первый ключ november-room-1 → 201, id = N
Повтор тех же данных с тем же ключом → 201, id = N
Другие данные с тем же ключом → 409 key_reused

Сериализация операций с одним ключом

Перед чтением таблицы сервер получает pg_advisory_xact_lock от hashtextextended строки с владельцем и ключом. Это блокировка до завершения транзакции. Два вызова нашего сервиса с одинаковой областью выполняют критический участок последовательно. После ожидания следующий SELECT при явно выбранном READ COMMITTED видит зафиксированную запись предыдущего вызова. Так исчезает гонка «оба не нашли ключ», а уникальный первичный ключ остаётся дополнительным структурным правилом таблицы.

Хэш может совпасть и для разных областей. В таком случае операции лишь излишне подождут друг друга; они не станут одинаковыми данными, потому что дальнейший SELECT и первичный ключ используют реальные actor_id и request_key. Это важная разница между ключом блокировки и ключом записи. Все пути создания через наш сервис должны соблюдать этот договор. Если добавить другой writer, который игнорирует advisory lock, потребуется согласовать поведение конфликтов и retries отдельно.

Когда запись найдена, сравниваем fingerprint. Совпадение возвращает BookingOut из сохранённого response_body; перед возвратом завершаем транзакцию commit. Несовпадение поднимает DomainError. Когда записи нет, создаём бронь, получаем номер через flush и записываем ответ в idempotency. Две записи фиксируются вместе. Не должно существовать сохранённой брони без её ключа только из-за отдельного commit между этими действиями.

Ошибка не становится сохранённым успехом

В этом учебном договоре сохраняются только успешные результаты создания. Если exclusion constraint отказал, транзакция откатывает и попытку брони, и запись ключа. Повтор после изменения расписания может снова попробовать то же намерение. Это осознанное ограничение: некоторые системы сохраняют и ответы об ошибках, но тогда срок и смысл повторения нужно определить отдельно. Мы не приписываем показанному коду такую политику и не называем все неудачи кэшированными.

Пример содержит ключ november-room-1. Первый успешный запрос должен получить карточку с номером N. Повтор тех же нормализованных данных с этим ключом возвращает ту же карточку и тот же успешный статус создания, поскольку маршрут явно задаёт 201. Это повтор первоначального ответа, а не утверждение, что строка была создана ещё раз. Если изменить resource_id, сохранив ключ, ожидается 409 key_reused. Если сменить ключ и оставить занятое время, возможен 409 slot_busy.

После будущей отмены GET карточки покажет cancelled, а повтор создания с прежним ключом всё ещё вернёт сохранённый confirmed-снимок первоначальной операции. Такой результат может удивить без объяснения. Клиенту нужно различать подтверждение выполненного запроса и запрос текущего состояния. Мы явно выбрали первый смысл для идемпотентного ответа. При необходимости другого договора изменяют схему API, а не молча заменяют сохранённый body свежим объектом.

Таблица пока не имеет срока очистки. Для учебной базы это позволяет сохранить историю повторов на протяжении курса. В действующей системе нужно определить окно повторения и политику удаления, иначе память ответов будет расти. Также после неизвестного результата соединения клиент должен повторять исходный ключ, а не считать отсутствие ответа доказательством отката. Все эти свойства следуют из места commit; фактические запросы и SQL при подготовке не выполнялись.

Окно повторения имеет предметный смысл

Ключ нельзя удалять из таблицы просто потому, что пользователь закрыл вкладку. Он может вернуться после сетевого сбоя и повторить исходное намерение позже. Если удалить память ответа слишком рано, тот же ключ опять станет новым созданием. Для production-договора нужен документированный срок, в пределах которого повтор сохраняет прежний смысл. В этой учебной базе очистка отсутствует, поэтому мы не показываем выдуманную гарантию на произвольное число часов.

Отпечаток также зависит от набора полей операции. Если в будущем появится новое предметное свойство, нужно решить, входит ли оно в идентичность намерения и как сравниваются старые записи. Просто обновить BookingIn недостаточно для истории уже сохранённых fingerprint. Текущий курс сохраняет один набор входных полей, так что договор читается полностью. Расширение API потребует версионирования канонической формы либо явной политики старых ключей. Этот вопрос отличается от выбора алгоритма SHA-256: надёжный алгоритм не определяет смысл данных за автора приложения.

Документация и исходники

Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.