Conditional requests
Which resources carry an ETag, what it is derived from, and how to send one back.
The read resources carry an ETag and answer a conditional request. Read the
validator off the response, send it back as If-None-Match, and a representation
you already hold costs you a round trip and no body.
| Resource | Carries ETag |
|---|---|
GET /api/pages |
Yes |
GET /api/pages/{path} |
Yes |
GET /api/query-schema.json |
Yes |
GET /api/openapi.json |
Yes |
GET /.well-known/api-catalog |
Yes |
GET /api/collections |
Yes |
GET /api/site-pages |
Yes |
GET /api/tags |
Yes |
GET /api/problems/{slug} |
Yes |
QUERY /api/pages |
No — see below |
GET /api/feedback |
No |
The generated reference documents the 304 on every one of them, and a test
compares this table against that document — so a resource gaining or losing a
validator fails naming itself rather than leaving this table quietly wrong.
Writing this page is what found the two the document was missing:
/api/pages/{path} and /api/problems/{slug} both set a validator and always
answered a conditional request, and the document had never said so.
How to use one
curl -sS -D- https://lateano.com/api/collections
# ETag: "…"
curl -sS -D- -H 'If-None-Match: "…"' https://lateano.com/api/collections
# HTTP/2 304
A 304 carries ETag and Cache-Control and no body. It means the
representation you already have is still current.
What the validator is derived from
The commit these bytes were built from, and the corpus that was baked into them —
the same two facts /version.json publishes, described under
Versioning, so a client comparing a validator
against that marker is comparing the same things rather than two derivations of
them.
That makes these strong validators. The corpus is fetched during the build and emitted into the bundle, and nothing reads a content store at request time, so two deployments built from one commit and one corpus answer byte-identically. The validator can therefore promise byte equality, which is what a strong validator claims.
A build that cannot stamp itself carries no ETag at all. Both stamps have to
be real. If either is missing the resource omits the header rather than emitting
a constant tag — a tag that cannot tell two representations apart would answer
304 for something that has since changed, which is the one failure worth
spending a round trip to avoid. A local or fixtures build is the usual case.
Why the query surface cannot have one
QUERY /api/pages carries the question in a request body, so two callers sending
different bodies to that one URL receive different answers. An ETag identifies
a representation of a resource, and a resource with more than one has nothing
for a single validator to name.
The GET spelling beside it does have one, and that is the reason to prefer
it: the whole question lives in the URL, so the address has exactly one
representation. Reach for GET /api/pages where it can spell your question, and
fall back to the QUERY grammar only for what it cannot — a boolean tree, a
batch, a lookup by slug, or a pasted passage.
About the 412
The catalogue carries a precondition-failed type, and RFC 9110 § 13.1.2 is why:
a failed If-None-Match on a method that is not GET or HEAD takes 412
rather than 304.
You cannot currently provoke it. The non-retrieval surfaces that reach the
precondition — QUERY /api/pages and the feedback writes — deliberately carry
no validator, so there is nothing for a failed condition to be false against. The
other non-retrieval method every resource answers is OPTIONS, and it is
answered before any conditional header is read: RFC 9110 § 13.2.1 says a server
“MUST ignore the conditional request header fields … when received with a request
method that does not involve the selection or modification of a selected
representation”, and OPTIONS is named there.
The arm exists so that the day a write gains a validator, the status is already
right, rather than a 304 being sent where a client would misread it.