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

MCP: повтор запроса и идемпотентная запись

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

В library.create_draft уже есть operation. С десятого состояния сервер разрешает replay существующей операции при совпадении fingerprint. Черновики и подтверждения хранятся в одной SQLite-базе, поэтому состояние не зависит от памяти процесса. Пример не запускался; база, потеря ответа и реальные повторные вызовы при подготовке не создавались. Мы рассматриваем полный код и ожидаемые состояния транзакции, не изображая выполненный эксперимент.

Идентификатор не является содержимым

Клиент выбирает устойчивое имя операции, например draft-request-17. Оно должно сохраняться при повторе одного намерения. Новый черновик получает новый operation. Само имя не доказывает право записи и не является секретом авторизации. Сначала сервер проверяет editor-роль. Для первой записи также требуется подходящее подтверждение человека. Таким образом, идентификатор решает вопрос повторной доставки, а роль и токен — вопрос допустимости действия.

Payload связывает operation с slug, title, text и kind. Если клиент повторяет ключ, но меняет содержание, сервер отказывает. Иначе старое одобрение могло бы превратиться в разрешение на новый текст под удобным именем. Это важное условие любого такого договора: идемпотентность не означает «последний запрос с ключом побеждает». Она означает устойчивый результат одного определённого действия. Смена намерения требует другого ключа и самостоятельного подтверждения.

Операция и slug имеют разные ограничения. Slug определяет имя черновика в библиотеке, operation — попытку его создания. Две операции с одинаковым slug не создают два документа и не перезаписывают один автоматически. Уникальность slug даёт предметный отказ. Если нужен редакторский update, его следует добавить отдельным действием с версией и подтверждением изменения. Нельзя вывести право обновления из существования инструмента создания.

Порядок транзакции

Сервер начинает транзакцию и сначала ищет operation в drafts. Если запись уже есть, сравнивает payload. При совпадении возвращает slug, draft и replayed: true, не вставляя новую строку. При различии возникает DomainError. Такой порядок позволяет получить прежний результат после использованного или истёкшего токена: повтор не выполняет новую запись, а подтверждает уже состоявшееся действие. Это ограничение нужно объяснить оператору, чтобы replay не воспринимался как новый допуск.

Если operation отсутствует, сервер проверяет действующее подтверждение и создаёт черновик обычным маршрутом. Проверка и запись находятся в одной транзакции. При конкурентной попытке SQLite сериализует начало записи, а уникальное поле operation сохраняет единственность результата. Наш сервер сам синхронен, но отдельные процессы могут обратиться к той же базе. Такой договор на уровне хранения полезнее одного словаря в памяти, который исчезнет после перезапуска.

BEGIN IMMEDIATE не превращает всю систему в бесконечно масштабируемую очередь. Это небольшая локальная база с одной операцией записи и ограниченным временем ожидания блокировки. При серьёзной конкуренции нужно рассмотреть нагрузку, обработку занятости и подходящее хранилище. Мы используем механизм ради атомарной связи подтверждения и черновика, а не ради обещания высокой пропускной способности. Статическое чтение не измеряет скорость или поведение на вашей файловой системе.

Потерянный ответ

Разберём три возможных момента. Если процесс упал до commit, транзакция не должна оставить завершённый черновик, и повтор сможет пройти обычную проверку. Если commit состоялся, а ответ не дошёл, повтор с тем же operation найдёт сохранённый результат. Если клиент ошибочно выбрал новый operation, сервер не считает его повтором и применяет обычные ограничения, включая уникальный slug и подтверждение. Клиент должен сохранять ключ намерения, иначе сервер не сможет угадать связь двух сообщений.

Повтор чтения и повтор записи имеют разный смысл. Чтение может вернуть новую версию документа, если источник изменился; оно не создаёт дубль. Запись требует помнить связь с прежним payload. Поэтому аннотация idempotentHint становится для нашей create_draft положительной только с десятого состояния. Однако аннотация остаётся подсказкой. Фактическое свойство обеспечивают таблица, проверка fingerprint и транзакция. Нельзя получить идемпотентность простой сменой поля в tools/list.

Мы не храним отдельную таблицу всех отказов или срок жизни ключей идемпотентности. Operation связан с сохранённым черновиком и остаётся доступным, пока эта запись есть в базе. Если удалить её, возможность replay изменится. Реальная система должна определить срок хранения и порядок очистки, особенно если клиент может повторить запрос после долгого перерыва. В лаборатории удаления нет, чтобы связь между первым действием и повтором была видна в одном месте.

Ожидаемый первый ответ имеет replayed: false, второй с тем же ключом и payload — true. Под тем же ключом изменённый текст должен дать отказ, а база сохранить первоначальную запись. Это ожидание по коду, не выполненный сценарий. В статье нет фактических токенов или дампа базы. Читатель сможет исследовать поведение самостоятельно, сохраняя различие авторского описания и своих наблюдений о транзакции.

Два ключа для одного slug

Пусть первый вызов сохранил slug new-draft под operation A. Клиент потерял ответ и ошибочно повторил содержимое с operation B. Сервер не знает, что это повтор одного намерения: ключ другой. Уникальность slug остановит вставку, а новое подтверждение не должно приводить к молчаливой перезаписи. Такой отказ помогает увидеть ошибку клиента, но не заменяет правильное сохранение A до отправки первого запроса.

Если повтор содержит A и тот же payload, прежний результат можно вернуть даже после истечения approval. Новая запись не совершается, поэтому срок токена не используется как повод повторно подтвердить уже сохранённое действие. Это не означает, что любой editor может создавать новое содержание без токена. Проверка существующей операции отделена от маршрута первой вставки и требует точного совпадения.

Наш ответ replay включает slug и статус, но не раскрывает полный текст прежнего черновика. Это ограничивает результат повторной доставки нужной информацией. Если будущий интерфейс добавит просмотр drafts, его доступ следует определить отдельно. Идемпотентный create не обязан становиться универсальным reader закрытых редакционных записей.

Следующий урок создаст ограниченный клиент для чтения. Он покажет, как связать id запроса с ответом, ограничить ожидание и корректно завершить процесс. Редакторская запись останется отдельным маршрутом: учебный reader-клиент не получит права запускать операторское подтверждение или самовольно переключать роль.

Фрагмент файла storage.py из состояния lesson-10. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.

def create_draft(slug, title, text, approval, operation, allow_retry):
    if ROLE != "editor":
        raise DomainError("write not allowed for this role")
    payload = fingerprint(slug,title,text)
    digest = hashlib.sha256(approval.encode()).hexdigest()
    db = connection()
    try:
        db.execute("BEGIN IMMEDIATE")
        old = db.execute("SELECT slug,payload FROM drafts WHERE operation=?", (operation,)).fetchone()
        if old is not None:
            if not allow_retry or old[1] != payload:
                raise DomainError("operation already used or payload differs")
            db.commit()
            return {"slug": old[0], "draft": True, "replayed": True}
        permit = db.execute("SELECT payload,expires,used FROM approvals WHERE digest=?", (digest,)).fetchone()
        if permit is None or permit[0] != payload or permit[1] <= int(time.time()) or permit[2] != 0:
            raise DomainError("confirmation missing, expired, changed or used")
        db.execute("INSERT INTO drafts(slug,title,text,operation,payload) VALUES(?,?,?,?,?)", (slug,title,text,operation,payload))
        db.execute("UPDATE approvals SET used=1 WHERE digest=?", (digest,))
        db.commit()
        return {"slug": slug, "draft": True, "replayed": False}
    except sqlite3.IntegrityError:
        db.rollback()
        raise DomainError("draft already exists") from None
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

Статус примера: not_run. При подготовке не выполнялись учебные программы, тесты, вызовы AI или деплой. Описанные эффекты ожидаемые.

Оглавление серии · Полные исходники

Первичные документы: Источник 1, Источник 2.