# ETag и безопасный повтор запросов

Source: <https://telegafirst.com/docs/etag-and-idempotency>
Locale: ru
releaseGitSha: c9a428d902de0b479a981e43774f38b9ec077ed7
sourceContentDigest: 6fd9912360d55f1f7be9ca6b35647a07b3e2e586abf0324f792cb42876966ca9
Version: 1

Перед изменением прочитайте объект и сохраните его ETag. Для одного намерения создайте один `Idempotency-Key`; при потерянном ответе повторите исходный запрос с этим же ключом и телом. ETag защищает от правки устаревшей версии, а ключ отличает повтор принятой команды от новой команды.

Это правила управления доступным бизнесом через Hub. Номер объекта остаётся адресом P3 внутри бизнеса, а права определяются credential, scopes и текущими полномочиями. [Идентификаторы](https://telegafirst.com/docs/identifiers-and-pagination), [авторизация](https://telegafirst.com/docs/authorization), [права](https://telegafirst.com/docs/scopes-and-permissions).

## Чтение и conditional GET

В деталях каталога ETag находится в HTTP header `ETag` и в `editable_config.etag`. Передавайте значение целиком, включая `W/` и кавычки. Не вычисляйте его самостоятельно по времени или названию. Список `editable_config.editable_fields` показывает разрешённые поля; наличие поля в read-ответе ещё не разрешает его изменять.

Для повторного чтения detail используйте `If-None-Match` с сохранённым ETag. На этом маршруте совпадение возвращает HTTP `304` с пустым телом; клиент сохраняет предыдущий объект. Отсутствие совпадения возвращает обычный detail и актуальный ETag. Такой polling не меняет объект и не требует idempotency key.

```http
GET <HUB_BASE_URL>/api/v1/catalog/7?zone=ru HTTP/1.1
X-Api-Key: <API_KEY>
If-None-Match: W/"57f60dd745c6591b"
```

При совпадении версии:

```http
HTTP/1.1 304 Not Modified
ETag: W/"57f60dd745c6591b"
```

`If-None-Match` для чтения и `If-Match` для изменения имеют разные задачи. Не переносите это поведение на маршруты, в которых conditional headers не предусмотрены.

## Пример: изменить название товара и повторить команду

Результат — новое название одного товара без изменения цены, активности или публичности. Нужны доступный бизнес, подключённый бот и разрешённая операция каталога: `catalog:read` для чтения и `catalog:write` для изменения. REST PATCH каталога использует API-key guard; Bearer здесь должен быть credential, поддерживаемым этим guard, а не произвольной customer/surface сессией. Для своего агента используйте фактически доступный typed MCP tool и его schema. Способ входа не даёт дополнительных прав.

`<HUB_BASE_URL>` — адрес вашего Hub без завершающего `/`, `<API_KEY>` — локальный секрет. Предполагается, что вы выбрали предназначенный для правки товар №7 и прочитали его в `zone=ru`. Ниже использованы обезличенные значения boundary fixture; это описание контракта, а не подтверждение живого сервера или разрешение редактировать чужой товар.

1. Прочитайте `GET /api/v1/catalog/7?zone=ru`, проверьте бизнес, название и `editable_config.editable_fields`.
2. Для намерения «переименовать в Changed» сохраните исходный ETag `W/"57f60dd745c6591b"`, тело и ключ `catalog-change`.
3. Отправьте следующий запрос. Цена, деньги, отправка сообщений и публикация этим телом не запрашиваются.

```http
PATCH <HUB_BASE_URL>/api/v1/catalog/7?zone=ru HTTP/1.1
X-Api-Key: <API_KEY>
Content-Type: application/json
If-Match: W/"57f60dd745c6591b"
Idempotency-Key: catalog-change

{"title":"Changed"}
```

Успех, HTTP `200`, header `ETag: W/"bc0efc7576cb13b2"`. Полный detail из сохранённой квитанции команды одинаков при первом успехе и точном повторе. Текущие `available_providers` доступны при GET, а PATCH возвращает зафиксированный результат:

```json
{
  "seq_id":7,
  "title":"Changed",
  "title_alt":"Course EN",
  "description":"Frozen course",
  "description_alt":"Frozen EN",
  "is_active":true,
  "is_public":false,
  "is_free":false,
  "groups":[],
  "editable_config":{
    "etag":"W/\"bc0efc7576cb13b2\"",
    "editable_fields":["title","title_alt","description","mediaRefs","description_alt","is_active","is_public","prices","item_type","sku_code","measurement_unit","requires_postal_code","booking","trial","variants","ru_vat_rate","ru_price_includes_tax","intl_tax_rate_percent","intl_price_includes_tax"]
  },
  "prices":[
    {"currency":"RUB","amount":"125.50","trial_amount":null},
    {"currency":"USD","amount":"2.50","trial_amount":null},
    {"currency":"EUR","amount":null,"trial_amount":null}
  ],
  "item_type":"product",
  "sku_code":null,
  "measurement_unit":"шт",
  "requires_postal_code":false,
  "booking":{"enabled":false,"mode":"hourly","duration_value":null,"buffer_minutes":0,"resource_seq_nums":[12]},
  "trial":null,
  "deeplink_code":"ProductCode12345",
  "ru_vat_rate":null,
  "ru_price_includes_tax":null,
  "intl_tax_rate_percent":null,
  "intl_price_includes_tax":null,
  "variants":null,
  "updated_at":"2026-10-01T10:01:00.000Z"
}
```

4. Если ответ потерялся, повторите исходный PATCH с `catalog-change`, исходным `If-Match` и тем же телом. Каталог сначала ищет квитанцию этой принятой команды: точный повтор возвращает сохранённый результат, даже если исходный ETag уже устарел. Он не запускает вторую правку и не заменяет исторический результат новым чтением.
5. Новое намерение требует нового ключа. Если отправить тот же исходный ETag с новым ключом после успешной правки, HTTP `412 PRECONDITION_FAILED` означает конфликт версии. Прочитайте товар заново, сравните изменение, затем решите, нужна ли новая правка. Не подставляйте свежий ETag вслепую.
6. Для отмены своей правки сначала прочитайте текущий товар и выполните отдельный PATCH с его ETag, новым ключом и прежним названием. Это новая условная команда; сохранённую квитанцию предыдущей команды нельзя переписать.

Каталог связывает durable replay с бизнесом, адресом товара, command key и каноническим содержимым patch. Он использует собственную квитанцию, а не общий краткоживущий HTTP cache. При использовании того же ключа для другого содержимого возвращается HTTP `422 IDEMPOTENCY_KEY_MISMATCH`. Даже если пустое тело допустимо как no-op, оно проверяет ETag и не создаёт новую durable команду для replay. Ключ каталога имеет предел **128 символов**; длиннее — HTTP `400 INVALID_REQUEST`.

В MCP та же подготовленная правка передаётся доступному `update_catalog_item` с `catalog:write`. Поля HTTP header становятся явными полями schema инструмента, tenant выбирается проверенным контекстом:

```json
{"seq_id":7,"zone":"ru","etag":"W/\"57f60dd745c6591b\"","idempotency_key":"catalog-change","patch":{"title":"Changed"}}
```

Ответ использует тот же detail каталога. Для самого tool call бизнес-ошибка передаётся как `isError:true`, а не как обещанный HTTP `412` на transport: [MCP guide](https://telegafirst.com/docs/mcp-guide) объясняет этот envelope. Правило нового ключа для нового намерения и неизменного ключа для точного повтора сохраняется.

## Ошибки условной правки каталога

| HTTP / код                                         | Когда                                                                                          | Восстановление                                                                                                                     |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `428 PRECONDITION_REQUIRED`                        | Нет непустого `If-Match` либо отсутствует `Idempotency-Key`                                    | Прочитать detail и передать оба header                                                                                             |
| `412 PRECONDITION_FAILED`                          | Версия устарела и точного replay принятой команды нет                                          | Прочитать текущее состояние, сравнить и принять новое решение                                                                      |
| `422 IDEMPOTENCY_KEY_MISMATCH`                     | Принятая идентичность команды повторена с другим patch                                         | Разделить исходный повтор и новое намерение                                                                                        |
| `422 CATALOG_FIELD_READ_ONLY`                      | Непосредственный вызов сервиса содержит недопустимое/read-only поле                            | Использовать разрешённые поля и schema; REST body pipe также может отказать с `400`                                                |
| `422 VALIDATION_ERROR`                             | Правила канонического writer отклонили patch, а внутренний код не входит в публичный ErrorCode | Исправить payload по фактическому сообщению                                                                                        |
| `409 VARIANT_KEY_IMMUTABLE` / `VARIANT_KEY_REUSED` | Нарушена идентичность варианта                                                                 | Сохранить ключ существующего варианта и исправить набор                                                                            |
| `401` / `403`                                      | Не пройдены вход, scopes или актуальные ограничения                                            | Проверить [авторизацию](https://telegafirst.com/docs/authorization) и [права](https://telegafirst.com/docs/scopes-and-permissions) |

В отказе `412` может присутствовать `current_etag`. Это подсказка для нового чтения, а не согласие на перезапись. RFC9457 problem response использует `code` и `traceId`; при обращении в поддержку передайте их без credential и личных данных. Поля ошибки зависят от конкретного пути отказа, поэтому не считайте `current_etag` обязательным в каждом конфликте. [Справочник ошибок](https://telegafirst.com/docs/errors).

Полный контрактный пример отказа PATCH без `If-Match`, HTTP `428`, `Content-Type: application/problem+json`. Route-параметр в `instance` показан так, как он записан в boundary fixture:

```json
{
  "type":"https://telegafirst.ru/docs/errors#PRECONDITION_REQUIRED",
  "title":"Precondition Required",
  "status":428,
  "instance":"/api/v1/catalog/{seq_id}",
  "code":"PRECONDITION_REQUIRED",
  "traceId":"0123456789abcdef0123456789abcdef",
  "detail":"If-Match header is required for PATCH",
  "hint":{
    "reason":"This write is guarded by optimistic concurrency and needs the version it is based on.",
    "recovery":"Read the object first and send the write with its ETag in the If-Match header."
  }
}
```

## Общая HTTP идемпотентность имеет собственное окно

Общий idempotency interceptor требует ключ для `POST` с непустым телом, если маршрут явно не исключён; отсутствие ключа в этом случае даёт HTTP `400 IDEMPOTENCY_KEY_REQUIRED`. Переданный ключ включает общий cache и для остальных поддерживаемых запросов на не исключённых маршрутах: `PATCH`, `PUT`, `DELETE` и `POST` без непустого тела. Дополнительные обязательные требования к ключу определяет контракт конкретной операции, как у каталога с его `428 PRECONDITION_REQUIRED`. Квитанция общего REST cache хранится **24 часа**. В этом окне завершённый запрос повторяет сохранённые body и HTTP status; для некоторых терминальных `4xx` сохраняется и отказ. `429` и `5xx` не считаются таким завершённым replay и допускают предусмотренную повторную попытку.

Пока запрос выполняется, повтор с его ключом получает `409 CONFLICT`. Изменённое тело общего запроса под прежним ключом даёт `422 IDEMPOTENCY_KEY_MISMATCH`. У создания заказа и storefront checkout применяется собственная канонизация запроса: несовпадение даёт `409 IDEMPOTENCY_CONFLICT`. Поэтому «все конфликты — 409» и «все записи — один cache» неверны. У маршрутов корзины собственная проверка версии может возвращать `428 ETAG_REQUIRED`; не заменяйте её каталоговым кодом.

Общий ключ привязан к проверенному бизнесу, актору и операции; копирование строки ключа в другой бизнес не является адресом старого результата. Scope, доступность, принадлежность объекта и необходимые route preconditions продолжают проверяться до исполнения или replay. После истечения общего окна повтор не обещает возврат прежней квитанции: сначала выясните результат, особенно если операция могла иметь внешний эффект. GET-чтение, catalog durable replay и MCP-вызовы не наследуют автоматически этот 24-часовой cache.

Для примера ответа формы из [идентификаторов](https://telegafirst.com/docs/identifiers-and-pagination) повтор REST POST с `form-1042` возвращает тот же `seq_num:1042` и `submission_code`, пока квитанция доступна. Новая отправка с новым ключом создаёт новый ответ, даже если посетитель ещё anonymous. Привязка ранее созданного ответа к пользователю не меняет его номер и код.

## Денежная операция требует собственного решения человека

Свой агент клиента через scoped Hub готовит продажу или счёт парой `prepare_manual_sale` → `execute_manual_sale` либо `prepare_send_invoice` → `execute_send_invoice`. Человек проверяет подготовленный результат и подтверждает исполнение; idempotency key не заменяет этот шаг. Используйте реальные scopes и текущую schema typed tool, не придумывайте credential provenance или фиктивное приложение. Внутренние разговорные AI-агенты только советуют и передают вопрос оператору; они не выполняют денежную мутацию. [Продажи](https://telegafirst.com/docs/catalog-and-sales), [платежи](https://telegafirst.com/docs/payments).

Успех проверки replay — сохранённый результат одной команды; успех новой правки — ожидаемое изменение после чтения свежей версии. Ни одно из этих правил не даёт права автоматически повторять продажу, отправку счёта, рассылку или оплату при неясном исходе.
