Skip to content

Set the client-side cache headers, with an ETag that is the same in every process and none after errors - #59

Draft
jwrosewell wants to merge 6 commits into
rcfrom
fix/client-side-etag
Draft

jwrosewell wants to merge 6 commits into
rcfrom
fix/client-side-etag

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

For specifications#53, the client-side caching change decided on 5 November.

The problem

The cache header detail of the web integration feature says the JSON and JavaScript endpoints MUST set Cache-Control: max-age=1800 private, Vary, an ETag that hashes the Evidence, Content-Type and Content-Length, and answer a matching If-None-Match with a 304. specifications#53 adds that the same Evidence MUST give the same ETag in every process running the same build, and that a response to a request whose processing recorded errors MUST carry Cache-Control: private, no-cache and no ETag, while a request whose If-None-Match matches still gets its 304.

Python has no web integration package. The client-side endpoints are written in each Flask example, and none of them set any cache header. The places that build them are:

The one shared module is fiftyone_pipeline_core/web.py, which the examples already use for webevidence and set_response_header.

The change

fiftyone_pipeline_core.web gains javascript_response(pipeline, request) and json_response(pipeline, request), which process the request and answer with the JavaScriptBuilder's JavaScript or the JSONBundler's JSON. Everything else is private to the module.

  • The ETag is a SHA-256 hash of the names and values of the request's evidence that one of the pipeline's elements accepts, as a quoted string. Python's hash() of a string is seeded afresh in each process, so it is not used.
  • The ETag is worked out from the request's evidence before processing, because the sequence element gives a request with no session id a new one during processing, which would make every ETag different.
  • If-None-Match is compared with the weak comparison RFC 9110 section 13.1.2 asks for, so W/ is ignored, a list matches when any member does, and * matches. A match on a GET or HEAD is answered 304 without processing, with the ETag, Cache-Control and Vary a 200 would have carried, as section 15.4.5 requires, and no Content-Type. Python's endpoints set no CORS headers on a 200, so the 304 has none either. A match on any other method, being the POST to the JSON endpoint, is answered 412 Precondition Failed with no body and no ETag, as section 13.1.2 requires. James decided on 7 November to follow RFC 9110 here in every language, rather than the specification's older text, which gave both endpoints a 304.
  • Otherwise the response carries Content-Type (application/x-javascript or application/json), Content-Length from Flask, and Cache-Control: max-age=1800, private with the ETag, or Cache-Control: private, no-cache and no ETag when flowdata.errors is not empty.
  • Vary names the request's headers that one of the pipeline's elements accepts.
  • The headers a browser adds when it revalidates or reloads (If-None-Match, If-Modified-Since, If-Match, If-Unmodified-Since, If-Range, Cache-Control, Pragma) are left out of the ETag and of Vary. An element whose filter accepts every key would otherwise put If-None-Match in the ETag, and a revalidation would never match the response it revalidates. Accept only the evidence each element reads, and none by default #63 narrows the four default elements that did, so a default on-premise pipeline no longer accepts these headers. Accept only the evidence each element reads, and none by default #63 also has the cloud engines accept nothing. The list stays, because FlowElement's default filter still accepts every key, so a pipeline holding an element that does not set its own filter, such as a customer's, would still break revalidation without it.
  • The flow data is closed when the response is built.

The five example routes now call these two functions. Their comments say what the call does.

How to verify

Red is rc at 0507eef with only the tests added (d4e82a1), and green is this branch at b1a6754. Both ran the same temporary workflow on Ubuntu and Windows with Python 3.12, running tests/test_web_client_side.py, tests/test_web.py and tests/test_javascriptbuilder.py in pipeline/fiftyone_pipeline_core and tests/test_cloud_mixed_web_offline.py in the pipeline examples, which runs the mixed example against a stand-in for the cloud service.

  1. Red (run 42394701136, failure on both). The examples' endpoints send only two headers today.

    E        +      where Headers([('Content-Type', 'application/x-javascript'), ('Content-Length', '12777')]) = <WrapperTestResponse streamed [200 OK]>.headers
    E       AssertionError: assert None == 'max-age=1800, private'
    E       AssertionError: assert None == 'private, no-cache'
    E       AssertionError: a good response carries an ETag
    E       AssertionError: ['None', 'None']
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_good_response_may_be_kept_by_the_browser_alone[/51Degrees.core.js]
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_good_response_may_be_kept_by_the_browser_alone[/json]
    FAILED tests/test_cloud_mixed_web_offline.py::test_the_etag_is_the_same_in_every_process
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_response_to_a_failed_request_carries_no_etag[/51Degrees.core.js]
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_response_to_a_failed_request_carries_no_etag[/json]
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_good_copy_is_kept_while_processing_fails[/51Degrees.core.js]
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_good_copy_is_kept_while_processing_fails[/json]
    =================== 7 failed, 11 passed, 3 warnings in 1.82s ===================
    

    The core tests could not be collected, with ImportError: cannot import name 'javascript_response' from 'fiftyone_pipeline_core.web'.

  2. Green (run 42395023618, success on both).

    === pipeline/fiftyone_pipeline_core: exit 0
    ======================== 24 passed, 2 warnings in 1.53s ========================
    === pipeline/fiftyone_pipeline_cloudrequestengine/examples: exit 0
    ======================== 18 passed, 3 warnings in 1.76s ========================
    

    Windows gave the same counts.

  3. The 412 for a POST. Red is this branch at b1a6754 with only the tests changed (4c36e9a), and green is 8e8f285, on the same workflow (red run 42416498061, failure on Ubuntu and Windows, green run 42416617410, success on both).

    E       AssertionError: 412 != 304
    FAILED tests/test_web_client_side.py::ClientSideCacheTests::test_a_post_with_a_matching_etag_is_refused
    E       assert 304 == 412
    FAILED tests/test_cloud_mixed_web_offline.py::test_a_matching_etag_is_answered_while_processing_fails[/json-412]
    

    Green gave 25 passed in the core tests and 18 in the examples on both. The core test of the 304 now sends the revalidation as a GET and as a HEAD and checks that its ETag, Cache-Control and Vary are those of the good response. The full build in step 3 ran before this change.

  4. The web request value. The usage sharing change (Accept no evidence for usage sharing, and share what it finds in the flow data and the web request #61) has webevidence() add the request itself as server.http-request, a read-only view of every header, If-None-Match included. The JavaScript builder accepts every key (Three elements the pipeline builder adds accept every evidence key #52), so hashing it would give a revalidation a different ETag. _uses() now leaves it out. Red is 300a081's parent dcde28f, green 300a081 (red run 42418120887, AssertionError: 304 != 200 on Ubuntu and Windows, green run 42418163568, 26 passed in the core tests and 18 in the examples on both).

The tests check each point of the specification for both endpoints. The cross-process tests run the endpoint in two new Python processes with PYTHONHASHSEED set to 1 and to 2 and compare the ETags. The failure tests use an element, or a stand-in cloud service, that fails while a flag is set, so the same evidence is processed first with and then without errors.

  1. The full pull request build. common-ci's nightly-pull-request.yml run as a dry run on this pull request over all 15 entries in ci/options.json (run 42400101270, success, 17 jobs passed and the two performance comparison jobs skipped). On Ubuntu with Python 3.12 (job) the pipeline core unit tests gave 294 passed, the Device Detection examples 31 passed, the pipeline examples 38 passed and 13 skipped (the licence key half), location 3 passed, and the Selenium contract tests passed 9 of 9 against the Device Detection cloud web example and 9 of 9 against the on-premise one, both now serving /51Degrees.core.js and /json through the new functions.

For James's review

  1. Vary is wide with an element that keeps the default filter. The specification asks for every header in the Pipeline's evidence key filter. Before Accept only the evidence each element reads, and none by default #63 every default pipeline accepted every key, so Vary named every header the request carried. With Accept only the evidence each element reads, and none by default #63 a default pipeline names only the headers its engines read and Host (Vary: Host, User-Agent in Accept only the evidence each element reads, and none by default #63's check with this merged in). A pipeline holding an element that does not set its own filter still names every header, because FlowElement's default filter accepts every key. That is safe, because the browser only reuses its copy when they all match.

What remains

  • fiftyone_pipeline_core gains two public functions, so it is released with this change and so are the packages that depend on it.

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