# Условительные API-запросы, которые предотвращают перезапись данных агентами

Агент может отправить совершенно корректный API-запрос и все равно нанести реальный ущерб. Ошибка возникает, когда он читает запись, другой участник меняет ее, а затем агент записывает поверх нового состояния свою старую копию. Аутентификация это не останавливает. Авторизация тоже. Запрос пришел от разрешенного субъекта, но содержал устаревшее представление реальности.

Условительные API-запросы устраняют именно такую проблему. Клиент фактически говорит: «примените это изменение, только если ресурс все еще имеет ту версию, которую я видел». Сервер проверяет это условие как часть записи. Если оно ложно, сервер отклоняет действие до внесения каких-либо изменений.

Для агентов, которые программируют, этот договор важнее, чем для человека, нажимающего кнопки в форме. Агент может прочитать множество ресурсов, остановиться, чтобы изучить код или запустить тесты, а затем отправить серию записей, когда мир уже изменился. Считайте каждое существенное обновление операцией чтение-изменение-запись, если только API не может доказать, что это добавление или коммутативная команда.

## Потеря обновлений в обычных операциях чтение-изменение-запись

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

Представьте конфигурацию развертывания в формате JSON:

```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
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 в небезопасный запрос. Сервер выполняет метод только тогда, когда текущее представление при сильном сравнении совпадает с переданным тегом.

Агент может прочитать запись и сохранить полученный заголовок:

```bash
curl -i \
  -H 'Authorization: Bearer $TOKEN' \
  https://api.example.test/v1/deployments/billing-worker
```

Ответ содержит:

```http
ETag: "deploy-8f31c2"
```

Затем агент отправляет минимальное нужное изменение вместе с валидатором, полученным при чтении:

```bash
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
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
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` оказалось ложным. Именно за это свойство вы платите. Проверка должна происходить в одной атомарной операции с изменением. Обработчик, который читает строку, сравнивает ревизию в памяти приложения, а затем позже записывает данные, все еще оставляет гонку между сравнением и записью.

Для реляционной базы данных реализация часто выглядит как условительное обновление:

```sql
UPDATE deployments
SET replicas = :replicas,
    revision = revision + 1
WHERE id = :id
  AND revision = :expected_revision;
```

Если число затронутых строк равно нулю, API возвращает 412. Если оно равно единице, API возвращает обновленный документ и получает новый ETag из новой ревизии. Размещайте проверку в предложении `WHERE` или используйте эквивалентную транзакционную операцию сравнения и установки. Не разделяйте ее на два отдельных запроса, называя такую схему безопасной.

## Поля версии выражают тот же договор в данных приложения

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

GET может вернуть:

```json
{
  "id": "billing-worker",
  "revision": 42,
  "replicas": 3,
  "maintenanceMode": false
}
```

Обновление может явно указать ожидаемую версию:

```http
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: *`, то есть «создать это, только если текущего представления не существует». Клиент может безопасно попытаться создать ресурс с заданным именем:

```bash
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 должен остановить текущий план изменения. Исходная предпосылка агента оказалась неверной, и повторная отправка того же запроса не сделает ее верной.

Безопасная последовательность восстановления короткая:

1. Получите текущее представление и новый валидатор.
2. Сравните поля или предположения о состоянии, на которых основано действие, а не только поле, указанное в патче.
3. Повторите запрос с новым валидатором, только если намерение остается правильным и не требует переосмысления.
4. Запросите одобрение или остановитесь, если текущее состояние изменило смысл, масштаб или риск действия.

Именно на втором шаге автоматизированные клиенты часто пытаются схитрить. Допустим, агент планировал удалить пользователя из группы доступа после чтения списка участников. Затем человек изменил роль пользователя с подрядчика на специалиста по реагированию на инциденты. После повторного чтения агент все еще может выполнить синтаксически допустимое удаление. Но делать это автоматически не следует: изменение роли ставит исходный план под сомнение.

Храните скромную, но полную запись работы: 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, когда у операции есть важная предпосылка для конкретного поля:

```json
[
  {"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`, сделайте отсутствие предварительного условия ошибкой и протестируйте двух авторов на одном маршруте. Когда сервер по умолчанию начнет отклонять устаревшие записи, скорость агента перестанет превращать обычную параллельность в незаметный ущерб.
