Repository navigation
Unchanged JSON answers under /v1 are 304 with an ETag #919
Description
Part of #905.
Scope
A client that sends back the ETag of its last answer gets 304 Not Modified with no body when a JSON read under /v1 has not changed. The console uses it in #916; any HTTP client can.
In short
Every GET under /v1 that answers 200 with JSON gains an ETag header. A later GET of the same route with If-None-Match: <that tag> answers 304 and no body while the answer is unchanged.
flowchart LR
R[GET /v1/...] --> H[route handler] --> E["ETag step (new)"]
E -->|tag matches| N[304, no body]
E -->|else| J[200 JSON + ETag]
If it goes wrong: an event stream stalls because it is buffered to compute a tag, or a client sees an old answer.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | No /v1 answer sets ETag or Cache-Control; only console files do. |
src/http/app.ts:280 (only Location), src/http/server.ts:122-126 |
| F2 | Hono's etag() runs after the handler, only for GET/HEAD/QUERY with res.ok, hashes the body (SHA-1 via crypto.subtle, chained per 256 KiB), sets a strong "…" tag, and answers 304 keeping only cache-control, content-location, date, etag, expires, vary. |
node_modules/hono/dist/middleware/etag/index.js:1-80, digest.js |
| F3 | The app's middleware are added with app.use("*", …) in createHttpApp; the request log line reads c.res.status after next(). |
src/http/app.ts:172-212 |
| F4 | Streams answer text/event-stream: /v1/events/stream, /v1/leases/{id}/events, /v1/lease-requests/{id}/events, exec and install. |
src/http/AGENTS.md "Streams"; src/http/app.test.ts:1054, 1751, 2113 |
| F5 | Mounts (/v1/stream, agent-device) are registered with app.all after the routes. |
src/http/app.ts:762-766 |
| F6 | hono is ^4.13.5. |
package.json:75 |
| F7 | On a worker, GET /v1/workers stamps lastSeenAt with the time of each call and a per-read transitionAgeMs, so it never answers the same body twice and never gets a 304; on a gateway it changes only when a view refreshes. |
src/daemon/dispatcher.ts:902, 1080; src/gateway/worker-registry.ts:368 |
| F8 | The existing mount fake answers new Response("ok") (text/plain). |
src/http/app.test.ts:3119-3125 |
Modules touched
src/http/app.ts (the ETag step), src/http/app.test.ts, src/http/AGENTS.md (How a request runs), website/src/content/docs/docs/using-devices/remote-agents.mdx (one line on ETag/If-None-Match). Searches: ETag, If-None-Match, etag( in src/ and ui/src/: none today.
Contract and event changes
None: a response header only, no contract shape (searched src/contract/ for etag: none).
Other code on the same state
The response object: the request log middleware (app.ts:198) reads c.res.status after next(); with the ETag step inside it, a 304 logs as 304. The error boundary (app.onError) answers non-2xx, which gets no tag (F2). Searches: c.res in src/http/ (3 hits).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
| ETag step | JSON 200 | tag set, 304 on match | none |
| ETag step | stream or mount | skipped by content type and path, before the body is read | none |
| ETag step | error answer | no tag (F2) | none |
No state is written.
Rules in play
- Documentation rule 9, "a change to the public surface … updates the pages":
remote-agents.mdxgains the line. - ADR 0044 §3 "ETag": complies.
Tests
Seam: createHttpApp through app.request, modelled on src/http/app.test.ts.
- A client reads a JSON route twice, sending back the ETag: unchanged → 304, empty body, same tag; changed (a token created between reads of
/v1/tokens) → 200, new tag; stale tag → 200; a 404 answer → noETag;/v1/events/stream→ noETag, first event arrives; a mount answering 200application/json(the fake changed to JSON, F8) → noETag.
Done when
pnpm vitest run src/http/app.test.tspasses with the new test.- On a running daemon, a second
GET /v1/tokenswithIf-None-Match: <tag of the first>answers 304 (reviewer may use the test instead)./v1/workersanswers 304 only on a gateway (F7).
Out of scope
The console sending tags (#916). Cache-Control headers.
Depends on
Written by an agent.
Activity
test: the ETag test fails on a buffered stream by assertion, not by t…
fix(http): the ETag step is a * step, so no /v1/* route is registered (…
feat(http): unchanged JSON answers under /v1 are 304 with an ETag (#920)
test(console): an idle console on a 50-worker fleet stays under 40 re…