跳到正文
DEV Community· Jitsumi·· 51 分鐘前AI 評分57

用 If-Match 防止過期編輯器覆蓋變更

Stop Stale Editors Overwriting Changes with If-Match

AI 導讀

Jitsumi 説明如何在 HTTP API 以強 ETag 配合 If-Match,只有編輯版本仍為最新時才接受更新,版本不符則通常回傳 412 Precondition Failed。資料庫應以版本條件執行更新,避免檢查版本與寫入之間出現競爭。遇到 412 時,應保留未儲存的變更、載入最新文件並呈現差異,而非更換 ETag 後重送舊文件。

正文

Two people open the same settings page. One changes the title; the other saves a description a minute later. Both receive a successful response, but the second save restores the old title.

The second person did not deliberately undo anything. Their editor submitted an old copy of the document. If your API accepts every replacement unconditionally, the most recent request wins even when it contains older information.

For an HTTP API, a useful defence is a strong ETag paired with If-Match. The client sends the version it edited; the server applies the change only if that version is still current. A mismatch normally produces 412 Precondition Failed, giving the editor a chance to reconcile instead of silently overwriting.

Turn the version into a save condition

An illustrative read might return:

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "settings-17"

{"title":"Release notes","description":"Weekly changes"}

Keep the quoted validator with the loaded document. When saving, send it unchanged:

PUT /settings HTTP/1.1
Content-Type: application/json
If-Match: "settings-17"

{"title":"Release notes","description":"Changes every Friday"}

If another editor has already produced version 18, the condition is false. RFC 9110's If-Match rules define this conditional request behaviour. The header uses strong comparison: a weak tag such as W/"settings-17" cannot satisfy it. See Mozilla's If-Match reference.

Treat the ETag as an opaque value. Clients should not increment its number or reconstruct it from a timestamp. The server owns its meaning, including which representation it identifies.

Reproduce the conflict without an HTTP server

This small model demonstrates the save rule. Save it as conditional-save.mjs and run node conditional-save.mjs with a current Node.js release. It uses only the built-in assertion module:

import assert from 'node:assert/strict';

let version = 17;
let document = { title: 'Release notes', description: 'Weekly changes' };
const tag = () => `"settings-${version}"`;
const read = () => ({ document: structuredClone(document), etag: tag() });

function save(replacement, ifMatch) {
  // No await between comparing and updating this in-memory model.
  if (ifMatch !== tag()) return { status: 412 };
  document = structuredClone(replacement);
  version += 1;
  return { status: 200, etag: tag() };
}

const alice = read();
const ben = read();
alice.document.title = 'Engineering release notes';
assert.equal(save(alice.document, alice.etag).status, 200);

ben.document.description = 'Changes every Friday';
assert.equal(save(ben.document, ben.etag).status, 412);
assert.equal(read().document.title, 'Engineering release notes');
assert.equal(read().document.description, 'Weekly changes');
console.log('Stale save rejected; the first edit remains.');

Ben's description is still in his local editor. It was never accepted by the server. That is the point at which the interface should offer a comparison, not announce that everything has been saved.

The database must enforce the same boundary

A server implementation can still lose updates if it checks the version, yields to another request, and later writes without a condition. Comparing the tag in one database query and performing an unconditional update in another recreates the race.

For a versioned row, the core operation might be:

UPDATE settings
SET title = :title,
    description = :description,
    version = version + 1
WHERE id = :id AND version = :expected_version;

Interpret the affected-row count and distinguish a missing resource from a stale version according to your API contract. Authenticate and authorise the caller independently. For an ETag representing several rows, the transaction must cover every change contributing to that representation.

This SQL is an implementation sketch, not a complete HTTP validator parser. A production endpoint must also handle required-header policy, validation, representation variants and its database's transaction behaviour.

A conflict is different from an uncertain retry

On 412, keep the unsaved changes, fetch the latest document and show what changed. Automatically replacing the ETag and resubmitting the old whole document defeats the protection. A field-by-field merge can help, but overlapping edits still need a deliberate decision.

If-Match: * is not equivalent to a version-specific tag: it checks for an existing representation, not whether the editor saw the current one.

Also separate concurrency from transport uncertainty. If a response disappears after a successful save, the caller needs to discover the outcome; retry design is a separate concern. Jitsumi's explanation of API idempotency and uncertain retries works through that boundary.

For caching, the related validator question is usually whether a stored representation can be reused. Jitsumi's ETag and Cache-Control example covers that read path. The save path here asks a different question: is this still the version the editor is entitled to replace?

This article was prepared with AI assistance.

來源:DEV Community · dev.to