Repository navigation
Cache the client-side endpoints' answers as the specification and RFC 9110 ask - #67
Draft
jwrosewell wants to merge 18 commits into
Draft
jwrosewell wants to merge 18 commits into
jwrosewell wants to merge 18 commits into
Conversation
…r ETag A response to a request whose processing recorded errors should carry Cache-Control private, no-cache and no ETag, a matching If-None-Match should still get its 304, a missing builder result should not throw, and fixed evidence should give a fixed ETag.
A response to a request whose processing recorded errors may lack the data of the element that failed. It carried the ETag and Cache-Control private,max-age=1800, so a browser kept it for 30 minutes and then revalidated it into a 304 for as long as the evidence stayed the same, although processing worked again. It now carries Cache-Control private, no-cache and no ETag, as the web integration specification asks. A request whose If-None-Match matches still gets its 304, so a browser holding a good response keeps it through an outage. A missing builder result no longer throws a NullPointerException when the content is written.
…he 200's headers RFC 9110 section 13.1.2 answers a matching If-None-Match with 304 for GET and HEAD and 412 for any other method, and section 15.4.5 has a 304 carry the Cache-Control, Vary and ETag headers a 200 would.
…he 200's headers RFC 9110 section 13.1.2 has an origin server answer a matching If-None-Match with 304 for GET and HEAD and 412 for any other method, and section 15.4.5 has the 304 carry the Cache-Control, Vary and ETag headers a 200 to the same request would. The endpoints answered every method with a bare 304. A 304 now carries Vary, Cache-Control, the ETag unless processing recorded errors, and the CORS header, so a browser that revalidates during a failure is told to ask again next time. Any method other than GET or HEAD gets 412.
… the Pipeline accepts The ETag is made after processing, so a session id added during processing changes it on every request. The test fails until the ETag is made from the Evidence the request supplied.
A Flow Element can add or replace Evidence while processing, and a session id added that way changed the ETag on every request, so a revalidation never got a 304. FlowData now keeps a copy of the Evidence taken when processing starts, generateSuppliedKey builds a key from it, and the ETag uses that key. generateSuppliedKey is a default method, so existing FlowData implementations still compile.
…y and Content-Length The ETag is a weak entity tag holding a quoted SHA-256 digest, the same for the same Evidence in two Pipelines and against a value worked out apart from the Java code, and unchanged when the Sequence Element adds a new session id. If-None-Match is evaluated as RFC 9110 section 13.1.2 says, with "*", lists over one or several field lines, W/ on either side, tabs and empty elements matching, and a tag then a non-tag, two tags with no comma, a space inside the quotes, "*" with a tag, a lower case w/ and an unquoted value not matching. The JSON endpoint gets 304 for GET and 412 for POST, also while processing fails. A parallel group's headers are in Vary, and Content-Length counts bytes in the response's character encoding. The usage sharing test of a revalidation now checks the 304 carries the ETag that was sent, rather than how the ETag is worked out.
… as RFC 9110 says The ETag was the exclusive or of the hash codes of the Evidence after processing, sent as a bare number. A bare number is not an entity tag, which RFC 9110 section 8.8.3 writes in double quotes, and the Sequence Element adds a new session id to every request without one, so the ETag of the JavaScript endpoint changed on every request and no revalidation got a 304. The ETag is now W/"<SHA-256 in hex>" of the values the request supplied, read from the web request record the web integration keeps, for the keys in the Pipeline's Evidence key filter. Keys are lower cased and sorted, and each key and value is written as its length then its UTF-8 bytes, so the same values give the same ETag in every process. It is weak because the content can change for the same Evidence, as when a data file is updated. If-None-Match is evaluated as section 13.1.2 says. All its field lines form one list, "*" matches, and a list matches when one tag has the same quoted part as the ETag, with or without W/ on either side. A field that is neither "*" nor a list of entity tags does not match. Vary now holds the headers of a parallel group's filter, and Content-Length counts the content's bytes in the response's character encoding rather than its characters, as the specification's header table says.
Both changes make the ETag from the Evidence the request supplied. The service keeps the weak SHA-256 ETag of the web request record. The test of a revalidation after a Flow Element adds a session id is kept as it was here, and the next commit reconciles it and generateSuppliedKey with the ETag.
The ETag is the SHA-256 digest of the values in the web request record the web integration adds to Evidence on every request, which holds the values as the request supplied them. A DataKey cannot give that digest, because it holds only the exclusive or of the values' hash codes and not the keys, so nothing calls generateSuppliedKey and FlowData no longer copies its Evidence before processing. The test of a revalidation after an element adds a session id built its FlowData without the web integration and read If-None-Match with getHeader. Json_ETag_UnchangedWhenSequenceElementAddsSessionId covers the same case with the real Sequence Element through the web integration.
… default The web integration specification says the max-age of the client-side endpoints' Cache-Control header SHOULD be configurable, so that a site whose JavaScript gathers values that change, such as a location, can shorten it. It was fixed at 1800 seconds. ClientsidePropertyServiceCore.Default gains setCacheMaxAge, which refuses a negative number. The servlet filter reads it from the clientside-cache-max-age init parameter and fails to start on a value that is not a whole number of seconds, 0 or more. The Spring integration reads it from FiftyOneInterceptorConfig.getClientsideCacheMaxAge, a default method, which FiftyOneInterceptorConfigDefault can set. The default stays 1800 seconds, and a failed response still carries private, no-cache.
JavaScript_MissingBuilder_MatchingIfNoneMatch_Throws and Json_MissingBuilder_MatchingIfNoneMatch_Throws send a matching If-None-Match to a Pipeline without the builder for the endpoint, and expect a PipelineConfigurationException rather than a 304. JavaScript_VaryHeader_PSEUDO now expects no Vary header where the Evidence key filter names no real header, rather than an empty one, as .NET sets Vary only when the list is not empty. FiftyOneInterceptorConfigDefaultTests expects the Spring setter to refuse a negative max-age and keep 0. The two missing builder tests, JavaScript_VaryHeader_PSEUDO and setClientsideCacheMaxAge_Negative_Throws are written to fail on the previous commit.
…e Spring max-age serveContent now finds the builder element for the endpoint before it tests If-None-Match, so a Pipeline without the JavaScriptBuilderElement or JsonBuilderElement fails with a PipelineConfigurationException on every request, rather than answering 304 to a client that already holds an ETag. setCachingHeaders sets Vary only when some header affects the response, as .NET's SetHeaders does, rather than sending an empty Vary header. FiftyOneInterceptorConfigDefault.setClientsideCacheMaxAge refuses a negative max-age with the message the service gives, so the error points at the setting where it is typed.
This was referenced Nov 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For #11. This brings the client-side JavaScript and JSON endpoints of the web integration in line with the Cache header detail section of the web integration specification on
rc, which 51Degrees/specifications#53 wrote, and with three drafts that changed the same section, being 51Degrees/specifications#81 (the 304 and 412 answers), 51Degrees/specifications#115 (the weak entity tag) and 51Degrees/specifications#121 (the Evidence the request supplied). #115 and #121 are onrcof the specifications repository. #81 is still an open draft. The 304 and 412 split by method is already onrc, in the Cache header detail section quoted below, and the headers a 304 carries follow RFC 9110 section 15.4.5 until #81 adds them to the specification.serveContentinClientsidePropertyServiceCore.javaserves both endpoints, for the servlet filter and for the Spring interceptor.The problem
On
rcat 7299688 the endpoints had these faults.661586230. RFC 9110 writes an entity tag in double quotes, so the number was neither a weak nor a strong entity tag.query.session-idto every request that has none, and the Pipeline's Evidence key filter holds that key, so the JavaScript endpoint sent a different ETag every time and no revalidation ever got a 304.*or a second If-None-Match line never matched.Cache-Control: private,max-age=1800and the ETag, even when processing had recorded errors, so a browser kept a response built without the failed element's data for 30 minutes and then revalidated it into a 304.null, andresponse.getWriter().write(content)threw aNullPointerExceptionafter the headers were set.EvidenceKeyFilterWhitelistwas read, and a parallel group's filter is anEvidenceKeyFilterAggregator.What the specifications say
The Cache header detail section on
rcgives a table of the headers both endpoints MUST set. Its rows are Cache-Controlmax-age=1800 private, Vary "all headers in Pipeline Evidence key filter", ETag "Calculated hash of ALL the Evidence values in Evidence filter key for this Pipeline", Content-Typeapplication/x-javascriptorapplication/JSON, and Content-Length "Content length in bytes". The same section says the following.Cache-Control: private, no-cacheand MUST NOT carry an ETag."#115 and #121 added two rules.
W/"...", because it is not a strong validator." The ETag depends on the Evidence alone, so a data file update that changes the content for the same Evidence leaves the ETag as it was.RFC 9110 and RFC 9111 are both Internet Standards, and their rfc-editor.org records list nothing that obsoletes or updates them, checked on 9 November 2026.
entity-tag = [ weak ] opaque-tag,weak = %s"W/",opaque-tag = DQUOTE *etagc DQUOTEandetagc = %x21 / %x23-7E / obs-text. An entity tag that does not meet the strong validator definition MUST be marked weak withW/, which is case sensitive.If-None-Match = "*" / #entity-tagand says "A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match". The condition is false if the field is*, false if one of a list of entity tags matches, and "Otherwise, the condition is true." A false condition gets 304 for GET or HEAD and 412 for any other method. A list holding*and other values "is syntactically invalid".private, no-cacheand, as Shorten the Location Doxygen titles and keep the generated index pages out of search #53 asks, no ETag. A cache that follows section 4.3.4 then has no validator to pick the stored response by and may leave its headers as they were. Either way the stored response is past its max-age and is checked again before its next use.What changed
All in
pipeline/pipeline.web.packages/pipeline.web. Line numbers are at 902290d for items 1 to 8.getETagreturnsW/"<SHA-256 in lower case hex>". The values come from theWebRequestEvidencerecord that the web integration adds to Evidence asserver.http-requeston every request, which holds each value as the request supplied it, before any Flow Element runs. Only values whose keys are in the Pipeline's Evidence key filter count. Keys are lower cased and sorted, and each key and value is written as its length in four bytes, big endian, then its UTF-8 bytes, so two different sets of values cannot give the same bytes. The digest has no per-process seed, so the same Evidence gives the same ETag in every process. A FlowData whose Evidence was added without the web integration has no record, so its own Evidence is used.ifNoneMatchMatchesreads every If-None-Match line withgetHeadersand joins them into one list.*matches. Otherwise the field has to be a comma separated list of entity tags, with spaces, tabs and empty elements allowed around the commas, and the list matches when one tag's quoted part equals the ETag's, with or withoutW/on either side. Any other field does not match, such as a tag followed by something that is not a tag, two tags with no comma between them, a space inside the quotes,*with a tag, a lower casew/, or an unquoted value.Access-Control-Allow-Origin. The 200 sends no Content-Location or Expires, so neither does the 304, and Date is the servlet container's to add. Any other method gets 412 with no content and the CORS header, so a script on another origin can read the 412. The headers on the 304 are what Write the usage sharing record as the specification says #81, still a draft, adds to the specification, so for now they follow RFC 9110 section 15.4.5.Cache-Control: private, no-cacheand no ETag, written as the specification writes it. Vary, Content-Type, Content-Length and the CORS header are set as before.nullcontent is not written, and the response is sent with a length of 0.initreads any filter that is anEvidenceKeyFilterWhitelistor derives from one, as its comment already said, so a parallel group's headers are in Vary.setHeaderssets the length of the content in bytes in the response's character encoding, which the servlet specification makes ISO-8859-1 when none is set.WebRequestEvidence's class comment says the ETag is made from it too.ClientsidePropertyServiceCore.Default.setCacheMaxAgesets the max-age in seconds and refuses a negative number. The default stays 1800 seconds,DEFAULT_CLIENTSIDE_CACHE_MAX_AGE_SECONDSinfiftyone.pipeline.web.Constants, and a failed response still carriesprivate, no-cache.clientside-cache-max-ageinit parameter (PipelineFilter.getCacheMaxAgeSeconds) and fails to start on a value that is not a whole number of seconds, 0 or more.FiftyOneInterceptorConfig.getClientsideCacheMaxAge(), a default method that returns 1800, so existing implementations still compile.FiftyOneInterceptorConfigDefault.setClientsideCacheMaxAgesets it, andClientsidePropertyService.setCacheMaxAgeis a default method too, whichClientsidePropertyService.Defaulttakes from the core class.serveContentfinds the builder element for the endpoint before it tests If-None-Match. So a Pipeline without theJavaScriptBuilderElementorJsonBuilderElementfails with aPipelineConfigurationExceptionon every request, rather than answering 304 to a client that already holds an ETag. .NET'sServeContentanswers the 304 before it looks for the builder, so for a Pipeline missing its builder the two differ. No specification page covers this order.setCachingHeaderssets Vary only when some header affects the response, rather than sending an empty Vary header, as .NET'sSetHeadersdoes.FiftyOneInterceptorConfigDefault.setClientsideCacheMaxAgerefuses a negative max-age with the message the service gives, so the error points at the setting.clientside-cache-max-ageinit parameter is described in the Javadoc ofINIT_PARAM_CLIENTSIDE_CACHE_MAX_AGEinfiftyone.pipeline.web.Constants. The other init parameters,config-fileandclientside-properties-enabled, have no documentation beyond their use in the exampleweb.xmlfiles, so none is added for this one.Overlap with #86. #86 reads the Vary headers through a new
getHeadersAffectingJavaScript(), so that they are worked out again until every element has loaded. Both change the line that sets Vary. Whichever merges second keeps both, by reading throughgetHeadersAffectingJavaScript()and setting Vary only when the list is not empty.rcwas merged in at 330f707, bringing #68. ItsRevalidation_SameETag_Gets304test worked the expected ETag out fromgenerateKey(...).hashCode(). That test now checks that the 304 carries the ETag that was sent, and its mocked request answersgetHeadersas well asgetHeader.The supplied Evidence key that was replaced
1f92896 and 36f2526 on this branch met the supplied-Evidence rule another way. They added a default method
FlowData.generateSuppliedKey, backed by a copy of the Evidence thatFlowDataDefault.processtook before processing, and made the ETag from that key's hash code. ADataKeyholds only the exclusive or of its values' hash codes, without the keys, so it cannot give the digest above. 902290d therefore removesgenerateSuppliedKeyand the copy, sopipeline.coreis as it is onrc, the publicFlowDatainterface is unchanged and no FlowData copies its Evidence. Their test, which built its FlowData without the web integration, is replaced byJson_ETag_UnchangedWhenSequenceElementAddsSessionId, which covers the same case with the real Sequence Element through the web integration.For the release notes
W/"<SHA-256 in hex>", worked out from the Evidence the request supplied. A browser that revalidates the JavaScript now gets a 304, where before the new session id added to every request changed the ETag each time. An ETag kept from an earlier version does not match, so each browser gets one full response after the upgrade.*, a list of entity tags, several If-None-Match lines and weak tags, as RFC 9110 section 13.1.2 says.Cache-Control: private, no-cacheand no ETag, so a browser does not keep it.clientside-cache-max-ageinit parameter of the servlet filter orFiftyOneInterceptorConfigDefault.setClientsideCacheMaxAgefor Spring. The default stays 1800 seconds.NullPointerException.PipelineConfigurationExceptioneven for a request whose If-None-Match matches, rather than answering 304.FiftyOneInterceptorConfigDefault.setClientsideCacheMaxAgerefuses a negative number where it is called.How to verify
The tests and the fix are separate commits.
rcmerged in, with the old service code. Twelve tests are written to fail there:JavaScript_VaryHeader_ParallelElements,JavaScript_ETag_IsWeakEntityTag,Json_ETag_IsWeakEntityTag,JavaScript_ETag_SameInEveryProcess,Json_ETag_SameAcrossPipelines,Json_ETag_UnchangedWhenSequenceElementAddsSessionId,IfNoneMatch_Star_304,IfNoneMatch_ListHoldingTheTag_304,IfNoneMatch_TagWithoutWeakPrefix_304,IfNoneMatch_TabBetweenElements_304,IfNoneMatch_SeveralFieldLines_304andJson_ContentLength_InBytes. The_200If-None-Match tests are written to pass on both sides, because the old exact comparison never matched those values either. They keep the new parser from matching too much.JavaScript_ETag_SameInEveryProcessandJson_ETag_SameAcrossPipelinescompare the ETag with SHA-256 values worked out apart from the Java code, with Python'shashlibover the same encoding.The command, with the JavaScript templates submodule checked out because
PipelineFilterTestsandUsageSharingEvidenceTestsbuild aJavaScriptBuilderElement:The full build below ran every test in these classes on 7a2686b.
4e42723 adds the configurable max-age with its nine tests in one commit, because they use methods and constants that did not exist before it, so on 902290d they would not compile. In
ClientsidePropertyServiceTeststhey areJavaScript_CacheMaxAge_Configured,Json_CacheMaxAge_Configured_304,JavaScript_CacheMaxAge_FailedResponseNoCacheandCacheMaxAge_Negative_Refused. InPipelineFilterTeststhey areCacheMaxAge_NotSet_IsTheDefault,CacheMaxAge_Set,CacheMaxAge_Invalid_RefusedandPipelineFilter_Initialise_InvalidCacheMaxAge. InFiftyOneInterceptorTestsit isFiftyOneInterceptor_SetsTheCacheMaxAge.Earlier commits on this branch were checked on
ubuntu-latestwith JDK 17, building the same two modules and running the four classes other thanUsageSharingEvidenceTests, which came later withrc.The failed response and the null content. Red at 012e70a, run 42394657601, with 17 passed, 1 error and 2 failures. Green at b8b53dc, run 42394664423, with 20 passed.
The 412 and the 304's headers. Red at 4f62ea4, run 42416471804, with 21 passed and 3 failures. Green at 6a74ed1, run 42416575313, with 24 passed.
013146b holds the review follow-up tests alone, on 4e42723's code.
JavaScript_MissingBuilder_MatchingIfNoneMatch_ThrowsandJson_MissingBuilder_MatchingIfNoneMatch_Throwsexpect aPipelineConfigurationExceptionrather than a 304.JavaScript_VaryHeader_PSEUDOnow expects no Vary header where the filter names no real header.FiftyOneInterceptorConfigDefaultTestsholdssetClientsideCacheMaxAge_Negative_ThrowsandsetClientsideCacheMaxAge_Zero_Kept.setClientsideCacheMaxAge_Zero_Keptare written to fail there. In run 42726888694 (branchtmp/red-67) these four failed, as expected:JavaScript_MissingBuilder_MatchingIfNoneMatch_ThrowsandJson_MissingBuilder_MatchingIfNoneMatch_Throwswith "A PipelineConfigurationException should have been thrown",JavaScript_VaryHeader_PSEUDOwith Mockito's "Never wanted here" forsetHeader("Vary", <any string>), andsetClientsideCacheMaxAge_Negative_Throwswith "An IllegalArgumentException should have been thrown".7a2686b, this branch's head, holds the follow-up fixes.
Full build
The full build and test matrix passed every job other than macOS on head 7a2686b in run 42773789376 on 10 November 2026, 11 jobs succeeded, run as a dry run so nothing merged. macOS was left out of this draft's full build under the 9 November decision. The rc head is built with macOS before it merges into main.