Skip to content

HEAD /query answers an empty page with 404 where POST /query answers 200 [] #304

Description

@thehabes

Summary

queryHeadRequest() (controllers/history.js:86) treats an empty result page as "there are no objects in the database matching the query" (controllers/history.js:99-101) and returns 404. POST /query returns 200 [] for the same request.

POST /query?limit=2 {"type":"NoSuchTypeAtAll"}    -> 200, []
HEAD /query?limit=2 {"type":"NoSuchTypeAtAll"}    -> 404

POST /query?limit=2&skip=100 {"type":"Thing"}     -> 200, []   (29 matches, so this is past the end)
HEAD /query?limit=2&skip=100 {"type":"Thing"}     -> 404

The second pair is the one that matters for paging: an exhausted page and a query with no matches are indistinguishable to HEAD, and both differ from what POST reports.

A HEAD response is defined to carry the headers its GET/POST counterpart would send. A 404 against a 200 is not that.

Why this matters

The one endpoint that could cheaply tell a client where a result set ends signals it with a status code the paged endpoint never uses. A client cannot use HEAD to probe ahead of POST without special-casing the disagreement, which defeats the purpose of probing.

HEAD /query is not a count and is easily mistaken for one. It returns the Content-Length of the page it would have sent, subject to the same clamping: limit=2 gives 11832, limit=100 gives 68396, and limit=1000 gives 949640 — the byte size of the clamped 500-record page. It says nothing about how many records match. Past the skip maximum it reports the same Content-Length for the same repeated page on every deployment (1883 local, 1891 devstore, 1404 store), so it inherits the paging bug too.

It is the only paged endpoint left out of the contract work. HEAD /query shares getPagination() with everything else (controllers/history.js:89), so it inherits the validation and header changes automatically. The status-code mismatch is the one thing that does not fall out of the shared fix, which is why it needs its own issue.

Evidence

Verified 2026-09-02 and re-run 2026-09-03, read-only, against localhost:3001, devstore.rerum.io, and store.rerum.io. Behavior is identical on all three.

Affected lines

File Line Current
controllers/history.js 89, 91 HEAD /query paging; no total count
controllers/history.js 99-103 Returns 404 on an empty page where POST /query returns 200 []
openapi/contracts/core-provider.openapi.yaml 233-239 head: /api/query declares 200 and 404, no parameters

Proposed change

Return 200 with Content-Length: 2 — the byte length of the [] body POST would send — when the page is empty. Drop the 404 branch.

const negotiated = matches.map(o => idNegotiation(o))
const size = Buffer.byteLength(JSON.stringify(negotiated))
res.set("Content-Length", size)
res.status(200).end()

The if (matches.length) guard goes away and the same code path serves both cases, which is also what makes the two verbs agree by construction rather than by matching two branches.

Update the OpenAPI contract to drop 404 from head: /api/query and to declare the pagination parameters, alongside the rest of #305.

Resolve this alongside #96 rather than independently of it. That issue questions whether these HEAD handlers should exist at all. If the answer is that they should be removed, this fix is wasted work; if they stay, this is the change they need. Answer that first, then do one or the other.

Notes

Acceptance criteria

  • The HEAD history - since - query #96 question of whether these HEAD handlers should exist is answered before this is implemented
  • HEAD /query and POST /query return the same status for the same request body and pagination parameters, including on an empty page
  • An empty page returns 200 with Content-Length: 2
  • HEAD /query inherits the parameter validation and rel="next" behavior of the other paged endpoints
  • The OpenAPI contract for head: /api/query matches the shipped status codes and declares the pagination parameters
  • Regression tests assert status parity between the two verbs across the empty, partial, and full page cases

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions