Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья11 мин чтенияПросмотры0

ETag и If-Match: как не затереть остаток при двух обновлениях API

Практический guide по защите API от устаревшей записи: два клиента читают один товар, первый меняет остаток, второй получает 412 и повторяет обновление только после нового GET.

Комментарии 0

Два сканера направлены на одну посылку: конкурентные обновления товара требуют проверки версии
В этой статье

Если два клиента прочитали один товар, а затем отправили полные обновления, поздний запрос может вернуть старое значение поля, которое уже изменил первый клиент. Если API выдаёт сильный ETag, клиент передаёт его в If-Match при PUT или другом изменяющем запросе. Несовпавший тег останавливает запись: клиент получает 412, перечитывает ресурс и только затем решает, можно ли объединить изменения.

На маленьком примере оба клиента читают цену 1 500 рублей и остаток 10. Клиент A меняет остаток на 8. Клиент B всё ещё держит старый снимок и хочет изменить цену на 1 400 рублей. Обычный полный PUT от B мог бы вместе с новой ценой вернуть остаток 10. Условный запрос не даёт сделать это молча.

Что именно проверяют ETag и If-Match

В RFC 9110 If-Match описан как условие для изменяющих методов: сервер сравнивает полученный entity tag с текущим и проверяет условие до выполнения метода. Для If-Match применяется сильное сравнение, потому что клиент хочет остановить запись при любом изменении представления.

  • ETag — непрозрачный идентификатор конкретного представления ресурса. Клиент не должен извлекать из него номер версии или время.

  • Сильный тег выглядит, например, как "v2". Слабый тег начинается с W/ и не совпадает при сильном сравнении If-Match.

  • If-Match: "v2" означает: выполнить изменение только если текущее представление всё ещё имеет этот тег.

  • Ответ 412 означает, что переданное условие ложно. Это не команда повторить тот же запрос через несколько секунд.

Два клиента и один старый снимок

Состояние меняется в таком порядке. Вторая запись проходит только после нового GET и осознанного объединения. В учебном сценарии владельцы полей заранее определены: A отвечает за остаток, B — за цену. Без такого правила автоматическое слияние было бы гаданием с хорошей осанкой.

Шаг

Запрос

Результат

Текущее состояние

1

GET A и GET B

оба получают ETag "v1"

цена 1 500; остаток 10

2

PUT A с If-Match "v1"

200; новый ETag "v2"

цена 1 500; остаток 8

3

PUT B со старым If-Match "v1"

412; запись не выполнена

цена 1 500; остаток 8

4

GET B, затем PUT с If-Match "v2"

200; новый ETag "v3"

цена 1 400; остаток 8

Два клиента получают ETag v1: первый обновляет остаток и создаёт v2, второй со старым тегом получает 412, затем перечитывает ресурс и создаёт v3
Учебная последовательность: устаревший If-Match останавливает запись, а новый PUT строится только после повторного GET и проверки полей.

Если убрать проверку версии, третий шаг запишет тело B целиком: цена изменится, а остаток вернётся с 8 на 10. Сервер ответит успехом, хотя один подтверждённый факт уже потерян.

Воспроизводимый локальный стенд

Проверка выполнена 16 октября 2026 года на CPython 3.12.14 только со стандартной библиотекой. Сервер слушает случайный порт локального loopback-интерфейса, не обращается во внешнюю сеть и хранит одну учебную запись в памяти. После завершения процесс останавливается; к рабочему магазину пример не подключается.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from urllib.error import HTTPError from urllib.request import Request, urlopen import json state = {"price": 1500, "stock": 10, "version": 1} def etag() -> str: return f'"v{state["version"]}"' class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: body = json.dumps( {"price": state["price"], "stock": state["stock"]} ).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("ETag", etag()) self.end_headers() self.wfile.write(body) def do_PUT(self) -> None: expected = self.headers.get("If-Match") if expected != etag(): self.send_response(412) self.send_header("ETag", etag()) self.end_headers() return length = int(self.headers["Content-Length"]) payload = json.loads(self.rfile.read(length)) state["price"] = payload["price"] state["stock"] = payload["stock"] state["version"] += 1 body = json.dumps( {"price": state["price"], "stock": state["stock"]} ).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("ETag", etag()) self.end_headers() self.wfile.write(body) def log_message(self, format: str, *args: object) -> None: pass

def get(url: str) -> tuple[dict, str]: with urlopen(url) as response: return json.load(response), response.headers["ETag"] def put(url: str, payload: dict, tag: str | None) -> tuple[dict, str]: headers = {"Content-Type": "application/json"} if tag is not None: headers["If-Match"] = tag request = Request( url, data=json.dumps(payload).encode(), method="PUT", headers=headers, ) with urlopen(request) as response: return json.load(response), response.headers["ETag"]

server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() url = f"http://127.0.0.1:{server.server_port}/product/42" try: client_a, tag_a = get(url) client_b, tag_b = get(url) assert client_a == client_b == {"price": 1500, "stock": 10} assert tag_a == tag_b == '"v1"' updated_a, tag_v2 = put(url, {"price": 1500, "stock": 8}, tag_a) assert updated_a == {"price": 1500, "stock": 8} assert tag_v2 == '"v2"' try: put(url, {"price": 1400, "stock": 10}, tag_b) except HTTPError as error: assert error.code == 412 assert error.headers["ETag"] == '"v2"' else: raise AssertionError("stale update was accepted") current, current_tag = get(url) assert current == {"price": 1500, "stock": 8} assert current_tag == '"v2"' for invalid_tag in (None, 'W/"v2"'): try: put(url, {"price": 1400, "stock": 8}, invalid_tag) except HTTPError as error: assert error.code == 412 assert error.headers["ETag"] == '"v2"' else: raise AssertionError("invalid precondition was accepted") merged, tag_v3 = put(url, {"price": 1400, "stock": 8}, current_tag) assert merged == {"price": 1400, "stock": 8} assert tag_v3 == '"v3"' try: put(url, {"price": 1400, "stock": 10}, tag_b) except HTTPError as error: assert error.code == 412 assert error.headers["ETag"] == '"v3"' else: raise AssertionError("repeated stale update was accepted") print('GET A/B -> {"price": 1500, "stock": 10}, ETag "v1"') print('PUT A If-Match "v1" -> 200, stock 8, ETag "v2"') print('PUT B If-Match "v1" -> 412, state unchanged') print('GET B -> {"price": 1500, "stock": 8}, ETag "v2"') print('PUT without If-Match -> 412, state unchanged') print('PUT If-Match W/"v2" -> 412, state unchanged') print('PUT B If-Match "v2" -> 200, price 1400, stock 8, ETag "v3"') print('repeat stale If-Match "v1" -> 412, state unchanged') finally: server.shutdown() server.server_close()

GET A/B -> {"price": 1500, "stock": 10}, ETag "v1" PUT A If-Match "v1" -> 200, stock 8, ETag "v2" PUT B If-Match "v1" -> 412, state unchanged GET B -> {"price": 1500, "stock": 8, ETag "v2" PUT without If-Match -> 412, state unchanged PUT If-Match W/"v2" -> 412, state unchanged PUT B If-Match "v2" -> 200, price 1400, stock 8, ETag "v3" repeat stale If-Match "v1" -> 412, state unchanged

Процесс завершился с кодом 0. Проверены восемь наблюдений: одинаковый v1 у двух клиентов, успешное обновление A, отказ старому B, сохранность остатка, отказ запросу без If-Match, отказ слабому тегу, успешное объединение по v2 и повторный отказ старому v1 после появления v3.

Почему 412 нельзя повторять вслепую

Описание статуса 412 в RFC 9110 связывает его с ложным условием на текущее состояние ресурса. Пауза ничего не исправит: старый тег не станет новым сам по себе. Клиенту нужен свежий снимок и бизнес-решение.

  1. Получить текущее представление и новый ETag.

  2. Сравнить поля старого снимка, локального изменения и текущего ресурса.

  3. Объединить только те поля, для которых заранее определено совместимое правило.

  4. Показать конфликт человеку или остановить пакет, если оба участника меняли один и тот же бизнес-факт.

  5. Отправить новый запрос с If-Match от перечитанного представления.

Крайние случаи, которые меняют результат

  • If-Match отсутствует. В учебном сервере изменяющий запрос отклоняется с 412. В реальном API обязательность заголовка должна быть частью контракта.

  • Пришёл W/"v2". Слабый тег не проходит сильное сравнение If-Match; пример подтверждает отказ 412.

  • Использован If-Match: *. Это проверяет существование текущего представления, но не защищает от записи поверх более новой версии.

  • Ответ на успешный PUT потерян. RFC допускает отдельное поведение, если сервер может доказать, что изменение уже применено; конкретный контракт нужно проверить, а не додумывать.

  • ETag меняется из-за другого представления или способа сериализации. Клиент хранит тег ровно для того ресурса и варианта ответа, к которому относится обновление.

Тут есть подвох: ETag защищает границу HTTP-запроса, но не решает, кто владеет ценой, остатком или описанием. Он обнаруживает несовпадение состояния; правило выхода из конфликта остаётся частью интеграции.

Как применить к интеграции магазина

Для 1С-Битрикс: Управление сайтом этот механизм уместен только там, где фактический внешний API документирует ETag и If-Match для изменяющей операции. Нельзя считать, что любой REST-метод или обмен с 1С поддерживает условную запись. Сначала фиксируют продукт, версию API, ресурс, метод, права и точный контракт ответа 412.

Если спор возник именно вокруг количества товара, сначала нужно определить где живёт окончательная цифра остатка. Версионная проверка не превращает две независимые системы в два равноправных источника истины.

Пункт контракта

Что зафиксировать до разработки

Ресурс

товар, торговое предложение или складской остаток

Представление

какие поля входят в версию и когда меняется ETag

Метод

PUT, PATCH или другой документированный изменяющий запрос

Условие

обязателен ли If-Match и принимаются ли списки тегов

Конфликт

412, тело ошибки, новый ETag и необходимость повторного GET

Слияние

какие поля можно объединять автоматически, а какие требуют остановки

Журнал

ID объекта, старый и новый тег, результат без секретов и персональных данных

Условная запись и идемпотентность повторного webhook решают разные задачи. Идемпотентность защищает один бизнес-эффект от повторной доставки, а If-Match не даёт применить изменение к неожиданной версии ресурса. В надёжной интеграции могут понадобиться оба механизма.

Протокол приёмки

  1. Создать обезличенный товар или заглушку с ценой 1 500 и остатком 10; получить сильный ETag двумя клиентами.

  2. Изменить остаток первым клиентом и подтвердить новый ETag.

  3. Отправить старый полный снимок вторым клиентом; ожидать 412 и неизменный остаток 8.

  4. Повторить старый запрос без паузы и после паузы; состояние не должно измениться.

  5. Проверить запрос без If-Match, со слабым тегом и с If-Match: * согласно фактическому контракту API.

  6. Перечитать ресурс, применить разрешённое правило слияния и отправить новый тег; ожидать цену 1 400 и остаток 8.

  7. Одновременно запустить два обновления одного поля и доказать по финальному состоянию и журналу, что конфликт не был скрыт.

Считайте проверку пройденной не по двум зелёным HTTP-ответам, а по сохранённым бизнес-фактам. После отказа 412 старое тело не должно попасть в хранилище, новый GET должен вернуть фактическое состояние, а повтор возможен только с новым тегом и понятным правилом.

Границы примера

Учебный сервер намеренно строг: любой отсутствующий, слабый или устаревший If-Match получает 412. Это проверяемая модель, а не обещание поведения конкретного API. Она не показывает авторизацию, валидацию полей, транзакцию основной БД, кэширование, прокси и распределённые блокировки.

Перед внедрением проверьте документацию и тестовый контур выбранного сервиса. Если API не выдаёт сильный ETag для изменяемого ресурса, нужен другой документированный номер версии, условие в теле запроса или серверная проверка конкурентного обновления. Просто добавить произвольный заголовок клиентом не получится: защищать запись обязан сервер.

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.