Условительные API-запросы, которые предотвращают перезапись данных агентами
Условительные API-запросы защищают обновления, выполняемые агентами, от перезаписи устаревшими данными. Узнайте об ETag, полях версии, If-Match, ошибках 412 и безопасных правилах повторных попыток.

Агент может отправить совершенно корректный API-запрос и все равно нанести реальный ущерб. Ошибка возникает, когда он читает запись, другой участник меняет ее, а затем агент записывает поверх нового состояния свою старую копию. Аутентификация это не останавливает. Авторизация тоже. Запрос пришел от разрешенного субъекта, но содержал устаревшее представление реальности.
Условительные API-запросы устраняют именно такую проблему. Клиент фактически говорит: «примените это изменение, только если ресурс все еще имеет ту версию, которую я видел». Сервер проверяет это условие как часть записи. Если оно ложно, сервер отклоняет действие до внесения каких-либо изменений.
Для агентов, которые программируют, этот договор важнее, чем для человека, нажимающего кнопки в форме. Агент может прочитать множество ресурсов, остановиться, чтобы изучить код или запустить тесты, а затем отправить серию записей, когда мир уже изменился. Считайте каждое существенное обновление операцией чтение-изменение-запись, если только API не может доказать, что это добавление или коммутативная команда.
Потеря обновлений в обычных операциях чтение-изменение-запись
Потеря обновления происходит, когда два автора начинают работу с одного старого состояния, а более поздняя запись стирает результат более ранней. Для этого не нужны сбой базы данных, злоумышленник или неисправная сеть. Достаточно сервера, который принимает безусловную замену.
Представьте конфигурацию развертывания в формате JSON:
{
"name": "billing-worker",
"replicas": 3,
"image": "registry.example/billing:2.4.0",
"maintenanceMode": false
}
Агент читает ее, чтобы увеличить replicas с 3 до 5 перед нагрузочным тестом. Пока он работает, оператор меняет maintenanceMode на true, чтобы разобраться с проблемой очереди. Если агент позже отправит полный PUT с сохраненным документом, он может вернуть maintenanceMode в false. Число реплик изменится как задумано. Но вместе с этим будет отменено важное решение по безопасности, которого агент не видел.
Частичное обновление уменьшает масштаб возможного ущерба, но не устраняет гонку. Если агент отправляет PATCH для замены /replicas, это поле все равно могло измениться после чтения. Более того, решение установить 5 реплик может зависеть от других полей, которые тоже изменились. PATCH описывает форму тела запроса. Он не сообщает, на какую версию ресурса это тело рассчитано.
Поэтому утверждение «наш интерфейс меняет только одно поле» не является схемой контроля параллельности. Интерфейс может какое-то время скрывать проблему, потому что люди действуют медленно и видят свежие страницы. У автономного процесса таких случайных защит нет.
ETag обозначает представление, увиденное клиентом
Заголовок ответа ETag - это HTTP-валидатор. Когда сервер возвращает представление ресурса, он может добавить токен, который обозначает его версию:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "deploy-8f31c2"
{
"name": "billing-worker",
"replicas": 3,
"image": "registry.example/billing:2.4.0",
"maintenanceMode": false
}
У этой строки нет обязательного внутреннего формата. Она может содержать ревизию базы данных, хеш содержимого или непрозрачное сгенерированное значение. Клиенты должны считать ее непрозрачной. Не разбирайте тег, чтобы узнать номер ревизии, и не создавайте его из тела JSON. Смысл значения определяет сервер.
RFC 9110 описывает теги сущностей и различает сильные и слабые теги. Сильный ETag имеет обычный синтаксис в кавычках, например "deploy-8f31c2". Он означает, что представления совпадают побайтно в рамках выбранной сервером семантики представления. Слабый тег начинается с W/, например W/"deploy-8f31c2", и означает лишь, что два представления достаточно похожи для проверки кэша.
Это различие постоянно размывают. Слабые валидаторы подходят для многих проверок кэша GET. Для защиты записи они не годятся: два «достаточно похожих» представления все равно могут различаться в поле, которое запись уничтожит. RFC 9110 требует для If-Match сильного сравнения. Если API публикует только слабые ETag, он не предоставляет ETag, подходящий для оптимистического контроля параллельности.
У одного ресурса могут быть разные ETag для разных представлений. Красиво отформатированный JSON, компактный JSON и форматы, выбранные согласованием содержимого, могут получить собственные валидаторы. Это допустимое поведение HTTP, но для клиентов API оно неудобно. По возможности используйте стабильное каноническое представление для конечных точек записи. Тогда ETag, полученный клиентом при GET, останется осмысленным для PUT, PATCH и DELETE.
If-Match превращает проверку версии в обязанность сервера
If-Match помещает ожидаемый ETag в небезопасный запрос. Сервер выполняет метод только тогда, когда текущее представление при сильном сравнении совпадает с переданным тегом.
Агент может прочитать запись и сохранить полученный заголовок:
curl -i \
-H 'Authorization: Bearer $TOKEN' \
https://api.example.test/v1/deployments/billing-worker
Ответ содержит:
ETag: "deploy-8f31c2"
Затем агент отправляет минимальное нужное изменение вместе с валидатором, полученным при чтении:
curl -i -X PATCH \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json-patch+json' \
-H 'If-Match: "deploy-8f31c2"' \
--data '[{"op":"replace","path":"/replicas","value":5}]' \
https://api.example.test/v1/deployments/billing-worker
Если ресурс все еще имеет эту версию, сервер применяет патч и возвращает новый ETag:
HTTP/1.1 200 OK
ETag: "deploy-a19d77"
Content-Type: application/json
{
"name": "billing-worker",
"replicas": 5,
"image": "registry.example/billing:2.4.0",
"maintenanceMode": false
}
Если изменение оператора первым изменило текущую версию, сервер возвращает:
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "https://api.example.test/problems/precondition-failed",
"title": "The deployment changed after it was read",
"status": 412,
"detail": "Fetch the current deployment before retrying this update."
}
RFC 9110 говорит, что исходный сервер не должен выполнять запрошенный метод, если условие If-Match оказалось ложным. Именно за это свойство вы платите. Проверка должна происходить в одной атомарной операции с изменением. Обработчик, который читает строку, сравнивает ревизию в памяти приложения, а затем позже записывает данные, все еще оставляет гонку между сравнением и записью.
Для реляционной базы данных реализация часто выглядит как условительное обновление:
UPDATE deployments
SET replicas = :replicas,
revision = revision + 1
WHERE id = :id
AND revision = :expected_revision;
Если число затронутых строк равно нулю, API возвращает 412. Если оно равно единице, API возвращает обновленный документ и получает новый ETag из новой ревизии. Размещайте проверку в предложении WHERE или используйте эквивалентную транзакционную операцию сравнения и установки. Не разделяйте ее на два отдельных запроса, называя такую схему безопасной.
Поля версии выражают тот же договор в данных приложения
Поле версии - это валидатор на уровне приложения. Оно дает клиентам видимую ревизию, которую они возвращают в теле запроса, параметре запроса или специальном заголовке. Такой подход может быть удобнее для клиентов с автоматически созданными SDK, очередей сообщений или протоколов, которые плохо сохраняют заголовки ответов HTTP.
GET может вернуть:
{
"id": "billing-worker",
"revision": 42,
"replicas": 3,
"maintenanceMode": false
}
Обновление может явно указать ожидаемую версию:
PATCH /v1/deployments/billing-worker HTTP/1.1
Content-Type: application/json
{
"expectedRevision": 42,
"replicas": 5
}
Сервер атомарно сравнивает expectedRevision с сохраненной ревизией. При успехе он увеличивает ревизию. При несовпадении отклоняет запрос с документированным ответом, обычно 412, если поле играет роль предварительного условия.
Не путайте поле версии с временной меткой. Монотонный целочисленный счетчик делает равенство однозначным. Временные метки порождают неудобные вопросы: с какой точностью сервер их хранит? Могут ли две записи попасть в один интервал точности? Не изменило ли сериализованное представление значение? Назначает ли реплика время иначе? Некоторые проблемы можно решить, но счетчик ревизий требует меньше объяснений.
ETag и поля версии не противоречат друг другу. API может предоставлять оба: ETag будет нести стандартную семантику HTTP, а ревизия поможет прикладному коду показывать или согласовывать изменения. Они должны происходить из одного зафиксированного состояния. Если один говорит о версии 42, а другой случайно относится к версии 41, у клиента не будет надежного способа восстановиться.
Не принимайте одновременно ETag и поле ревизии, если они могут расходиться. Выберите одно авторитетное предварительное условие для маршрута или требуйте, чтобы оба значения совпадали. Гибкие контракты выглядят дружелюбно, пока клиент не отправит устаревшую ревизию тела вместе со свежим скопированным заголовком и серверу не станет ясно, какому утверждению доверять.
If-None-Match защищает создание, а не замену устаревших данных
If-None-Match меняет условие на противоположное. Он говорит, что метод можно выполнить, только если текущее представление не совпадает ни с одним из переданных тегов. Для небезопасных методов ложное условие приводит к 412.
Самая полезная форма для изменения - If-None-Match: *, то есть «создать это, только если текущего представления не существует». Клиент может безопасно попытаться создать ресурс с заданным именем:
curl -i -X PUT \
-H 'Authorization: Bearer $TOKEN' \
-H 'Content-Type: application/json' \
-H 'If-None-Match: *' \
--data '{"name":"nightly-export","schedule":"0 2 * * *"}' \
https://api.example.test/v1/jobs/nightly-export
Если другой клиент уже создал эту задачу, сервер отклонит запрос, а не заменит ресурс молча. Это полезно, когда агент сам вывел идентификатор и не должен перехватывать существующий объект с тем же именем.
Не отправляйте If-Match: * для обычного оптимистического контроля параллельности. Он требует лишь наличия какого-либо текущего представления. Это дает агенту право перезаписать любую текущую версию, в том числе ту, которую он никогда не читал. Это защита существования ресурса, а не защита от потери обновлений.
Для GET и HEAD If-None-Match поддерживает кэширование. Совпавший тег обычно приводит к 304 Not Modified без тела ответа. Именно так разработчики часто впервые сталкиваются с ETag. Но не сводите валидаторы к служебным механизмам кэша. При записи тот же механизм имеет гораздо более серьезные последствия.
Ответ на устаревшую запись требует четкой политики агента
Ответ 412 должен остановить текущий план изменения. Исходная предпосылка агента оказалась неверной, и повторная отправка того же запроса не сделает ее верной.
Безопасная последовательность восстановления короткая:
- Получите текущее представление и новый валидатор.
- Сравните поля или предположения о состоянии, на которых основано действие, а не только поле, указанное в патче.
- Повторите запрос с новым валидатором, только если намерение остается правильным и не требует переосмысления.
- Запросите одобрение или остановитесь, если текущее состояние изменило смысл, масштаб или риск действия.
Именно на втором шаге автоматизированные клиенты часто пытаются схитрить. Допустим, агент планировал удалить пользователя из группы доступа после чтения списка участников. Затем человек изменил роль пользователя с подрядчика на специалиста по реагированию на инциденты. После повторного чтения агент все еще может выполнить синтаксически допустимое удаление. Но делать это автоматически не следует: изменение роли ставит исходный план под сомнение.
Храните скромную, но полную запись работы: URI ресурса, наблюдавшийся ETag или ревизию, прочитанные поля, предполагаемое изменение и ответ. Средство запуска может держать это в памяти в течение короткой задачи. Более длительный автономный процесс должен сохранять данные в собственном аудируемом состоянии задачи. Не просите модель помнить валидатор только по тексту: значения заголовков в кавычках легко потерять, изменить или применить к другому ресурсу.
Sallyport может не допускать агента к учетным данным API, пока тот выполняет HTTP-вызов, но агенту все равно нужно сохранить ETag и передать его как обычные данные запроса. Изоляция учетных данных и контроль параллельности устраняют разные проблемы, поэтому при существенных последствиях используйте оба механизма.
412, 409 и 428 описывают разные ошибки
Возвращайте 412 Precondition Failed, когда клиент передал условительный заголовок или эквивалентное документированное предварительное условие, а оно оказалось ложным. Ответ точно говорит, что ресурс больше не находится в состоянии, заявленном клиентом.
Возвращайте 428 Precondition Required, когда сервер требует предварительное условие для маршрута, а клиент его не передал. RFC 6585 определяет этот статус специально для предотвращения потери обновлений. В ответе можно указать, что PATCH требует If-Match, и включить текущий ETag, если его раскрытие не создает проблемы утечки данных.
Возвращайте 409 Conflict, когда запрос конфликтует с состоянием приложения даже после успешной проверки версии. Например, клиент может отправить совпадающий с текущим счетом If-Match, но сервер все равно отклонит отмену, потому что началось проведение платежа. Проверка версии прошла, но бизнес-команда по-прежнему конфликтует с состоянием счета.
Не сводите эти ошибки к одной общей категории. Агент должен реагировать по-разному:
- После 428 получить ресурс и повторить запрос с обязательным условием.
- После 412 снова прочитать ресурс и переоценить исходное намерение.
- После 409 изучить конфликт предметной области и следовать предусмотренному API сценарию его разрешения.
Полезное тело ошибки называет ресурс, описывает невыполненное условие без раскрытия секретов и говорит, может ли помочь новый GET. Оно не должно делать вид, что повтор безопасен. Статус HTTP дает машиночитаемую категорию, а тело предоставляет оператору достаточно контекста для решения о дальнейших действиях.
Даты последнего изменения как запасной вариант совместимости
Last-Modified и If-Unmodified-Since выражают похожее условие: выполнить метод, только если ресурс не менялся после указанной даты. Они полезны, когда старый API уже публикует время изменения, а добавление тегов потребует времени.
Для важных записей они слабее. HTTP-даты имеют точность в одну секунду. Два изменения в одну секунду могут получить одну и ту же видимую дату, а клиент может не знать, хранится ли на сервере временная метка с большей точностью, чем указано в заголовке. Репликация, часы и сериализация создают дополнительные источники неожиданностей.
Если клиент отправляет и If-Match, и If-Unmodified-Since, RFC 9110 отдает приоритет If-Match. Это разумно: сильный валидатор дает точную проверку версии, а дата остается приближением.
Не создавайте собственный заголовок X-If-Version, если только стандартные заголовки не могут решить задачу по протокольным причинам. Пользовательские заголовки быстро распространяются по SDK и прокси, а затем превращаются в постоянную работу по совместимости. У ETag и If-Match уже есть ясная семантика, известные коды статуса и поддержка обычными HTTP-инструментами.
Форматы PATCH тоже требуют собственных проверок
Условительные заголовки защищают версию ресурса. Они не проверяют, выражает ли патч безопасное преобразование. JSON Merge Patch, включающий целый вложенный объект, все равно может стереть соседние поля даже при правильном ETag. JSON Patch может обратиться не к тому индексу массива, если API моделирует упорядоченный список, состав которого изменился.
Выбирайте формат патча в соответствии с операцией. JSON Patch, определенный RFC 6902, выражает операции replace, add, remove и test над конкретными путями. Операция test может проверить значение внутри документа перед выполнением следующих операций. JSON Merge Patch, определенный RFC 7396, описывает желаемый частичный документ и трактует null как удаление.
ETag всего документа должен оставаться внешней защитой. Добавляйте test JSON Patch, когда у операции есть важная предпосылка для конкретного поля:
[
{"op":"test","path":"/maintenanceMode","value":false},
{"op":"replace","path":"/replicas","value":5}
]
Если другой автор изменил maintenanceMode до этого запроса, запрос должен завершиться ошибкой, а не увеличивать мощность во время технического обслуживания. API должен документировать ошибку при неудачной проверке JSON Patch. Многие реализации используют 409, потому что инструкция патча конфликтует с текущим документом, тогда как несовпадение внешнего ETag остается 412. Такое различие полезно, если клиенту нужно понять, держал ли он устаревший документ или отправил недопустимый запрос, зависящий от состояния.
Не полагайтесь только на test патча как на общую схему контроля параллельности. Он защищает лишь те пути, которые вы не забыли проверить. Сильный ETag защищает версию представления, на которой агент действительно основывал свой план.
Сервер должен проверять условие на границе записи
Контракт API, который лишь рекомендует If-Match, не выдержит давления сроков. Один клиент пропустит его, другой SDK забудет переслать, и уязвимая конечная точка станет той, которую агенты найдут по примерам. Требуйте условие для обновлений, где устаревшая перезапись имеет заметную цену.
Обработчик должен отклонить запрос без условия до выполнения побочных эффектов. Затем он должен передать ожидаемый валидатор в операцию хранилища, которая меняет состояние. Для ресурса, связанного с несколькими таблицами или внешней управляющей плоскостью, объедините сравнение и изменение состояния в одну транзакцию или используйте операцию сравнения и установки, предоставленную системой. Если система этого не умеет, API не может честно обещать защиту от потери обновлений.
Проверяйте гонку намеренно. Создайте ресурс с ревизией 7. Пусть клиенты A и B оба выполнят GET. Затем пусть A отправит PATCH с If-Match: "7" и получит ревизию 8. После этого B должен отправить PATCH с If-Match: "7", получить 412, а его предполагаемое изменение не должно появиться. Повторите тест с DELETE, полным PUT и любым массовым действием, которое изменяет ресурс на основе предварительного чтения.
Проверяйте и опасные сокращения: отсутствующий If-Match должен получать 428 на защищенных маршрутах, If-Match: * нельзя выдавать за защиту от устаревшей записи, а слабый ETag не должен проходить сильное сравнение. Эти тесты выявляют регрессии, возникающие, когда новая конечная точка обходит обычный метод репозитория.
Сделайте безопасный путь проще пути перезаписи
API должен возвращать ETag при каждом GET изменяемого ресурса, указывать обязательные условия рядом с каждой небезопасной операцией и естественным образом передавать валидаторы в методах SDK. Клиенту не должно требоваться извлекать сырые заголовки из малоизвестного объекта ответа, чтобы не уничтожить работу другого автора.
Для агентов разделяйте планирование и выполнение. Прочитайте цель, сохраните ее валидатор, опишите предполагаемое изменение и отправьте условительный вызов. Если какое-либо наблюдение изменилось, откажитесь от запланированной записи, если только агент не может показать, что изменение не имеет значения. Это правило звучит осторожно, потому что оно и должно быть осторожным. Альтернатива дает автоматизированному процессу право действовать на основании фактов, которые он знает как устаревшие.
Начните с конечных точек, где перезаписанное изменение может заставить кого-то срочно вмешаться: настройки развертывания, контроль доступа, данные клиентов, платежное состояние и метаданные секретов. Добавьте If-Match, сделайте отсутствие предварительного условия ошибкой и протестируйте двух авторов на одном маршруте. Когда сервер по умолчанию начнет отклонять устаревшие записи, скорость агента перестанет превращать обычную параллельность в незаметный ущерб.
Вопросы и ответы
Что такое ETag в API?
ETag - это HTTP-валидатор, который идентифицирует конкретное представление ресурса. Клиент отправляет это значение обратно в If-Match, когда хочет, чтобы сервер изменил или удалил только ту версию, которую он ранее прочитал. Если текущий ETag отличается, сервер должен отклонить запись.
Когда API следует использовать If-Match?
Используйте If-Match для обновления, замены, удаления или другой операции, которая должна применяться только к версии, проверенной клиентом. Совпавший тег разрешает выполнение метода, а несовпавший должен приводить к ответу 412 Precondition Failed. Это стандартная защита от ситуации, когда устаревший клиент незаметно выигрывает гонку записи.
Чем отличаются If-Match и If-None-Match?
If-Match предназначен для оптимистического контроля параллельности, а If-None-Match обычно предотвращает создание ресурса или позволяет не передавать неизменившееся представление. Для небезопасного запроса If-None-Match: * означает «выполнить это, только если текущего ресурса не существует». Не используйте If-None-Match вместо защиты операции чтение-изменение-запись.
Что должен делать агент после ответа 412?
Ответ 412 Precondition Failed означает, что условие HTTP-запроса оказалось ложным. Клиенту следует получить текущее представление, сравнить его с предполагаемым изменением и решить, нужно ли повторить запрос, объединить изменения или обратиться к человеку. Повтор той же устаревшей попытки лишь снова приведет к ошибке.
Можно ли использовать поле версии вместо ETag?
Поле версии подходит, если сервер атомарно сравнивает его с сохраненной ревизией во время записи. Разработчикам часто проще читать и анализировать такое поле, чем непрозрачный ETag. Оно не заменяет ETag, когда нужны стандартные условные HTTP-семантики для универсальных клиентов и кэшей.
Безопасен ли If-Unmodified-Since для контроля параллельности?
Обычно нет. If-Unmodified-Since опирается на временные метки, у которых может быть недостаточная точность или непредсказуемое поведение часов. Это полезный запасной вариант для совместимости, но для важных записей надежнее использовать сильный ETag или атомарное поле версии.
Возвращать 409 или 412 для устаревшего обновления?
409 Conflict сообщает о конфликте бизнес-логики или состояния, который клиент должен понять, например о попытке закрыть счет с неоплаченным счетом. 412 Precondition Failed означает, что явное HTTP-условие оказалось ложным. Используйте оба кода для разных ошибок, вместо того чтобы превращать каждый отклоненный запрос в 409.
Предотвращает ли If-Match со звездочкой потерю обновлений?
If-Match: * означает, что подходит любое существующее представление. Это защищает только от обновления несуществующего ресурса и не предотвращает перезапись более новой версии. Когда нужно сохранить изменения другого участника, отправляйте точное значение ETag.
Должна ли каждая конечная точка PATCH требовать ETag?
Требуйте предварительные условия для конечных точек, где устаревшая запись может изменить деньги, права доступа, состояние развертывания, данные клиента или конфигурацию. Если клиент не передал обязательное условие, сервер может вернуть 428 Precondition Required. Не навязывайте его без необходимости командам, которые только добавляют данные и не имеют гонки чтение-изменение-запись.
Как AI-агенту безопасно хранить версии API?
Агент должен связать ETag или ревизию с тем представлением, которое он действительно прочитал, отправить это значение в следующей защищенной записи и удалить его после ошибки проверки условия. Нельзя придумывать тег, повторно использовать тег другого ресурса или автоматически разрешать смысловой конфликт перезаписью текущего состояния. Для короткой задачи достаточно временной записи с URL ресурса, полученным валидатором, предполагаемыми полями и ответом.