Every record or page you read or write comes with an ETag header. Send it back on your next request. With If-None-Match, a read returns no body when nothing changed. With If-Match, a write is refused when someone else changed the record first.
In the examples, <your-url> is the address you sign in to plnnrly on, such as acme.plnnrly.com, or your organisation's own domain.
ETag
There are two kinds of tag, and they answer different questions.
| You read | The tag | Send it back as | Never as |
|---|---|---|---|
| One record, or the response to a write | Strong, such as "projects-12-t1791995400-3fa1" | If-Match on a later write, or If-None-Match on a re-read | No restriction. Use the header that fits the request. |
| A page of a collection | Weak, such as W | If-None-Match on a re-read of the same page | If-Match. A page is not a record. |
A tag comes with every 200 or 201 that returns a record or a page, with a 304 and with a 412. A 204 from a DELETE has none, and neither does any other error.
Treat a tag as opaque. Store it exactly as you received it, quotes and W/ prefix included, and send it back unchanged. Do not parse it, and do not build one yourself.
Re-read only what changed: If-None-Match
Send the tag you hold. When nothing changed, the answer is 304 Not Modified with no body.
curl -i "https://<your-url>/api/v2/projects/12" \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
-H 'If-None-Match: "projects-12-t1791995400-3fa1"'- Repeat the exact query you used before. A record's tag also depends on the
viewandincludeyou asked for: change either and you get a full200. Other parameters, such asmeta=actions, do not change the tag, so a304does not tell you whethermeta.actionschanged. - A page's tag is weak and covers the whole page as it was rendered. Send it back with its
W/prefix. - A
304still uses one unit of your rate limit, whatevervieworincludeyou asked for. On a poller it is the cheapest way to ask "has this changed?".
Refuse a stale write: If-Match
Every PATCH, every DELETE and every action POST accepts If-Match. Send the tag of the version you based your change on. When the record changed since, nothing is written.
curl -X PATCH "https://<your-url>/api/v2/projects/12" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H 'If-Match: "projects-12-t1791995400-3fa1"' \
-d '{
"status": "on-hold"
}'The 200 carries the record's new tag. Keep it for your next write.
If-Matchis never required. Without it, the write goes ahead whatever changed in the meantime.If-Matchcompares versions, not query shapes. A tag from anyvieworincludeof the current version is accepted.If-Match: *matches any version, so it protects nothing. Send the real tag.
When the record changed: 412
Here a work item moved on to version 12 while you still held version 11:
curl -X PATCH "https://<your-url>/api/v2/work-items/812" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H 'If-Match: "work-items-812-v11-8c2e"' \
-d '{
"title": "Fix the CSV import"
}'Nothing is written, and the answer is 412 precondition-failed. Its ETag header carries the current tag, "work-items-812-v12-8c2e", and the body gives the current version number:
{
"type": "https://developers.plnnrly.com/api/v2/problems/precondition-failed",
"title": "Precondition failed",
"status": 412,
"detail": "The record changed since the version named in If-Match. Re-read it and retry.",
"instance": "/api/v2/work-items/812",
"request_id": "01M4ZDAAR0K7Q3XW9B2NTC5H8E",
"current_version": 12
}
current_version is a number for work items only. For every other resource it is null, because those records have no version counter.
To recover:
- Re-read the record, unless you already hold a copy with the tag from the
412. - Re-apply your change to what is there now, and check that it still makes sense.
- Retry with the new tag.
Never resend the same write with the tag from the 412 without looking first. That overwrites exactly the change the lock caught.
How strong is the lock
- Work items carry a version counter,
version, so a write based on an older version is caught. Two writes sent at the same moment with the same tag can still both go through. - Every other resource is versioned by the second it was last changed. That is best effort: two writes in the same second share a tag, so
If-Matchcannot tell them apart. If two writers can change the same record within one second, coordinate those writes in your own code. - A tag tracks the record itself, not the rows you included. A change to an included record does not change the tag, so a
304on a read withincludecan hide it. Read that record directly when you need it fresh.
Which header you want
| You want to say | Header |
|---|---|
| "Don't create this twice if my retry is a duplicate." | Idempotency-Key on the POST |
| "Don't let me overwrite a change I haven't seen." | If-Match on the PATCH, DELETE or action POST |
| "Don't send me a body I already have." | If-None-Match on the GET |
They are independent and combine. An action POST can carry both an Idempotency-Key and an If-Match.
Related
- Idempotency: safe retries for
POST. - Actions and state changes:
If-Matchon actions. - Keeping a copy in sync: polling with
If-None-Match. - Errors and retries: every problem type.