Skip to content

Send the client-side caching headers, and one ETag for the same evidence - #44

Merged
jwrosewell merged 5 commits into
rcfrom
fix/client-side-cache-headers
Nov 9, 2026
Merged

jwrosewell merged 5 commits into
rcfrom
fix/client-side-cache-headers

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

For 51Degrees/specifications#53.

The problem

The web integration specification's cache header detail asks the client-side JSON and JavaScript endpoints for Cache-Control, Vary and an ETag, and a 304 when If-None-Match matches. Specifications#53 adds that the same evidence gives the same ETag in every process, and that a response to a request whose processing recorded errors carries Cache-Control: private, no-cache and no ETag, while a matching If-None-Match still gets its 304.

Node has no web integration package, so its client-side endpoints are written in each web example, and none of them did any of this. On rc (21fa119) they set only Content-Type and the body.

The /json of IP Intelligence's on-premise mixed/gettingstarted-api stands in for the cloud service rather than being a client-side endpoint, so it is unchanged.

What the specifications say

Read in full, with RFC 9110 and RFC 9111 checked as current at rfc-editor.org (neither is obsoleted or updated).

  • The cache header detail, with specifications#53's two paragraphs, as above.
  • RFC 9110 section 13.1.2: "A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match", and the origin server "MUST respond with either a) the 304 (Not Modified) status code if the request method is GET or HEAD or b) the 412 (Precondition Failed) status code for all other request methods." The JSON endpoint is posted to, so a matching If-None-Match there gets a 412, not a 304.
  • RFC 9110 section 15.4.5: a 304 "MUST generate any of the following header fields that would have been sent in a 200 (OK) response to the same request", including ETag, Vary and Cache-Control, and "cannot contain content".
  • RFC 9110 section 8.8.3: an entity tag is a quoted string, and one whose generation "does not satisfy all of the characteristics of a strong validator" MUST be marked weak with W/. A strong validator "changes value whenever a change occurs to the representation data" (section 8.8.1). A hash of the evidence stays the same when the answer changes, for example after a data file update, so it is weak.
  • RFC 9111 section 5.2.2.4 and section 5.2.2.7, for no-cache and private, as specifications#53 cites.

The open draft 51Degrees/specifications#81 writes the RFC 9110 rules above into the cache header detail. This change follows RFC 9110 whether or not that draft is merged, as was decided for the 412 on 7 November.

What changed

Helpers.writeClientSideResponse(request, response, flowData, contentType, body, options) in fiftyone.pipeline.core writes either endpoint's response.

  • ETag. W/"<32 hex digits>", the start of a SHA-256 of the evidence the request carries, keys sorted. It is built from what addFromRequest read from the request, which addFromRequest keeps in evidence.requestEvidence, hashing only the keys an element accepts, rather than from the flow data's evidence, because processing adds evidence of its own. Evidence added by hand has no such record, so its ETag comes from the flow data's evidence. The sequence element makes a new random session id for every request without one, and with that in the hash no two responses had the same ETag, which a first attempt at the fix showed (run 42397140186). The request's precondition headers (RFC 9110 section 13.1) and cache directives (Cache-Control and Pragma, RFC 9111 sections 5.2.1 and 5.4) are left out, because they say how to answer rather than what to answer, and the JavaScript builder takes every header. key as evidence, so a revalidation's own If-None-Match would otherwise change the ETag it is checked against. Nothing in the hash is chosen per process, so the same evidence gives the same ETag in every process.
  • Cache-Control. private, max-age=1800, with the age set by options.maxAgeSeconds as the specification's "Max-age SHOULD be configurable" asks. After errors, private, no-cache.
  • Vary. The header. evidence keys of every element whose filter lists its keys, as the Java pipeline does, such as user-agent.
  • A matching If-None-Match. A GET or HEAD gets a 304 with no body and the Cache-Control, Vary and ETag a 200 would carry, which after errors means private, no-cache and no ETag. Any other method gets a 412 with no caching headers.
  • Otherwise a 200 with Content-Type, Content-Length in bytes and the body, and no body for a HEAD.

The seven endpoints above call it in place of setting the status, the content type and the body.

How it was verified

Red, the tests alone on rc (run 42397458626, at 28f0425), and green, with the fix (run 42397637050, at a1048b8). Both ran on ubuntu-latest with Node 24 from a temporary branch holding this branch plus one workflow, which has been deleted. The Device Detection cloud example ran with the organisation's Bespoke resource key.

tests/clientSideCaching.test.js in fiftyone.pipeline.core serves both endpoints from a pipeline with one element that reads the User-Agent and can be made to fail.

Test Red Green
A response carries private, max-age=1800, Vary: user-agent, a weak ETag and the right Content-Length, on both endpoints fails, 500 Helpers.writeClientSideResponse is not a function passes
The same evidence gives the same ETag in two separate Node processes, and other evidence another one, through tests/clientSideETag.js fails, no ETag passes
If-None-Match with the ETag, its strong form, a list holding it, and * each get a 304 with no body and the same ETag, Cache-Control and Vary, and another tag gets a 200 fails passes
A POST whose If-None-Match matches gets a 412 fails passes
While the element fails, a response has private, no-cache and no ETag, a matching If-None-Match still gets a 304 with private, no-cache and no ETag, and once it works again the ETag is the one it was fails passes
Red    Tests: 5 failed, 5 total
Green  Tests: 5 passed, 5 total

The Device Detection cloud gettingstarted-web test gains cloud getting started web caching headers, which asks the real example for the script, then for it again with its ETag in If-None-Match, and posts to /json. On rc it failed with Expected: "private, max-age=1800" and Received: undefined. With the fix it passed, the second request getting a 304 with the same ETag.

Red    Tests: 1 failed, 1 passed, 2 total
Green  Tests: 2 passed, 2 total

Both runs also ran the IP Intelligence cloud mixed web example test and the location tests (7 passed), every other fiftyone.pipeline.core test (234 passed on red, all 239 on green), and eslint on every changed folder, which passed. The committed types/helpers.d.ts is what npm run tsc gave in the green run.

A later fix: the ETag no longer reads the request after processing

The full build of #45, which holds this branch, failed on macOS with Node 24 (run 42418797979). The Selenium contract tests stopped the Device Detection cloud web example with TypeError: Cannot read properties of undefined (reading 'toString') at request.connection.localAddress.toString(), called from clientSideETag. The ETag was then built by reading the request again after processing, and a browser can stop waiting while the pipeline asks the cloud, which leaves the request's socket with no addresses.

Commit 6554046 adds the ETag does not read the request again after processing, which processes a request and then takes its connection away before writing the response. It failed on run 42420811997, with Tests: 1 failed, 5 passed, 6 total. Commit 1d4caad keeps every value addFromRequest reads in requestEvidence and builds the ETag from that. All six core caching tests, both example tests and all 240 core tests passed on run 42420786946.

What remains

The tests ask for the Cache-Control, Vary and weak ETag headers of the
web integration specification's cache header detail on both client-side
endpoints, the same ETag for the same evidence in two processes, a 304
for a matching If-None-Match on a GET and a 412 on a POST, as RFC 9110
section 13.1.2 requires, and private, no-cache with no ETag on a
response to a request that recorded errors, while a matching
If-None-Match still gets its 304.
Node's client-side JSON and JavaScript endpoints are written in each web
example, and none sent Cache-Control, Vary or an ETag, or answered
If-None-Match, so they had none of the web integration specification's
cache header detail.

Helpers.writeClientSideResponse writes either endpoint's response with
those headers. The ETag is a SHA-256 of the evidence the request
carries, read from the request again, so evidence processing adds, such
as the new session id the sequence element makes, takes no part, and
leaving out the request's precondition and cache headers, so that a
revalidation can match. The same evidence therefore gives the same ETag
in every process. It is weak, as RFC 9110 section 8.8.3 asks of a
validator that can stay the same when the response changes. A matching
If-None-Match gets a 304 with the same Cache-Control, Vary and ETag to a
GET or HEAD, and a 412 to any other method, as RFC 9110 sections 13.1.2
and 15.4.5 require. A response to a request whose processing recorded
errors carries Cache-Control private, no-cache and no ETag, and a
matching If-None-Match still gets its 304.

The web examples of Device Detection, IP Intelligence, location and the
pipeline's custom element example use it for every client-side endpoint.
…sing

A browser can stop waiting while the pipeline processes, and the
request's socket then has no addresses. Reading the request again for
the ETag then threw a TypeError, which stopped the Device Detection
cloud web example during the Selenium contract tests.
writeClientSideResponse read the request again for its ETag after
processing. A browser can stop waiting while the pipeline processes,
and the request's socket then has no addresses, so the read threw a
TypeError. In the Selenium contract tests on macOS this stopped the
Device Detection cloud web example.

addFromRequest now keeps every value it reads, under its evidence key,
in requestEvidence, and the ETag is built from that, hashing only the
evidence an element accepts, so it no longer touches the request.
Evidence added by hand has no such record, so its ETag is built from
the flow data's evidence.
@jwrosewell
jwrosewell marked this pull request as ready for review November 9, 2026 04:18
@jwrosewell
jwrosewell merged commit f5c7325 into rc Nov 9, 2026
2 checks passed
@jwrosewell
jwrosewell deleted the fix/client-side-cache-headers branch November 9, 2026 04:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant