ETag and If-Match: How to Avoid Overwriting Stock During Dual API Updates
A practical guide to protecting APIs from stale records: two clients read the same product, the first updates the stock, the second receives a 412 and retries the update only after a new GET.

In this article
Если два клиента прочитали один товар, а затем отправили полные обновления, поздний запрос может вернуть старое значение поля, которое уже изменил первый клиент. Если 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 |

Если убрать проверку версии, третий шаг запишет тело 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 связывает его с ложным условием на текущее состояние ресурса. Пауза ничего не исправит: старый тег не станет новым сам по себе. Клиенту нужен свежий снимок и бизнес-решение.
Получить текущее представление и новый ETag.
Сравнить поля старого снимка, локального изменения и текущего ресурса.
Объединить только те поля, для которых заранее определено совместимое правило.
Показать конфликт человеку или остановить пакет, если оба участника меняли один и тот же бизнес-факт.
Отправить новый запрос с 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 500 и остатком 10; получить сильный ETag двумя клиентами.
Изменить остаток первым клиентом и подтвердить новый ETag.
Отправить старый полный снимок вторым клиентом; ожидать 412 и неизменный остаток 8.
Повторить старый запрос без паузы и после паузы; состояние не должно измениться.
Проверить запрос без If-Match, со слабым тегом и с If-Match: * согласно фактическому контракту API.
Перечитать ресурс, применить разрешённое правило слияния и отправить новый тег; ожидать цену 1 400 и остаток 8.
Одновременно запустить два обновления одного поля и доказать по финальному состоянию и журналу, что конфликт не был скрыт.
Считайте проверку пройденной не по двум зелёным HTTP-ответам, а по сохранённым бизнес-фактам. После отказа 412 старое тело не должно попасть в хранилище, новый GET должен вернуть фактическое состояние, а повтор возможен только с новым тегом и понятным правилом.
Границы примера
Учебный сервер намеренно строг: любой отсутствующий, слабый или устаревший If-Match получает 412. Это проверяемая модель, а не обещание поведения конкретного API. Она не показывает авторизацию, валидацию полей, транзакцию основной БД, кэширование, прокси и распределённые блокировки.
Перед внедрением проверьте документацию и тестовый контур выбранного сервиса. Если API не выдаёт сильный ETag для изменяемого ресурса, нужен другой документированный номер версии, условие в теле запроса или серверная проверка конкурентного обновления. Просто добавить произвольный заголовок клиентом не получится: защищать запись обязан сервер.
If two clients read the same product and then send full updates, the later request might return an old field value that the first client already changed. If the API provides a strong ETag, the client includes it in the If-Match header during a PUT or other mutating request. A mismatched tag blocks the write: the client receives a 412, re-reads the resource, and only then decides whether to merge the changes.
In a simple example, both clients read a price of 1,500 rubles and a stock of 10. Client A updates the stock to 8. Client B still holds the old snapshot and wants to change the price to 1,400 rubles. A standard full PUT from B could inadvertently restore the stock to 10 alongside the new price. A conditional request prevents this silent overwrite.
What ETag and If-Match Actually Check
In RFC 9110, If-Match is described as a condition for mutating methods: the server compares the received entity tag with the current one and checks the condition before executing the method. Strong comparison is used for If-Match because the client wants to prevent the write if the representation has changed in any way.
An ETag is an opaque identifier for a specific resource representation. Clients must not extract version numbers or timestamps from it.
A strong tag looks like "v2". A weak tag starts with W/ and does not match during a strong If-Match comparison.
If-Match: "v2" means: perform the update only if the current representation still has this tag.
A 412 response means the provided condition is false. This is not a command to retry the same request after a few seconds.
Two Clients and One Stale Snapshot
State changes in this order: the second write succeeds only after a new GET and conscious merging. In this educational scenario, field ownership is predefined: A manages stock, B manages price. Without such a rule, automatic merging would be guessing with good posture.
Step | Request | Result | Current state |
|---|---|---|---|
1 | GET A and GET B | both receive ETag "v1" | price 1 500; remaining 10 |
2 | PUT A with If-Match "v1" | 200; new ETag "v2" | price 1 500; remaining 8 |
3 | PUT B with old If-Match "v1" | 412; record not updated | price 1 500; remaining 8 |
4 | GET B, then PUT with If-Match "v2" | 200; new ETag "v3" | price 1 400; remaining 8 |

If the version check is removed, the third step writes body B entirely: the price changes, and the stock reverts from 8 to 10. The server responds with success, although one confirmed fact is already lost.
Reproducible local testbed
Verification performed on October 16, 2026, on CPython 3.12.14 using only the standard library. The server listens on a random port of the local loopback interface, does not access the external network, and stores a single learning record in memory. After completion, the process stops; the example is not connected to a live store.
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
The process completed with code 0. Eight observations were verified: identical v1 for two clients, successful update A, rejection of old B, stock preservation, rejection of requests without If-Match, rejection of weak tags, successful merge by v2, and repeated rejection of old v1 after v3 appeared.
Why 412 cannot be blindly retried
Description of status 412 in RFC 9110 links it to a false condition on the current resource state. A pause fixes nothing: the old tag will not become new on its own. The client needs a fresh snapshot and a business decision.
Retrieve the current representation and the new ETag.
Compare fields from the old snapshot, the local change, and the current resource.
Merge only those fields for which a compatible rule was defined in advance.
Show the conflict to a human or stop the package if both parties modified the same business fact.
Send a new request with If-Match based on the re-read representation.
Edge cases that change the result
If-Match is missing. In the test server, the modifying request is rejected with 412. In a real API, the header requirement must be part of the contract.
W/"v2" arrived. A weak tag fails a strong If-Match comparison; the example confirms the 412 rejection.
If-Match: * was used. This checks for the existence of the current representation but does not protect against overwriting a newer version.
The response to the successful PUT was lost. The RFC allows separate behavior if the server can prove the change was already applied; check the specific contract rather than assuming.
The ETag changes due to a different representation or serialization method. The client stores the tag only for the specific resource and response variant to which the update applies.
There is a catch: ETag protects the HTTP request boundary, but it does not decide who owns the price, stock, or description. It detects a state mismatch; the conflict resolution rule remains part of the integration.
How to apply this to a store integration
For 1C-Bitrix: Site Management, this mechanism is appropriate only where the actual external API documents ETag and If-Match for mutating operations. Do not assume that any REST method or exchange with 1C supports conditional writes. First, record the product, API version, resource, method, permissions, and the exact response contract for 412.
If the dispute concerns the quantity of goods, first determine where the final stock count resides. Version checking does not turn two independent systems into two equal sources of truth.
Contract clause | What to document before development |
|---|---|
Resource | product, product variant, or stock balance |
Representation | which fields are included in the version and when the ETag changes |
Method | PUT, PATCH, or another documented mutating request |
Condition | whether If-Match is required and whether tag lists are accepted |
Conflict | 412, error body, new ETag, and the need to repeat the GET |
Merge | which fields can be merged automatically and which require stopping |
Log | Object ID, old and new tags, result without secrets and personal data |
Conditional write and idempotency of repeated webhooks solve different problems. Idempotency protects a single business outcome from repeated delivery, while If-Match prevents applying a change to an unexpected version of the resource. A reliable integration may require both mechanisms.
Acceptance protocol
Create an anonymized product or placeholder with a price of 1,500 and a stock of 10; obtain a strong ETag with two clients.
Change the balance with the first client and confirm the new ETag.
Send the old full snapshot with the second client; expect 412 and an unchanged balance of 8.
Repeat the old request without a pause and after a pause; the state must not change.
Check the request without If-Match, with a weak tag, and with If-Match: * according to the actual API contract.
Re-read the resource, apply the allowed merge rule, and send a new tag; expect a price of 1,400 and a balance of 8.
Run two updates of the same field simultaneously and prove by the final state and log that the conflict was not hidden.
Consider the check passed not by two green HTTP responses, but by preserved business facts. After a 412 refusal, the old body must not enter storage; a new GET must return the actual state, and a retry is possible only with a new tag and a clear rule.
Example boundaries
The test server is intentionally strict: any missing, weak, or stale If-Match receives a 412. This is a verifiable model, not a promise of behavior for a specific API. It does not show authorization, field validation, main database transactions, caching, proxies, or distributed locks.
Before deployment, verify the documentation and test environment of the selected service. If the API does not provide a strong ETag for a mutable resource, you need another documented version number, a condition in the request body, or server-side concurrent update checking. Simply adding an arbitrary header from the client will not work: the server must protect the record.




Discussion 0
Share your experience and ask questions. Comments without links appear after editorial review.
No comments yet. Start the discussion.