From d4e82a180ec5e9bb20b49c784735b1d14d43d673 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 7 Nov 2026 17:07:02 +0000 Subject: [PATCH 01/14] Test the cache headers of the client-side endpoints --- .../tests/test_cloud_mixed_web_offline.py | 99 ++++++++ .../tests/test_web_client_side.py | 227 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py diff --git a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py index 47fe209e..82c092ad 100644 --- a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py +++ b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py @@ -27,6 +27,9 @@ """ import json +import os +import subprocess +import sys import pytest import requests @@ -113,6 +116,9 @@ class StubCloud: def __init__(self, sections): self.sections = sections self.requests = [] + # While set, a request for results fails as it does when the service + # cannot be reached. + self.failing = False # The Origin header of each request, kept apart from the body. self.origins = [] @@ -131,6 +137,10 @@ def request(self, method, url, data=None, headers=None): if "evidencekeys" in url: return StubResponse(["header.user-agent", "query.client-ip"]) + if self.failing: + raise requests.exceptions.ConnectionError( + "The stand-in service cannot be reached.") + # A request that names the properties it wants gets those alone, # and no section it named nothing from. named = data.get("values") @@ -302,3 +312,92 @@ def test_licence_key_gets_an_engine_only_for_what_it_asks_for(cloud): def test_licence_key_never_reaches_the_page(cloud): cloud(EVERY_SECTION) assert "a-licence-key" not in page_for(license_key="a-licence-key") + + +# The cache headers of the client-side script and the client-side callback, +# as the cache header detail of the web integration specification sets them. +DESKTOP_CHROME = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") +CACHEABLE = "max-age=1800, private" +NOT_CACHEABLE = "private, no-cache" + + +def client_side(client, path, **headers): + """The response of one of the client-side endpoints.""" + + headers.setdefault("User-Agent", DESKTOP_CHROME) + if path == "/json": + return client.post(path, headers=headers) + return client.get(path, headers=headers) + + +def example_client(): + pipeline = create_pipeline( + Logger(min_level="error"), resource_key="a-resource-key") + return create_app(pipeline).test_client() + + +def script_etag(): + """The ETag of the client-side script for a fixed request, with the + stand-in answering. Run in a process of its own by the test that + compares processes, because it replaces requests.request for good.""" + + requests.request = StubCloud(EVERY_SECTION).request + response = client_side(example_client(), "/51Degrees.core.js") + return response.headers.get("ETag") + + +@pytest.mark.parametrize("path", ["/51Degrees.core.js", "/json"]) +def test_a_good_response_may_be_kept_by_the_browser_alone(cloud, path): + cloud(EVERY_SECTION) + response = client_side(example_client(), path) + + assert response.status_code == 200 + assert response.headers.get("Cache-Control") == CACHEABLE + assert response.headers.get("ETag", "").startswith('"') + assert "User-Agent" in response.headers.get("Vary", "") + + +def test_the_etag_is_the_same_in_every_process(): + """Python seeds the hash of a string afresh in each process, so the two + processes are given different seeds.""" + + tests = os.path.dirname(os.path.abspath(__file__)) + code = ("import sys; sys.path.insert(0, sys.argv[1]); " + "from test_cloud_mixed_web_offline import script_etag; " + "print(script_etag())") + etags = [] + for seed in ("1", "2"): + finished = subprocess.run( + [sys.executable, "-c", code, tests], capture_output=True, + text=True, timeout=120, env=dict(os.environ, PYTHONHASHSEED=seed)) + assert finished.returncode == 0, finished.stderr + etags.append(finished.stdout.strip().splitlines()[-1]) + assert etags[0].startswith('"'), etags + assert etags[0] == etags[1] + + +@pytest.mark.parametrize("path", ["/51Degrees.core.js", "/json"]) +def test_a_response_to_a_failed_request_carries_no_etag(cloud, path): + stub = cloud(EVERY_SECTION) + client = example_client() + stub.failing = True + response = client_side(client, path) + + assert response.status_code == 200 + assert response.headers.get("Cache-Control") == NOT_CACHEABLE + assert "ETag" not in response.headers + + +@pytest.mark.parametrize("path", ["/51Degrees.core.js", "/json"]) +def test_a_good_copy_is_kept_while_processing_fails(cloud, path): + stub = cloud(EVERY_SECTION) + client = example_client() + etag = client_side(client, path).headers.get("ETag") + assert etag, "a good response carries an ETag" + stub.failing = True + response = client_side(client, path, **{"If-None-Match": etag}) + + assert response.status_code == 304 + assert response.headers.get("ETag") == etag diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py new file mode 100644 index 00000000..489e54e7 --- /dev/null +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -0,0 +1,227 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +"""The cache headers of the client-side JavaScript and JSON responses, as +the cache header detail of the web integration specification sets them.""" + +import os +import subprocess +import sys +import unittest + +from flask import Flask, request + +from fiftyone_pipeline_core.basiclist_evidence_keyfilter import \ + BasicListEvidenceKeyFilter +from fiftyone_pipeline_core.elementdata_dictionary import \ + ElementDataDictionary +from fiftyone_pipeline_core.flowelement import FlowElement +from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder +from fiftyone_pipeline_core.web import javascript_response, json_response + +CHROME = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36") +SAFARI = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 " + "Safari/605.1.15") + +CACHEABLE = "max-age=1800, private" +NOT_CACHEABLE = "private, no-cache" + + +class Switchable(FlowElement): + """An element that fails while failing is set, as a cloud request does + while the service cannot be reached.""" + + failing = False + + def __init__(self): + super(Switchable, self).__init__() + self.datakey = "switchable" + self.properties = {"answer": {"type": "int"}} + + def process_internal(self, flowdata): + if Switchable.failing: + raise Exception("The stand-in service cannot be reached.") + flowdata.set_element_data(ElementDataDictionary(self, {"answer": 42})) + + def get_evidence_key_filter(self): + return BasicListEvidenceKeyFilter(["header.user-agent"]) + + +def client(): + """A test client of an application serving the two client-side + endpoints.""" + + pipeline = PipelineBuilder({ + "javascript_builder_settings": {"endpoint": "/json"}, + "suppress_process_exceptions": True, + }).add(Switchable()).build() + + app = Flask(__name__) + + @app.route("/51Degrees.core.js") + def javascript(): + return javascript_response(pipeline, request) + + @app.route("/json", methods=["POST"]) + def results(): + return json_response(pipeline, request) + + return app.test_client() + + +def script_etag(): + """The ETag of the JavaScript for a fixed request. Run in a process of + its own by the test that compares processes.""" + + return client().get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}).headers["ETag"] + + +class ClientSideCacheTests(unittest.TestCase): + + def setUp(self): + Switchable.failing = False + self.client = client() + + def tearDown(self): + Switchable.failing = False + + def responses(self, headers): + """The JavaScript and the JSON responses to the same evidence.""" + return [ + self.client.get("/51Degrees.core.js", headers=headers), + self.client.post("/json", headers=headers), + ] + + def test_a_good_response_may_be_kept_by_the_browser_alone(self): + for response in self.responses({"User-Agent": CHROME}): + self.assertEqual(200, response.status_code) + self.assertEqual(CACHEABLE, response.headers.get("Cache-Control")) + etag = response.headers.get("ETag") + self.assertIsNotNone(etag, "a good response carries an ETag") + self.assertTrue(etag.startswith('"') and etag.endswith('"'), + "an ETag is a quoted string: " + etag) + self.assertIn("User-Agent", response.headers.get("Vary", "")) + + def test_the_content_type_says_which_endpoint_answered(self): + script, results = self.responses({"User-Agent": CHROME}) + self.assertEqual("application/x-javascript", + script.headers["Content-Type"]) + self.assertEqual("application/json", results.headers["Content-Type"]) + self.assertIn(b"fiftyoneDegreesManager", script.data) + self.assertEqual(42, results.get_json()["switchable"]["answer"]) + + def test_the_etag_follows_the_evidence(self): + first = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + again = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + other = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": SAFARI}) + self.assertEqual(first.headers["ETag"], again.headers["ETag"]) + self.assertNotEqual(first.headers["ETag"], other.headers["ETag"]) + + def test_the_etag_is_the_same_in_every_process(self): + """Python seeds the hash of a string afresh in each process, so the + two processes are given different seeds.""" + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + code = ("import sys; sys.path.insert(0, sys.argv[1]); " + "from tests.test_web_client_side import script_etag; " + "print(script_etag())") + etags = [] + for seed in ("1", "2"): + finished = subprocess.run( + [sys.executable, "-c", code, root], capture_output=True, + text=True, cwd=root, timeout=120, + env=dict(os.environ, PYTHONHASHSEED=seed)) + self.assertEqual(0, finished.returncode, finished.stderr) + etags.append(finished.stdout.strip().splitlines()[-1]) + self.assertEqual(etags[0], etags[1]) + self.assertEqual(script_etag(), etags[0]) + + def test_revalidation_with_the_etag_is_answered_not_modified(self): + for path, method in (("/51Degrees.core.js", self.client.get), + ("/json", self.client.post)): + etag = method(path, headers={"User-Agent": CHROME}) \ + .headers["ETag"] + # A browser that revalidates sends Cache-Control as well when + # the page is reloaded, and neither header is evidence. + response = method(path, headers={ + "User-Agent": CHROME, + "If-None-Match": etag, + "Cache-Control": "max-age=0", + }) + self.assertEqual(304, response.status_code, path) + self.assertEqual(b"", response.data) + self.assertEqual(etag, response.headers.get("ETag")) + self.assertEqual(CACHEABLE, response.headers.get("Cache-Control")) + + def test_a_weak_or_listed_etag_still_matches(self): + etag = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) \ + .headers["ETag"] + for value in ("W/" + etag, '"other", ' + etag, "*"): + response = self.client.get("/51Degrees.core.js", headers={ + "User-Agent": CHROME, "If-None-Match": value}) + self.assertEqual(304, response.status_code, value) + response = self.client.get("/51Degrees.core.js", headers={ + "User-Agent": CHROME, "If-None-Match": '"other"'}) + self.assertEqual(200, response.status_code) + + def test_a_response_to_a_failed_request_carries_no_etag(self): + Switchable.failing = True + for response in self.responses({"User-Agent": CHROME}): + self.assertEqual(200, response.status_code) + self.assertEqual( + NOT_CACHEABLE, response.headers.get("Cache-Control")) + self.assertNotIn("ETag", response.headers) + + def test_a_good_copy_is_kept_while_processing_fails(self): + etag = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) \ + .headers["ETag"] + Switchable.failing = True + response = self.client.get("/51Degrees.core.js", headers={ + "User-Agent": CHROME, "If-None-Match": etag}) + self.assertEqual(304, response.status_code) + + def test_a_failed_response_is_replaced_once_processing_works(self): + """The browser kept the failed response with no ETag, so it has + nothing to revalidate with and asks again in full.""" + + Switchable.failing = True + failed = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + Switchable.failing = False + headers = {"User-Agent": CHROME} + if "ETag" in failed.headers: + headers["If-None-Match"] = failed.headers["ETag"] + response = self.client.get("/51Degrees.core.js", headers=headers) + self.assertEqual(200, response.status_code) + self.assertEqual(CACHEABLE, response.headers.get("Cache-Control")) + + +if __name__ == "__main__": + unittest.main() From b1a6754adb01401902b7c6782bb60be8b7b6e645 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 7 Nov 2026 17:10:47 +0000 Subject: [PATCH 02/14] Set the client-side cache headers, with an ETag that is the same in every process and none after errors --- .../cloud/gettingstarted_web/app.py | 47 ++---- .../onpremise/gettingstarted_web/app.py | 49 ++---- location/examples/cloud/web.py | 21 +-- .../cloud/mixed/gettingstarted_web/app.py | 21 +-- ...lient_side_evidence_custom_flow_element.py | 23 +-- .../src/fiftyone_pipeline_core/web.py | 152 +++++++++++++++++- 6 files changed, 185 insertions(+), 128 deletions(-) diff --git a/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/cloud/gettingstarted_web/app.py b/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/cloud/gettingstarted_web/app.py index 8260065b..93f448b3 100644 --- a/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/cloud/gettingstarted_web/app.py +++ b/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/cloud/gettingstarted_web/app.py @@ -79,8 +79,8 @@ from flask.helpers import make_response from fiftyone_devicedetection_cloud.devicedetection_cloud_pipelinebuilder import DeviceDetectionCloudPipelineBuilder from fiftyone_pipeline_core.logger import Logger -from fiftyone_pipeline_core.web import webevidence, set_response_header -import json +from fiftyone_pipeline_core.web import ( + javascript_response, json_response, webevidence, set_response_header) import os import sys @@ -123,57 +123,28 @@ def run(self): GettingStartedWeb.app.run(port=int(os.environ.get("PORT", 5000))) # First we make a JSON route that will be called from the client side and will return - # a JSON encoded property database using any additional evidence provided by the client + # a JSON encoded property database using any additional evidence provided by the client. + # json_response processes the request and sets the cache headers, so a browser keeps a + # good answer for 30 minutes and never keeps one made while processing failed. @staticmethod @app.route('/json', methods=['POST']) def jsonroute(): - # Create the flowdata object for the JSON route - flowdata = GettingStartedWeb.pipeline.create_flowdata() - - # Add any information from the request (headers, cookies and additional - # client side provided information) - - flowdata.evidence.add_from_dict(webevidence(request)) - - # Process the flowdata - - flowdata.process() - - # Return the JSON from the JSONBundler engine - - return json.dumps(flowdata.jsonbundler.json) + return json_response(GettingStartedWeb.pipeline, request) # Next we serve the client-side JavaScript from its own route. The other # Pipeline APIs ship a web integration that intercepts '/51Degrees.core.js' # for this, so the page can reference the script by that name. There is no # such integration for Flask, so the example wires up the route itself. + # Query parameters are evidence, so the per-request 'fod-js-enable-cookies' + # override is picked up and applied by the JavaScriptBuilder engine. @staticmethod @app.route('/51Degrees.core.js') def core_js(): - # Create the flowdata object for the JavaScript route - flowdata = GettingStartedWeb.pipeline.create_flowdata() - - # Add any information from the request (headers, cookies and additional - # client side provided information). Query parameters are included, so - # the per-request 'fod-js-enable-cookies' override is picked up here and - # applied by the JavaScriptBuilder engine. - - flowdata.evidence.add_from_dict(webevidence(request)) - - # Process the flowdata - - flowdata.process() - - # Return the JavaScript from the JavaScriptBuilder engine - - response = make_response(flowdata.javascriptbuilder.javascript) - response.headers["Content-Type"] = "application/x-javascript" - - return response + return javascript_response(GettingStartedWeb.pipeline, request) # In the main route we dynamically update the screen's device property display # using the above JSON route diff --git a/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py b/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py index a8e1e3ce..e7242044 100644 --- a/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py +++ b/device-detection/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py @@ -82,7 +82,8 @@ from flask import Flask, request, render_template from fiftyone_pipeline_core.logger import Logger from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder -from fiftyone_pipeline_core.web import webevidence, set_response_header +from fiftyone_pipeline_core.web import ( + javascript_response, json_response, webevidence, set_response_header) import json class GettingStartedWeb(): @@ -108,60 +109,28 @@ def run(self): GettingStartedWeb.app.run(port=int(os.environ.get("PORT", 5001))) # First we make a JSON route that will be called from the client side and will return - # a JSON encoded property database using any additional evidence provided by the client + # a JSON encoded property database using any additional evidence provided by the client. + # json_response processes the request and sets the cache headers, so a browser keeps a + # good answer for 30 minutes and never keeps one made while processing failed. @staticmethod @app.route('/json', methods=['POST']) def jsonroute(): - # Create the flowdata object for the JSON route. It is closed when the - # route returns, which releases the results of the detection straight - # away rather than when the garbage collector next runs. - with GettingStartedWeb.pipeline.create_flowdata() as flowdata: - - # Add any information from the request (headers, cookies and additional - # client side provided information) - - flowdata.evidence.add_from_dict(webevidence(request)) - - # Process the flowdata - - flowdata.process() - - # Return the JSON from the JSONBundler engine - - return json.dumps(flowdata.jsonbundler.json) + return json_response(GettingStartedWeb.pipeline, request) # Next we serve the client-side JavaScript from its own route. The other # Pipeline APIs ship a web integration that intercepts '/51Degrees.core.js' # for this, so the page can reference the script by that name. There is no # such integration for Flask, so the example wires up the route itself. + # Query parameters are evidence, so the per-request 'fod-js-enable-cookies' + # override is picked up and applied by the JavaScriptBuilder engine. @staticmethod @app.route('/51Degrees.core.js') def core_js(): - # Create the flowdata object for the JavaScript route. It is closed - # when the route returns. - with GettingStartedWeb.pipeline.create_flowdata() as flowdata: - - # Add any information from the request (headers, cookies and additional - # client side provided information). Query parameters are included, so - # the per-request 'fod-js-enable-cookies' override is picked up here and - # applied by the JavaScriptBuilder engine. - - flowdata.evidence.add_from_dict(webevidence(request)) - - # Process the flowdata - - flowdata.process() - - # Return the JavaScript from the JavaScriptBuilder engine - - response = make_response(flowdata.javascriptbuilder.javascript) - response.headers["Content-Type"] = "application/x-javascript" - - return response + return javascript_response(GettingStartedWeb.pipeline, request) # In the main route we dynamically update the screen's device property display # using the above JSON route diff --git a/location/examples/cloud/web.py b/location/examples/cloud/web.py index 93c8e3aa..f0ea5a45 100644 --- a/location/examples/cloud/web.py +++ b/location/examples/cloud/web.py @@ -32,8 +32,7 @@ # This example is available in full on [GitHub](https://github.com/51Degrees/51degrees-python/blob/main/location/examples/cloud/web.py) from fiftyone_location.location_pipelinebuilder import LocationPipelineBuilder -from fiftyone_pipeline_core.web import webevidence -import json +from fiftyone_pipeline_core.web import json_response, webevidence # First create the location pipeline with the desired settings. @@ -68,24 +67,14 @@ app = Flask(__name__) # First we make a JSON route that will be called from the client side and will return - # a JSON encoded property database using any additional evidence provided by the client + # a JSON encoded property database using any additional evidence provided by the client. + # json_response processes the request and sets the cache headers, so a browser keeps a + # good answer for 30 minutes and never keeps one made while processing failed. @app.route('/json', methods=['POST']) def jsonroute(): - # Create the flowData object for the JSON route - flowData = pipeline.create_flowdata() - - # Add any information from the request (headers, cookies and additional - # client side provided information) - - flowData.evidence.add_from_dict(webevidence(request)) - - # Process the flowData - flowData.process() - - # Return the JSON from the JSONBundler engine - return json.dumps(flowData.jsonbundler.json) + return json_response(pipeline, request) # Helper function to get a property value if it exists and return # the reason why if it doesn't diff --git a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/src/fiftyone_pipeline_examples/cloud/mixed/gettingstarted_web/app.py b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/src/fiftyone_pipeline_examples/cloud/mixed/gettingstarted_web/app.py index 66856b86..f4569463 100644 --- a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/src/fiftyone_pipeline_examples/cloud/mixed/gettingstarted_web/app.py +++ b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/src/fiftyone_pipeline_examples/cloud/mixed/gettingstarted_web/app.py @@ -74,7 +74,6 @@ # - [fiftyone_pipeline_cloudrequestengine](https://pypi.org/project/fiftyone-pipeline-cloudrequestengine/) # - [flask](https://pypi.org/project/flask/) -import json import os import sys @@ -84,7 +83,8 @@ from fiftyone_pipeline_cloudrequestengine.cloudrequestexception import ( CloudRequestException) from fiftyone_pipeline_core.logger import Logger -from fiftyone_pipeline_core.web import set_response_header, webevidence +from fiftyone_pipeline_core.web import ( + javascript_response, json_response, set_response_header, webevidence) from fiftyone_pipeline_examples.cloud.engines import ( CountriesTranslationCloud, DeviceDetectionCloud, IpIntelligenceCloud) @@ -265,27 +265,20 @@ def index(): # The other Pipeline APIs ship web integrations that serve the # client-side script at '/51Degrees.core.js', so the page refers to it by # that name. There is no such integration for Flask, so the example adds - # the route itself. + # the route itself. javascript_response and json_response process the + # request and set the cache headers, so a browser keeps a good answer + # for 30 minutes and never keeps one made while processing failed. @app.route("/51Degrees.core.js") def core_js(): - flowdata = process() - - response = make_response(flowdata.javascriptbuilder.javascript) - response.headers["Content-Type"] = "application/x-javascript" - - return response + return javascript_response(pipeline, request) # The client-side script posts the evidence it gathers here, and gets # back the results of processing it as JSON. @app.route("/json", methods=["POST"]) def json_results(): - flowdata = process() - - return app.response_class( - json.dumps(flowdata.jsonbundler.json), - mimetype="application/json") + return json_response(pipeline, request) return app diff --git a/pipeline/fiftyone_pipeline_core/examples/client_side_evidence_custom_flow_element.py b/pipeline/fiftyone_pipeline_core/examples/client_side_evidence_custom_flow_element.py index 6edda1c2..e25c5723 100644 --- a/pipeline/fiftyone_pipeline_core/examples/client_side_evidence_custom_flow_element.py +++ b/pipeline/fiftyone_pipeline_core/examples/client_side_evidence_custom_flow_element.py @@ -31,12 +31,11 @@ # a person is in the northern or southern hemispheres. from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder -from fiftyone_pipeline_core.web import webevidence +from fiftyone_pipeline_core.web import json_response, webevidence from fiftyone_pipeline_core.basiclist_evidence_keyfilter import BasicListEvidenceKeyFilter from fiftyone_pipeline_core.flowelement import FlowElement from fiftyone_pipeline_core.elementdata_dictionary import ElementDataDictionary -import json # Function to get star sign from month and day def getStarSign(month, day): @@ -168,26 +167,14 @@ def get_evidence_key_filter(self): app = Flask(__name__) -# Make a route that returns back JSON encded properties for client side scripts to pick up +# Make a route that returns back JSON encoded properties for client side scripts to pick +# up. json_response processes the request and sets the cache headers, so a browser keeps +# a good answer for 30 minutes and never keeps one made while processing failed. @app.route('/json', methods=['POST']) def jsonroute(): - # Create the flowdata object for the JSON route - flowdata = myPipeline.create_flowdata() - - # Add any information from the request (headers, cookies and additional - # client side provided information) - - flowdata.evidence.add_from_dict(webevidence(request)) - - # Process the flowdata - - flowdata.process() - - # Return the JSON from the JSONBundler engine - - return json.dumps(flowdata.jsonbundler.json) + return json_response(myPipeline, request) @app.route('/') diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py index d3f96cf0..3cd6d098 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py @@ -20,7 +20,27 @@ # such notice(s) shall fulfill the requirements of that article. # ********************************************************************* -from flask import request +import hashlib +import json + +from flask import make_response, request + +# The Cache-Control of a client-side response to a request whose processing +# recorded no errors. The browser alone may keep it, for 30 minutes. +_CACHE_CONTROL = "max-age=1800, private" + +# The Cache-Control of a client-side response to a request whose processing +# recorded errors. It goes with no ETag, so a browser that kept the response +# has nothing to revalidate with and asks again in full once processing +# works, rather than having the faulty response confirmed by a 304. +_CACHE_CONTROL_AFTER_ERRORS = "private, no-cache" + +# The headers a browser adds when it revalidates or reloads. They differ +# between a request and its revalidation and no element uses them, so they +# are left out of the ETag and the Vary header. +_REVALIDATION_HEADERS = frozenset(( + "cache-control", "if-match", "if-modified-since", "if-none-match", + "if-range", "if-unmodified-since", "pragma")) def webevidence(request): @@ -90,4 +110,132 @@ def set_response_header(flowData, response): if response_value != "": response.headers[response_key] = response_value - return response \ No newline at end of file + return response + +def javascript_response(pipeline, request): + + """! + Process a request for the client-side JavaScript and answer with the + JavaScript the pipeline's JavaScriptBuilder writes, with the cache headers + the web integration specification sets. + + A request whose If-None-Match header names the ETag of its evidence gets + a 304 without being processed, so a browser keeps a good copy while + processing fails. Otherwise the response carries + 'Cache-Control: max-age=1800, private' and the ETag, or + 'Cache-Control: private, no-cache' and no ETag when processing recorded + errors. Both carry a Vary header naming the request headers the + pipeline's elements accept. + + @type pipeline: Pipeline + @param pipeline: A pipeline holding the JavaScriptBuilder + @type request: Request + @param request: The request for the JavaScript + @rtype Response + @return The response to send + """ + + return _client_side_response( + pipeline, request, + lambda flowdata: flowdata.javascriptbuilder.javascript, + "application/x-javascript") + +def json_response(pipeline, request): + + """! + Process a request from the client-side JavaScript and answer with the + JSON the pipeline's JSONBundler writes, with the same cache headers as + javascript_response. + + @type pipeline: Pipeline + @param pipeline: A pipeline holding the JSONBundler + @type request: Request + @param request: The request from the client-side JavaScript + @rtype Response + @return The response to send + """ + + return _client_side_response( + pipeline, request, + lambda flowdata: json.dumps(flowdata.jsonbundler.json), + "application/json") + +def _client_side_response(pipeline, request, content, content_type): + + flowdata = pipeline.create_flowdata() + try: + evidence = webevidence(request) + flowdata.evidence.add_from_dict(evidence) + + # From the request's own evidence, because processing adds to it. + # The sequence element gives a request with no session id a new one. + etag = _etag(pipeline, evidence) + vary = _vary(pipeline, request) + + if _etag_matches(request.headers.get("If-None-Match"), etag): + response = make_response("", 304) + response.headers.pop("Content-Type", None) + response.headers["Cache-Control"] = _CACHE_CONTROL + response.headers["ETag"] = etag + else: + flowdata.process() + response = make_response(content(flowdata)) + response.headers["Content-Type"] = content_type + if flowdata.errors: + response.headers["Cache-Control"] = _CACHE_CONTROL_AFTER_ERRORS + else: + response.headers["Cache-Control"] = _CACHE_CONTROL + response.headers["ETag"] = etag + + if vary: + response.headers["Vary"] = vary + return response + finally: + flowdata.close() + +def _uses(pipeline, key): + + # Whether the key is evidence one of the pipeline's elements accepts. + if key.startswith("header.") and \ + key[len("header."):] in _REVALIDATION_HEADERS: + return False + return any( + element.filter_evidence_key(key) + for element in pipeline.flow_elements) + +def _etag(pipeline, evidence): + + # SHA-256 rather than hash(), which Python seeds afresh in each process, + # so a browser revalidating against another process, after a restart or + # behind a load balancer, still matches. + digest = hashlib.sha256() + for key in sorted(evidence): + if _uses(pipeline, key): + digest.update(key.encode("utf-8")) + digest.update(b"\0") + digest.update(str(evidence[key]).encode("utf-8")) + digest.update(b"\0") + return '"' + digest.hexdigest()[:32] + '"' + +def _etag_matches(if_none_match, etag): + + # The weak comparison RFC 9110 section 13.1.2 asks for, so a W/ prefix + # is ignored, and "*" matches any ETag. + if not if_none_match: + return False + for candidate in if_none_match.split(","): + candidate = candidate.strip() + if candidate.startswith("W/"): + candidate = candidate[2:] + if candidate == "*" or candidate == etag: + return True + return False + +def _vary(pipeline, request): + + names = {} + for name, _ in request.headers: + key = name.lower() + if key not in names and _uses(pipeline, "header." + key): + names[key] = name + return ", ".join(sorted(names.values(), key=str.lower)) \ No newline at end of file From 4c36e9a7b28163d6df900f4500536737c93d8d57 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 7 Nov 2026 21:34:12 +0000 Subject: [PATCH 03/14] Test that a POST with a matching ETag gets 412, and a GET or HEAD 304 --- .../tests/test_cloud_mixed_web_offline.py | 13 +++++--- .../tests/test_web_client_side.py | 32 +++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py index 82c092ad..d2ea3516 100644 --- a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py +++ b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py @@ -390,8 +390,12 @@ def test_a_response_to_a_failed_request_carries_no_etag(cloud, path): assert "ETag" not in response.headers -@pytest.mark.parametrize("path", ["/51Degrees.core.js", "/json"]) -def test_a_good_copy_is_kept_while_processing_fails(cloud, path): +# A 304 for the script, which is a GET, and a 412 for the callback, which is +# a POST, as RFC 9110 section 13.1.2 asks. +@pytest.mark.parametrize("path, status", [ + ("/51Degrees.core.js", 304), ("/json", 412)]) +def test_a_matching_etag_is_answered_while_processing_fails( + cloud, path, status): stub = cloud(EVERY_SECTION) client = example_client() etag = client_side(client, path).headers.get("ETag") @@ -399,5 +403,6 @@ def test_a_good_copy_is_kept_while_processing_fails(cloud, path): stub.failing = True response = client_side(client, path, **{"If-None-Match": etag}) - assert response.status_code == 304 - assert response.headers.get("ETag") == etag + assert response.status_code == status + if status == 304: + assert response.headers.get("ETag") == etag diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 489e54e7..df25c0ff 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -162,21 +162,35 @@ def test_the_etag_is_the_same_in_every_process(self): self.assertEqual(script_etag(), etags[0]) def test_revalidation_with_the_etag_is_answered_not_modified(self): - for path, method in (("/51Degrees.core.js", self.client.get), - ("/json", self.client.post)): - etag = method(path, headers={"User-Agent": CHROME}) \ - .headers["ETag"] + good = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + for method in (self.client.get, self.client.head): # A browser that revalidates sends Cache-Control as well when # the page is reloaded, and neither header is evidence. - response = method(path, headers={ + response = method("/51Degrees.core.js", headers={ "User-Agent": CHROME, - "If-None-Match": etag, + "If-None-Match": good.headers["ETag"], "Cache-Control": "max-age=0", }) - self.assertEqual(304, response.status_code, path) + self.assertEqual(304, response.status_code) self.assertEqual(b"", response.data) - self.assertEqual(etag, response.headers.get("ETag")) - self.assertEqual(CACHEABLE, response.headers.get("Cache-Control")) + # The headers of the good response that RFC 9110 section 15.4.5 + # asks a 304 to repeat. + for name in ("ETag", "Cache-Control", "Vary"): + self.assertEqual(good.headers.get(name), + response.headers.get(name), name) + + def test_a_post_with_a_matching_etag_is_refused(self): + """RFC 9110 section 13.1.2 allows a 304 for GET and HEAD only, and + asks for 412 Precondition Failed for any other method.""" + + etag = self.client.post( + "/json", headers={"User-Agent": CHROME}).headers["ETag"] + response = self.client.post("/json", headers={ + "User-Agent": CHROME, "If-None-Match": etag}) + self.assertEqual(412, response.status_code) + self.assertEqual(b"", response.data) + self.assertNotIn("ETag", response.headers) def test_a_weak_or_listed_etag_still_matches(self): etag = self.client.get( From 8e8f285bb58453acbcd8eb41059e1edd21e85a08 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 7 Nov 2026 21:35:07 +0000 Subject: [PATCH 04/14] Answer a POST whose If-None-Match matches with 412, as RFC 9110 section 13.1.2 asks --- .../src/fiftyone_pipeline_core/web.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py index 3cd6d098..c4ccd511 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py @@ -119,9 +119,12 @@ def javascript_response(pipeline, request): JavaScript the pipeline's JavaScriptBuilder writes, with the cache headers the web integration specification sets. - A request whose If-None-Match header names the ETag of its evidence gets - a 304 without being processed, so a browser keeps a good copy while - processing fails. Otherwise the response carries + A GET or HEAD request whose If-None-Match header names the ETag of its + evidence gets a 304 without being processed, so a browser keeps a good + copy while processing fails. A request by any other method whose + If-None-Match matches, such as a POST to the JSON endpoint, gets 412 + Precondition Failed, as RFC 9110 section 13.1.2 asks. Otherwise the + response carries 'Cache-Control: max-age=1800, private' and the ETag, or 'Cache-Control: private, no-cache' and no ETag when processing recorded errors. Both carry a Vary header naming the request headers the @@ -173,6 +176,13 @@ def _client_side_response(pipeline, request, content, content_type): vary = _vary(pipeline, request) if _etag_matches(request.headers.get("If-None-Match"), etag): + if request.method not in ("GET", "HEAD"): + # RFC 9110 section 13.1.2 allows a 304 for GET and HEAD only. + response = make_response("", 412) + response.headers.pop("Content-Type", None) + return response + # With the headers of a good response that RFC 9110 section + # 15.4.5 asks a 304 to repeat. response = make_response("", 304) response.headers.pop("Content-Type", None) response.headers["Cache-Control"] = _CACHE_CONTROL From dcde28fc9c73bb5a619ff120c0dff0fb04c7d408 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 7 Nov 2026 21:54:58 +0000 Subject: [PATCH 05/14] Test that the web request value added for usage sharing is not in the ETag --- .../tests/test_web_client_side.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index df25c0ff..19f81304 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -26,10 +26,13 @@ import os import subprocess import sys +from types import MappingProxyType import unittest +from unittest import mock from flask import Flask, request +from fiftyone_pipeline_core import web from fiftyone_pipeline_core.basiclist_evidence_keyfilter import \ BasicListEvidenceKeyFilter from fiftyone_pipeline_core.elementdata_dictionary import \ @@ -192,6 +195,26 @@ def test_a_post_with_a_matching_etag_is_refused(self): self.assertEqual(b"", response.data) self.assertNotIn("ETag", response.headers) + def test_the_web_request_value_is_not_in_the_etag(self): + """The web integration may add the request itself as + server.http-request, for the usage sharing element. It holds the + request's every header, If-None-Match included, so it must not be + hashed, even though the JavaScript builder accepts every key.""" + + def with_web_request(request): + evidence = webevidence(request) + evidence["server.http-request"] = MappingProxyType(dict(evidence)) + return evidence + + webevidence = web.webevidence + with mock.patch.object(web, "webevidence", with_web_request): + etag = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) \ + .headers["ETag"] + response = self.client.get("/51Degrees.core.js", headers={ + "User-Agent": CHROME, "If-None-Match": etag}) + self.assertEqual(304, response.status_code) + def test_a_weak_or_listed_etag_still_matches(self): etag = self.client.get( "/51Degrees.core.js", headers={"User-Agent": CHROME}) \ From 300a08159f87658802a53a6258f1cde4e248c627 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 7 Nov 2026 21:55:27 +0000 Subject: [PATCH 06/14] Leave the web request value out of the ETag --- .../src/fiftyone_pipeline_core/web.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py index c4ccd511..2c1d66b1 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py @@ -205,7 +205,11 @@ def _client_side_response(pipeline, request, content, content_type): def _uses(pipeline, key): - # Whether the key is evidence one of the pipeline's elements accepts. + # Whether the key is evidence one of the pipeline's elements accepts. The + # web request itself, which the usage sharing element reads, is evidence + # no element uses, whatever the filters accept. + if key.lower() == "server.http-request": + return False if key.startswith("header.") and \ key[len("header."):] in _REVALIDATION_HEADERS: return False From e2cabee821c0d55759aa2d339b3f6c96cf931a9e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 9 Nov 2026 19:46:14 +0000 Subject: [PATCH 07/14] Test that the client-side ETag is weak and If-None-Match is read as RFC 9110 says --- .../tests/test_cloud_mixed_web_offline.py | 9 +- .../tests/test_web_client_side.py | 122 ++++++++++++++++-- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py index d2ea3516..ce2cac58 100644 --- a/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py +++ b/pipeline/fiftyone_pipeline_cloudrequestengine/examples/tests/test_cloud_mixed_web_offline.py @@ -28,6 +28,7 @@ import json import os +import re import subprocess import sys @@ -322,6 +323,10 @@ def test_licence_key_never_reaches_the_page(cloud): CACHEABLE = "max-age=1800, private" NOT_CACHEABLE = "private, no-cache" +# A weak entity tag of RFC 9110 section 8.8.3, because the ETag depends on +# the evidence alone. +WEAK_ETAG = re.compile(r'W/"[0-9a-f]{32}"\Z') + def client_side(client, path, **headers): """The response of one of the client-side endpoints.""" @@ -355,7 +360,7 @@ def test_a_good_response_may_be_kept_by_the_browser_alone(cloud, path): assert response.status_code == 200 assert response.headers.get("Cache-Control") == CACHEABLE - assert response.headers.get("ETag", "").startswith('"') + assert WEAK_ETAG.match(response.headers.get("ETag", "")) assert "User-Agent" in response.headers.get("Vary", "") @@ -374,7 +379,7 @@ def test_the_etag_is_the_same_in_every_process(): text=True, timeout=120, env=dict(os.environ, PYTHONHASHSEED=seed)) assert finished.returncode == 0, finished.stderr etags.append(finished.stdout.strip().splitlines()[-1]) - assert etags[0].startswith('"'), etags + assert WEAK_ETAG.match(etags[0]), etags assert etags[0] == etags[1] diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 19f81304..6842196d 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -24,6 +24,7 @@ the cache header detail of the web integration specification sets them.""" import os +import re import subprocess import sys from types import MappingProxyType @@ -50,6 +51,13 @@ CACHEABLE = "max-age=1800, private" NOT_CACHEABLE = "private, no-cache" +# A weak entity tag of RFC 9110 section 8.8.3, because the ETag depends on +# the evidence alone and the response can change while the evidence does not. +WEAK_ETAG = re.compile(r'W/"[0-9a-f]{32}"\Z') + +# The session id the JavaScript builder writes into the script. +SESSION_ID = re.compile(r'var sessionId = "([^"]*)"') + class Switchable(FlowElement): """An element that fails while failing is set, as a cloud request does @@ -123,8 +131,7 @@ def test_a_good_response_may_be_kept_by_the_browser_alone(self): self.assertEqual(CACHEABLE, response.headers.get("Cache-Control")) etag = response.headers.get("ETag") self.assertIsNotNone(etag, "a good response carries an ETag") - self.assertTrue(etag.startswith('"') and etag.endswith('"'), - "an ETag is a quoted string: " + etag) + self.assertRegex(etag, WEAK_ETAG) self.assertIn("User-Agent", response.headers.get("Vary", "")) def test_the_content_type_says_which_endpoint_answered(self): @@ -161,9 +168,34 @@ def test_the_etag_is_the_same_in_every_process(self): env=dict(os.environ, PYTHONHASHSEED=seed)) self.assertEqual(0, finished.returncode, finished.stderr) etags.append(finished.stdout.strip().splitlines()[-1]) + self.assertRegex(etags[0], WEAK_ETAG) self.assertEqual(etags[0], etags[1]) self.assertEqual(script_etag(), etags[0]) + def test_the_etag_is_the_same_from_two_pipelines(self): + self.assertRegex(script_etag(), WEAK_ETAG) + self.assertEqual(script_etag(), script_etag()) + + def test_the_session_id_the_sequence_element_adds_is_not_in_the_etag( + self): + """The sequence element gives a request with no session id a new one + while processing, and the script carries it. The ETag covers only the + evidence the request supplied, so it is the same for both + requests.""" + + responses = [ + self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + for _ in range(2)] + session_ids = [ + SESSION_ID.search(response.get_data(as_text=True)).group(1) + for response in responses] + self.assertTrue(session_ids[0], "the script carries a session id") + self.assertNotEqual(session_ids[0], session_ids[1]) + self.assertRegex(responses[0].headers["ETag"], WEAK_ETAG) + self.assertEqual( + responses[0].headers["ETag"], responses[1].headers["ETag"]) + def test_revalidation_with_the_etag_is_answered_not_modified(self): good = self.client.get( "/51Degrees.core.js", headers={"User-Agent": CHROME}) @@ -215,17 +247,87 @@ def with_web_request(request): "User-Agent": CHROME, "If-None-Match": etag}) self.assertEqual(304, response.status_code) - def test_a_weak_or_listed_etag_still_matches(self): + @staticmethod + def if_none_match_cases(etag): + """Values of If-None-Match, each with whether it matches the ETag by + RFC 9110 section 13.1.2. A value that is neither "*" nor a list of + entity tags matches nothing, even when it holds the ETag.""" + + quoted = etag[len("W/"):] + return [ + # The weak comparison ignores W/ on either side. + (etag, True), + (quoted, True), + ("*", True), + ('"other", ' + etag, True), + ('W/"other", ' + quoted, True), + # A list may hold a comma inside a tag, a tab or no space + # around a comma, and empty elements (RFC 9110 section 5.6.1.2). + ('W/"a,b", ' + etag, True), + ('"other",\t' + etag, True), + ('"other"\t,' + etag, True), + ('"other",' + etag, True), + (", " + etag + " ,", True), + ('"other"', False), + ('W/"other", "another"', False), + ("", False), + # Not a list of entity tags. + (etag + ", other", False), + ("other, " + etag, False), + ('"other" ' + etag, False), + ('"other"' + etag, False), + (etag + ' "other"', False), + (etag + ', "a b"', False), + (quoted[:-1] + ' "', False), + ("w/" + quoted, False), + ("W/ " + quoted, False), + ("*, " + etag, False), + (etag + ", *", False), + (quoted[1:-1], False), + (quoted[:-1], False), + ] + + def test_if_none_match_on_a_get_is_compared_weakly(self): + good = self.client.get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + self.assertRegex(good.headers["ETag"], WEAK_ETAG) + for value, matches in self.if_none_match_cases(good.headers["ETag"]): + with self.subTest(value=value): + response = self.client.get("/51Degrees.core.js", headers={ + "User-Agent": CHROME, "If-None-Match": value}) + self.assertEqual(304 if matches else 200, + response.status_code) + self.assertEqual(good.headers["ETag"], + response.headers.get("ETag")) + + def test_if_none_match_on_a_post_is_compared_weakly(self): + good = self.client.post("/json", headers={"User-Agent": CHROME}) + self.assertRegex(good.headers["ETag"], WEAK_ETAG) + for value, matches in self.if_none_match_cases(good.headers["ETag"]): + with self.subTest(value=value): + response = self.client.post("/json", headers={ + "User-Agent": CHROME, "If-None-Match": value}) + self.assertEqual(412 if matches else 200, + response.status_code) + if matches: + self.assertEqual(b"", response.data) + self.assertNotIn("ETag", response.headers) + else: + self.assertEqual( + 42, response.get_json()["switchable"]["answer"]) + + def test_if_none_match_fields_are_one_list(self): + """Several If-None-Match fields are one comma-separated list, as RFC + 9110 section 5.3 says.""" + etag = self.client.get( "/51Degrees.core.js", headers={"User-Agent": CHROME}) \ .headers["ETag"] - for value in ("W/" + etag, '"other", ' + etag, "*"): - response = self.client.get("/51Degrees.core.js", headers={ - "User-Agent": CHROME, "If-None-Match": value}) - self.assertEqual(304, response.status_code, value) - response = self.client.get("/51Degrees.core.js", headers={ - "User-Agent": CHROME, "If-None-Match": '"other"'}) - self.assertEqual(200, response.status_code) + response = self.client.get("/51Degrees.core.js", headers=[ + ("User-Agent", CHROME), + ("If-None-Match", '"other"'), + ("If-None-Match", etag)]) + self.assertEqual(304, response.status_code) def test_a_response_to_a_failed_request_carries_no_etag(self): Switchable.failing = True From a7e05a413376a7db3e6dc58af5e4c5d644e7b111 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 9 Nov 2026 19:47:18 +0000 Subject: [PATCH 08/14] Send the client-side ETag as a weak entity tag, and match If-None-Match only when it is "*" or a list of entity tags --- .../src/fiftyone_pipeline_core/web.py | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py index 2c1d66b1..8cba11d2 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py @@ -22,6 +22,7 @@ import hashlib import json +import re from flask import make_response, request @@ -42,6 +43,15 @@ "cache-control", "if-match", "if-modified-since", "if-none-match", "if-range", "if-unmodified-since", "pragma")) +# An entity tag of RFC 9110 section 8.8.3, being an optional W/ and an +# opaque tag in double quotes of visible characters other than the double +# quote. The second group is the opaque tag without its quotes. +_ENTITY_TAG = re.compile(r'(W/)?"([\x21\x23-\x7e\x80-\xff]*)"') + +# The optional whitespace around the commas of a list, RFC 9110 section +# 5.6.3. +_OWS = " \t" + def webevidence(request): """! @@ -119,12 +129,14 @@ def javascript_response(pipeline, request): JavaScript the pipeline's JavaScriptBuilder writes, with the cache headers the web integration specification sets. - A GET or HEAD request whose If-None-Match header names the ETag of its - evidence gets a 304 without being processed, so a browser keeps a good - copy while processing fails. A request by any other method whose - If-None-Match matches, such as a POST to the JSON endpoint, gets 412 - Precondition Failed, as RFC 9110 section 13.1.2 asks. Otherwise the - response carries + The ETag is a weak entity tag, W/"...", hashed from the evidence the + request supplied that the pipeline's elements accept. A GET or HEAD + request whose If-None-Match header matches it, by the weak comparison of + RFC 9110 section 13.1.2, gets a 304 without being processed, so a + browser keeps a good copy while processing fails. A request by any other + method whose If-None-Match matches, such as a POST to the JSON endpoint, + gets 412 Precondition Failed. An If-None-Match that is neither "*" nor a + list of entity tags matches nothing. Otherwise the response carries 'Cache-Control: max-age=1800, private' and the ETag, or 'Cache-Control: private, no-cache' and no ETag when processing recorded errors. Both carry a Vary header naming the request headers the @@ -172,10 +184,14 @@ def _client_side_response(pipeline, request, content, content_type): # From the request's own evidence, because processing adds to it. # The sequence element gives a request with no session id a new one. - etag = _etag(pipeline, evidence) + opaque_tag = _opaque_tag(pipeline, evidence) + # Weak, because the response can change while the evidence does not, + # for example when the data file is updated (RFC 9110 section 8.8.3). + etag = 'W/"' + opaque_tag + '"' vary = _vary(pipeline, request) - if _etag_matches(request.headers.get("If-None-Match"), etag): + if _if_none_match_matches( + request.headers.get("If-None-Match"), opaque_tag): if request.method not in ("GET", "HEAD"): # RFC 9110 section 13.1.2 allows a 304 for GET and HEAD only. response = make_response("", 412) @@ -217,7 +233,7 @@ def _uses(pipeline, key): element.filter_evidence_key(key) for element in pipeline.flow_elements) -def _etag(pipeline, evidence): +def _opaque_tag(pipeline, evidence): # SHA-256 rather than hash(), which Python seeds afresh in each process, # so a browser revalidating against another process, after a restart or @@ -229,21 +245,46 @@ def _etag(pipeline, evidence): digest.update(b"\0") digest.update(str(evidence[key]).encode("utf-8")) digest.update(b"\0") - return '"' + digest.hexdigest()[:32] + '"' + return digest.hexdigest()[:32] -def _etag_matches(if_none_match, etag): +def _if_none_match_matches(if_none_match, opaque_tag): - # The weak comparison RFC 9110 section 13.1.2 asks for, so a W/ prefix - # is ignored, and "*" matches any ETag. - if not if_none_match: + # RFC 9110 section 13.1.2. "*" matches, as there is always a response. + # A list of entity tags matches when one of them has the same opaque + # tag, whether or not either is weak. Anything else matches nothing. + if if_none_match is None: return False - for candidate in if_none_match.split(","): - candidate = candidate.strip() - if candidate.startswith("W/"): - candidate = candidate[2:] - if candidate == "*" or candidate == etag: - return True - return False + value = if_none_match.strip(_OWS) + if value == "*": + return True + tags = _entity_tags(value) + return tags is not None and opaque_tag in tags + +def _entity_tags(value): + + # The opaque tags of a list of entity tags, which may have whitespace + # around its commas and empty elements (RFC 9110 section 5.6.1.2), or + # None when the value is not such a list. + tags = [] + position = 0 + while True: + while position < len(value) and value[position] in _OWS: + position += 1 + if position == len(value): + return tags + if value[position] != ",": + tag = _ENTITY_TAG.match(value, position) + if tag is None: + return None + tags.append(tag.group(2)) + position = tag.end() + while position < len(value) and value[position] in _OWS: + position += 1 + if position == len(value): + return tags + if value[position] != ",": + return None + position += 1 def _vary(pipeline, request): From 83c472268afc100137d05e60faa655b446845936 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 9 Nov 2026 19:59:36 +0000 Subject: [PATCH 09/14] Test that the client-side max age can be set --- .../tests/test_web_client_side.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 6842196d..8dc83a62 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -361,6 +361,28 @@ def test_a_failed_response_is_replaced_once_processing_works(self): self.assertEqual(200, response.status_code) self.assertEqual(CACHEABLE, response.headers.get("Cache-Control")) + def test_the_max_age_can_be_set(self): + pipeline = PipelineBuilder({ + "javascript_builder_settings": {"endpoint": "/json"}, + }).add(Switchable()).build() + app = Flask(__name__) + + @app.route("/51Degrees.core.js") + def javascript(): + return javascript_response(pipeline, request, 60) + + @app.route("/json", methods=["POST"]) + def results(): + return json_response(pipeline, request, max_age_seconds=60) + + client = app.test_client() + for response in ( + client.get("/51Degrees.core.js", + headers={"User-Agent": CHROME}), + client.post("/json", headers={"User-Agent": CHROME})): + self.assertEqual("max-age=60, private", + response.headers.get("Cache-Control")) + if __name__ == "__main__": unittest.main() From bef273b799746faa10ecdfed26c3bb89bbd29caa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 9 Nov 2026 19:59:37 +0000 Subject: [PATCH 10/14] Let the caller set the client-side max age, which the specification says should be configurable --- .../src/fiftyone_pipeline_core/web.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py index 8cba11d2..dfa8af0b 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py @@ -26,9 +26,9 @@ from flask import make_response, request -# The Cache-Control of a client-side response to a request whose processing -# recorded no errors. The browser alone may keep it, for 30 minutes. -_CACHE_CONTROL = "max-age=1800, private" +# How long the browser alone may keep a client-side response to a request +# whose processing recorded no errors, unless the caller gives another time. +CLIENT_SIDE_MAX_AGE_SECONDS = 1800 # The Cache-Control of a client-side response to a request whose processing # recorded errors. It goes with no ETag, so a browser that kept the response @@ -122,7 +122,8 @@ def set_response_header(flowData, response): return response -def javascript_response(pipeline, request): +def javascript_response( + pipeline, request, max_age_seconds=CLIENT_SIDE_MAX_AGE_SECONDS): """! Process a request for the client-side JavaScript and answer with the @@ -137,15 +138,17 @@ def javascript_response(pipeline, request): method whose If-None-Match matches, such as a POST to the JSON endpoint, gets 412 Precondition Failed. An If-None-Match that is neither "*" nor a list of entity tags matches nothing. Otherwise the response carries - 'Cache-Control: max-age=1800, private' and the ETag, or - 'Cache-Control: private, no-cache' and no ETag when processing recorded - errors. Both carry a Vary header naming the request headers the + 'Cache-Control: max-age=1800, private', or the max age given, and the + ETag, or 'Cache-Control: private, no-cache' and no ETag when processing + recorded errors. Both carry a Vary header naming the request headers the pipeline's elements accept. @type pipeline: Pipeline @param pipeline: A pipeline holding the JavaScriptBuilder @type request: Request @param request: The request for the JavaScript + @type max_age_seconds: int + @param max_age_seconds: How long the browser may keep the response @rtype Response @return The response to send """ @@ -153,9 +156,10 @@ def javascript_response(pipeline, request): return _client_side_response( pipeline, request, lambda flowdata: flowdata.javascriptbuilder.javascript, - "application/x-javascript") + "application/x-javascript", max_age_seconds) -def json_response(pipeline, request): +def json_response( + pipeline, request, max_age_seconds=CLIENT_SIDE_MAX_AGE_SECONDS): """! Process a request from the client-side JavaScript and answer with the @@ -166,6 +170,8 @@ def json_response(pipeline, request): @param pipeline: A pipeline holding the JSONBundler @type request: Request @param request: The request from the client-side JavaScript + @type max_age_seconds: int + @param max_age_seconds: How long the browser may keep the response @rtype Response @return The response to send """ @@ -173,10 +179,12 @@ def json_response(pipeline, request): return _client_side_response( pipeline, request, lambda flowdata: json.dumps(flowdata.jsonbundler.json), - "application/json") + "application/json", max_age_seconds) -def _client_side_response(pipeline, request, content, content_type): +def _client_side_response( + pipeline, request, content, content_type, max_age_seconds): + cache_control = "max-age=" + str(int(max_age_seconds)) + ", private" flowdata = pipeline.create_flowdata() try: evidence = webevidence(request) @@ -201,7 +209,7 @@ def _client_side_response(pipeline, request, content, content_type): # 15.4.5 asks a 304 to repeat. response = make_response("", 304) response.headers.pop("Content-Type", None) - response.headers["Cache-Control"] = _CACHE_CONTROL + response.headers["Cache-Control"] = cache_control response.headers["ETag"] = etag else: flowdata.process() @@ -210,7 +218,7 @@ def _client_side_response(pipeline, request, content, content_type): if flowdata.errors: response.headers["Cache-Control"] = _CACHE_CONTROL_AFTER_ERRORS else: - response.headers["Cache-Control"] = _CACHE_CONTROL + response.headers["Cache-Control"] = cache_control response.headers["ETag"] = etag if vary: From e0799ee16b6b489617461111f78135008c1a2ceb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 9 Nov 2026 20:06:36 +0000 Subject: [PATCH 11/14] Read the session id from the minified script in the ETag test --- .../fiftyone_pipeline_core/tests/test_web_client_side.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 8dc83a62..3cf0bcf8 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -55,8 +55,9 @@ # the evidence alone and the response can change while the evidence does not. WEAK_ETAG = re.compile(r'W/"[0-9a-f]{32}"\Z') -# The session id the JavaScript builder writes into the script. -SESSION_ID = re.compile(r'var sessionId = "([^"]*)"') +# The session id the JavaScript builder writes into the script, which is +# minified by default. +SESSION_ID = re.compile(r'var sessionId\s*=\s*"([^"]*)"') class Switchable(FlowElement): From eca925725f8b69d98e6572d510e080546e6c69b3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 12 Nov 2026 07:28:29 +0000 Subject: [PATCH 12/14] Name every header in the evidence key filter in Vary, as the other languages do The Vary header named only the headers the request carried, so a cache would not re-fetch when a header the pipeline accepts appeared for the first time. The web integration specification's cache header detail says Vary carries all headers in the Pipeline Evidence key filter, and 51degrees-node, 51degrees-php and 51degrees-go all build it from each element's filter list. _vary now reads each element's evidence key filter list, and asks a filter that cannot list its keys about the request's own headers one by one, which is what PHP does. A key that is not a field name is left out, and the revalidation headers stay out as before. Three tests cover it. --- .../src/fiftyone_pipeline_core/web.py | 33 +++++-- .../tests/test_web_client_side.py | 94 +++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py index 95f43095..b5d06b0e 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/web.py @@ -51,6 +51,10 @@ # quote. The second group is the opaque tag without its quotes. _ENTITY_TAG = re.compile(r'(W/)?"([\x21\x23-\x7e\x80-\xff]*)"') +# A field name of RFC 9110 section 5.1, in lower case. An evidence key that +# is not one would make the Vary header unreadable, so it is left out. +_FIELD_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9a-z-]+\Z") + # The optional whitespace around the commas of a list, RFC 9110 section # 5.6.3. _OWS = " \t" @@ -176,8 +180,8 @@ def javascript_response( list of entity tags matches nothing. Otherwise the response carries 'Cache-Control: max-age=1800, private', or the max age given, and the ETag, or 'Cache-Control: private, no-cache' and no ETag when processing - recorded errors. Both carry a Vary header naming the request headers the - pipeline's elements accept. + recorded errors. Both carry a Vary header naming the headers the + pipeline's elements accept, whether or not the request carried them. @type pipeline: Pipeline @param pipeline: A pipeline holding the JavaScriptBuilder @@ -332,9 +336,22 @@ def _entity_tags(value): def _vary(pipeline, request): - names = {} - for name, _ in request.headers: - key = name.lower() - if key not in names and _uses(pipeline, "header." + key): - names[key] = name - return ", ".join(sorted(names.values(), key=str.lower)) \ No newline at end of file + # The headers in the pipeline's evidence key filter, whether or not this + # request carried them, so a cache re-fetches when one that was absent + # appears. An element whose filter is a list names every header in it. A + # filter that cannot list its keys is asked about the request's own + # headers one by one instead. + carried = [name.lower() for name, _ in request.headers] + names = set() + for element in pipeline.flow_elements: + listed = getattr(element.get_evidence_key_filter(), "list", None) + keys = listed if listed is not None else \ + ["header." + name for name in carried] + for key in keys: + key = str(key).lower() + if key.startswith("header.") and _uses(pipeline, key) and \ + _FIELD_NAME.match(key[len("header."):]): + names.add(key[len("header."):]) + return ", ".join( + "-".join(part.capitalize() for part in name.split("-")) + for name in sorted(names)) \ No newline at end of file diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 3cf0bcf8..0ffd5c2b 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -38,6 +38,7 @@ BasicListEvidenceKeyFilter from fiftyone_pipeline_core.elementdata_dictionary import \ ElementDataDictionary +from fiftyone_pipeline_core.evidence_keyfilter import EvidenceKeyFilter from fiftyone_pipeline_core.flowelement import FlowElement from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder from fiftyone_pipeline_core.web import javascript_response, json_response @@ -80,6 +81,43 @@ def get_evidence_key_filter(self): return BasicListEvidenceKeyFilter(["header.user-agent"]) +class Listed(FlowElement): + """An element that accepts the evidence keys it is given, as most + elements do, so its filter can list them.""" + + def __init__(self, keys): + super(Listed, self).__init__() + self.datakey = "listed" + self.keys = keys + + def process_internal(self, flowdata): + flowdata.set_element_data(ElementDataDictionary(self, {})) + + def get_evidence_key_filter(self): + return BasicListEvidenceKeyFilter(self.keys) + + +class Unlisted(FlowElement): + """An element whose filter decides key by key and cannot list what it + accepts, as the cloud request engine's does.""" + + def __init__(self): + super(Unlisted, self).__init__() + self.datakey = "unlisted" + + def process_internal(self, flowdata): + flowdata.set_element_data(ElementDataDictionary(self, {})) + + def get_evidence_key_filter(self): + return OnlyUserAgent() + + +class OnlyUserAgent(EvidenceKeyFilter): + + def filter_evidence_key(self, key): + return key.lower() == "header.user-agent" + + def client(): """A test client of an application serving the two client-side endpoints.""" @@ -384,6 +422,62 @@ def results(): self.assertEqual("max-age=60, private", response.headers.get("Cache-Control")) + def test_vary_names_a_header_the_request_did_not_carry(self): + # The Vary header names every header in the pipeline's evidence key + # filter, not only the ones this request happened to carry, so a + # cache re-fetches when a header that was absent appears. + pipeline = PipelineBuilder({ + "javascript_builder_settings": {"endpoint": "/json"}, + }).add(Listed(["header.user-agent", "header.sec-ch-ua-model"])).build() + app = Flask(__name__) + + @app.route("/51Degrees.core.js") + def javascript(): + return javascript_response(pipeline, request) + + response = app.test_client().get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + + self.assertEqual( + "Sec-Ch-Ua-Model, User-Agent", response.headers.get("Vary")) + + def test_vary_leaves_out_the_headers_a_browser_revalidates_with(self): + # If-None-Match and the rest differ between a request and its + # revalidation, so naming them would stop any response being reused. + pipeline = PipelineBuilder({ + "javascript_builder_settings": {"endpoint": "/json"}, + }).add(Listed(["header.user-agent", "header.if-none-match", + "header.cache-control"])).build() + app = Flask(__name__) + + @app.route("/51Degrees.core.js") + def javascript(): + return javascript_response(pipeline, request) + + response = app.test_client().get( + "/51Degrees.core.js", headers={"User-Agent": CHROME}) + + self.assertEqual("User-Agent", response.headers.get("Vary")) + + def test_vary_falls_back_to_the_request_for_a_filter_that_cannot_list( + self): + # An element whose filter is not a list cannot say which headers it + # accepts, so the request's own headers are asked of it one by one. + pipeline = PipelineBuilder({ + "javascript_builder_settings": {"endpoint": "/json"}, + }).add(Unlisted()).build() + app = Flask(__name__) + + @app.route("/51Degrees.core.js") + def javascript(): + return javascript_response(pipeline, request) + + response = app.test_client().get( + "/51Degrees.core.js", + headers={"User-Agent": CHROME, "Accept-Language": "en"}) + + self.assertEqual("User-Agent", response.headers.get("Vary")) + if __name__ == "__main__": unittest.main() From ae00321d2a9536a478e2da537b3e5199483590aa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 12 Nov 2026 07:32:19 +0000 Subject: [PATCH 13/14] Name Host in the Vary header expectations, which the JavaScript Builder adds The three new Vary tests expected only the headers their own element accepts. Run 43175480017 showed Host beside them, which is right, because the JavaScript Builder's evidence key filter accepts header.host to build the callback URL the client script posts to, and its filter is not a list so the request's own headers are asked of it one by one. --- .../tests/test_web_client_side.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 0ffd5c2b..12d2ced0 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -438,8 +438,10 @@ def javascript(): response = app.test_client().get( "/51Degrees.core.js", headers={"User-Agent": CHROME}) - self.assertEqual( - "Sec-Ch-Ua-Model, User-Agent", response.headers.get("Vary")) + # Host comes from the JavaScript Builder, which reads it to build + # the callback URL the script posts to. + self.assertEqual("Host, Sec-Ch-Ua-Model, User-Agent", + response.headers.get("Vary")) def test_vary_leaves_out_the_headers_a_browser_revalidates_with(self): # If-None-Match and the rest differ between a request and its @@ -457,7 +459,7 @@ def javascript(): response = app.test_client().get( "/51Degrees.core.js", headers={"User-Agent": CHROME}) - self.assertEqual("User-Agent", response.headers.get("Vary")) + self.assertEqual("Host, User-Agent", response.headers.get("Vary")) def test_vary_falls_back_to_the_request_for_a_filter_that_cannot_list( self): @@ -476,7 +478,9 @@ def javascript(): "/51Degrees.core.js", headers={"User-Agent": CHROME, "Accept-Language": "en"}) - self.assertEqual("User-Agent", response.headers.get("Vary")) + # User-Agent from the element below, Host from the JavaScript + # Builder, and no Accept-Language, which neither accepts. + self.assertEqual("Host, User-Agent", response.headers.get("Vary")) if __name__ == "__main__": From 3d7e242f75ece042582bf8004f611a80ea8c9b75 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 12 Nov 2026 08:59:37 +0000 Subject: [PATCH 14/14] Test that Content-Length is the length of what was sent --- .../fiftyone_pipeline_core/tests/test_web_client_side.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py index 12d2ced0..ddc07912 100644 --- a/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py +++ b/pipeline/fiftyone_pipeline_core/tests/test_web_client_side.py @@ -181,6 +181,11 @@ def test_the_content_type_says_which_endpoint_answered(self): self.assertIn(b"fiftyoneDegreesManager", script.data) self.assertEqual(42, results.get_json()["switchable"]["answer"]) + def test_the_content_length_is_the_length_of_what_was_sent(self): + for response in self.responses({"User-Agent": CHROME}): + self.assertEqual(str(len(response.data)), + response.headers.get("Content-Length")) + def test_the_etag_follows_the_evidence(self): first = self.client.get( "/51Degrees.core.js", headers={"User-Agent": CHROME})