К содержанию
TelegaFirst

Для AI-агентов: markdown этой страницы — /docs/etag-and-idempotency.mdиндекс документации — /llms.txt

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

Обновлено

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

Это правила управления доступным бизнесом через Hub. Номер объекта остаётся адресом P3 внутри бизнеса, а права определяются credential, scopes и текущими полномочиями. Идентификаторы, авторизация, права.

Чтение и 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.

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

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

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. Отправьте следующий запрос. Цена, деньги, отправка сообщений и публикация этим телом не запрашиваются.

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 возвращает зафиксированный результат:

{
  "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"
}
  1. Если ответ потерялся, повторите исходный PATCH с catalog-change, исходным If-Match и тем же телом. Каталог сначала ищет квитанцию этой принятой команды: точный повтор возвращает сохранённый результат, даже если исходный ETag уже устарел. Он не запускает вторую правку и не заменяет исторический результат новым чтением.

  2. Новое намерение требует нового ключа. Если отправить тот же исходный ETag с новым ключом после успешной правки, HTTP 412 PRECONDITION_FAILED означает конфликт версии. Прочитайте товар заново, сравните изменение, затем решите, нужна ли новая правка. Не подставляйте свежий ETag вслепую.

  3. Для отмены своей правки сначала прочитайте текущий товар и выполните отдельный 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 выбирается проверенным контекстом:

{"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 объясняет этот 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 или актуальные ограниченияПроверить авторизацию и права

В отказе 412 может присутствовать current_etag. Это подсказка для нового чтения, а не согласие на перезапись. RFC9457 problem response использует code и traceId; при обращении в поддержку передайте их без credential и личных данных. Поля ошибки зависят от конкретного пути отказа, поэтому не считайте current_etag обязательным в каждом конфликте. Справочник ошибок.

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

{
  "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.

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

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