DEV Community

Odinaka Robert Nnamani
Odinaka Robert Nnamani

Posted on Originally published at robertpaschal.medium.com

If-Match: two curls, one 412

You know that feeling. Two tabs. Same note. You save in one, then save in the other, and the first version is just gone. Last write wins. That is the default. It feels fine until it is your sentence that vanished.

You can watch it happen on a laptop. Two terminals. No cloud. Copy this into if-match-server.mjs and run node if-match-server.mjs.

import http from "node:http";

let note = { text: "hello" };
let etag = "1";

http.createServer((req, res) => {
  const reply = (status, body) => {
    const json = JSON.stringify(body);
    res.writeHead(status, {
      "Content-Type": "application/json",
      ETag: `"${etag}"`,
      "Content-Length": Buffer.byteLength(json),
    });
    res.end(json);
  };

  if (req.url !== "/note") return reply(404, { error: "not found" });
  if (req.method === "GET") return reply(200, { text: note.text, etag });
  if (req.method !== "PUT") return reply(405, { error: "method not allowed" });

  let raw = "";
  req.on("data", (c) => (raw += c));
  req.on("end", () => {
    const ifMatch = req.headers["if-match"];
    if (ifMatch && ifMatch !== `"${etag}"`) {
      return reply(412, { error: "Precondition Failed" });
    }
    const body = JSON.parse(raw || "{}");
    note = { text: body.text ?? note.text };
    etag = String(Number(etag) + 1);
    reply(200, { text: note.text, etag });
  });
}).listen(3456, "127.0.0.1", () => {
  console.log("http://127.0.0.1:3456/note");
});
Enter fullscreen mode Exit fullscreen mode

One note, at /note. A GET hands you the JSON and an ETag. An ETag is just the version token on that response. A PUT with no extra header overwrites. That overwrite is the whole problem.

I fetched it first.

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "1"
Content-Length: 27

{"text":"hello","etag":"1"}
Enter fullscreen mode Exit fullscreen mode

Call this tab one. It still has hello and ETag "1". You start rewriting. You have not saved yet.

Meanwhile tab two hits save. No extra header. Just a PUT.

curl -i -X PUT http://127.0.0.1:3456/note \
  -H 'Content-Type: application/json' \
  -d '{"text":"from tab two"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34

{"text":"from tab two","etag":"2"}
Enter fullscreen mode Exit fullscreen mode

GET it again and hello is gone.

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34

{"text":"from tab two","etag":"2"}
Enter fullscreen mode Exit fullscreen mode

Tab one still shows hello on screen. The server does not. If tab one saves now, it will wipe tab two the same way. That is the feeling. You thought you were editing. You were erasing.

Tab one still has ETag "1" in its head. Send that token with the PUT. The header is called If-Match. You are saying: only write if this is still the version I saw.

curl -i -X PUT http://127.0.0.1:3456/note \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "1"' \
  -d '{"text":"from tab one"}'
Enter fullscreen mode Exit fullscreen mode

I ran that. This came back:

HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "2"
Content-Length: 31

{"error":"Precondition Failed"}
Enter fullscreen mode Exit fullscreen mode

GET it. Still from tab two. Your stale save did not land.

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34

{"text":"from tab two","etag":"2"}
Enter fullscreen mode Exit fullscreen mode

That 412 is the point. The server refused you because the note had moved on. Now send the ETag the last GET actually returned.

curl -i -X PUT http://127.0.0.1:3456/note \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "2"' \
  -d '{"text":"from tab two, edited"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "3"
Content-Length: 42

{"text":"from tab two, edited","etag":"3"}
Enter fullscreen mode Exit fullscreen mode

This time it wrote. The version became "3". So the rule, now that you have seen both: If-Match means only do this if the resource still matches this ETag. Comparison is strong, byte-for-byte. A weak tag like W/"1" never matches it. And this is not If-None-Match. That one is the cache shortcut that returns 304. Do not mix them.

Leave the header off and last write wins. Send a stale one and you get 412, and the body stays put. Send the current one and you get 200, and the version moves.

You GET, then you PUT with that ETag. If someone else wrote in between, you see 412 instead of a silent overwrite.

Here is the check I ran. I replayed If-Match: "2" after the ETag was already "3".

curl -i -X PUT http://127.0.0.1:3456/note \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "2"' \
  -d '{"text":"should not land"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "3"
Content-Length: 31

{"error":"Precondition Failed"}
Enter fullscreen mode Exit fullscreen mode

Then GET:

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "3"
Content-Length: 42

{"text":"from tab two, edited","etag":"3"}
Enter fullscreen mode Exit fullscreen mode

should not land is not in the body. The old token did not rewind the note. If your server is already at "3", stop it and run node if-match-server.mjs again. GET. PUT with the current ETag. Then replay the previous one. You should see 412. The text should stay on the current sentence.

After that first 412, is the note still from tab two?

Originally on The Developer Codes.


Have you ever lost work to a silent overwrite? How do you handle concurrent edits — ETags, versioning, something else? Genuinely curious how others approach this.


I write about building, writing, and becoming — subscribe: https://robertpaschal.substack.com

Top comments (3)

Collapse
 
dalbaeb123321 profile image
Dalbaeb •

good

Collapse
 
dalbaeb123321 profile image
Dalbaeb •
Collapse
 
dalbaeb123321 profile image
Dalbaeb •