Repository navigation
Cache the client-side endpoints' answers as the specification and RFC 9110 ask - #67
Merged
Merged
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
Merged
51Degrees/specifications#136 has the client-side endpoints compare If-None-Match with the ETag of the supplied Evidence before the Pipeline processes the request, and give the 304 the headers of a good response. These tests fail on the code before the fix.
The servlet filter and the Spring interceptor now ask the JavaScript service, before the request is processed, whether a request for the JavaScript or the JSON carries an If-None-Match that matches the ETag of the Evidence it supplies. The Evidence comes from a FlowData that the integration's own result service creates and does not process, so it is the Evidence processing would use. A match gets a 304 to a GET or HEAD, carrying the headers of a good response, and a 412 to any other method, as 51Degrees/specifications#136 asks. A 304 sent after processing carries the same headers.
This was referenced Nov 14, 2026
Merged
Open
jwrosewell
marked this pull request as ready for review
November 15, 2026 02:32
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.
Closes #11. This brings the client-side JavaScript and JSON endpoints of the web integration, served by both the servlet filter and the Spring interceptor, in line with the Cache header detail section of the web integration specification, quoted below at 6570727d, the head of the specifications
mainbranch. That section was written by 51Degrees/specifications#53, #81, #115, #121 and #136, all merged.The problem
On
rcat b78810ff the endpoints have these faults.661586230. RFC 9110 writes an entity tag in double quotes, so the number is 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 sends a different ETag every time and no revalidation ever gets a 304.*or a second If-None-Match line never matches.Cache-Control: private,max-age=1800and the ETag, even when processing recorded errors, so a browser keeps a response built without the failed element's data for 30 minutes and then revalidates it into a 304. This is Client-side responses to a request whose processing recorded errors are cached with their ETag #11.null, andresponse.getWriter().write(content)throws aNullPointerExceptionafter the headers are set.What the specification says
The table gives Cache-Control
max-age=1800 private, Vary "all headers in Pipeline Evidence key filter", ETag "Weak entity tagW/"<hash>"of the Evidence values in the Evidence key filter, as the request supplied them", Content-Typeapplication/x-javascriptorapplication/JSON, and Content-Length "Content length in bytes". The section then says the following.W/"...", because it is not a strong validator."Cache-Control: private, no-cacheand MUST NOT carry an ETag."RFC 9110 and RFC 9111 are both Internet Standards, and the rfc-editor.org records list nothing that obsoletes or updates either, checked on 13 November 2026.
entity-tag = [ weak ] opaque-tag,weak = %s"W/",opaque-tag = DQUOTE *etagc DQUOTEandetagc = %x21 / %x23-7E / obs-text.If-None-Match = "*" / #entity-tagand says "A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match". A false condition gets 304 for GET or HEAD and 412 for any other method, and a list holding*and other values "is syntactically invalid".What changed
Line numbers in items 1 to 10 are at 5d419ebc, and in item 11 at this branch's head, a9ab91d9, whose commit changed only the lines item 11 names.
PipelineFilter.doFilterandFiftyOneInterceptor.preHandlefirst call the newFiftyOneJSServiceCore.serveNotModified. For a request that carries If-None-Match, to the JavaScript or the JSON path, with the client-side features on, it asks the result service for the request's FlowData without processing it, through the newPipelineResultServiceCore.createFlowData, whichprocessnow builds on. So the Evidence is the integration's own, including the web context root that the Spring evidence service adds and the JavaScript builder accepts.serveJavascriptNotModifiedandserveJsonNotModifiedthen compare If-None-Match with the ETag of that Evidence. A match is answered and the FlowData closed, and nothing is processed and the rest of the chain is not called. Anything else is processed as before. A request with no If-None-Match, or to another path, creates nothing extra.answerMatchgives a GET or HEAD a 304 with no content carrying Vary,Cache-Control: private,max-age=<max-age>, the ETag andAccess-Control-Allow-Origin, the headers of a good 200, and any other method but CONNECT, OPTIONS and TRACE (item 11) a 412 with the CORS header, so a script on another origin can read it.serveContentuses it too, so a 304 sent after processing, which only a caller ofserveJavascriptorserveJsonthat processed first can reach, carries the same headers. The 200 sends no Content-Location or Expires, so neither does the 304, and Date is the servlet container's to add.getETagreturnsW/"<SHA-256 in lower case hex>". The values come from theWebRequestEvidencerecord that the web integration adds to Evidence asserver.http-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 then its UTF-8 bytes, so the same values give the same ETag in every process.ifNoneMatchMatchesjoins every If-None-Match line 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 it matches when one tag's quoted part equals the ETag's, with or withoutW/on either side.Cache-Control: private, no-cacheand no ETag. 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.initread any filter that is anEvidenceKeyFilterWhitelistor derives from one, so that a parallel group's headers are in Vary, and addedgetHeadersAffectingJavaScript, which works the headers out again until every element has loaded. This draft adds the test of a parallel group,JavaScript_VaryHeader_ParallelElements, which passes onrctoo, andsetCachingHeadersleaves Vary out when no header affects the response, rather than sending it empty.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.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. The servlet filter reads theclientside-cache-max-ageinit parameter and fails to start on a value that is not a whole number of seconds, 0 or more. The Spring integration readsFiftyOneInterceptorConfig.getClientsideCacheMaxAge(), a default method that returns 1800, whichFiftyOneInterceptorConfigDefault.setClientsideCacheMaxAgesets and checks.JavaScriptBuilderElementorJsonBuilderElementfails with aPipelineConfigurationExceptionon every request for that endpoint, a matching one included. The check before processing leaves such a request to be processed so that the error is reported.ifNoneMatchAppliesnever matches for those three methods, andserveContent(line 353) andserveNotModified(line 419) ask it in place ofifNoneMatchMatches. So such a request is processed and answered as if it carried no If-None-Match, where before it got a 412. RFC 9110 section 13.2.1 says "a server MUST ignore the conditional request header fields defined by this specification when received with a request method that does not involve the selection or modification of a selected representation, such as CONNECT, OPTIONS, or TRACE."The new interface methods are default methods that answer nothing, so an application's own implementation of
PipelineResultServiceCore,FiftyOneJSServiceCoreorClientsidePropertyServiceCorestill compiles, and is then processed first as before.Merges with
rcrcwas merged in with a merge commit at 5ffe4940, bringing 70 commits, among them #80 (paths and element switches for the web integration) and #86 (the Vary headers worked out again). Seven files conflicted, and each kept both sides.Constants,FiftyOneInterceptorConfig,FiftyOneInterceptorConfigDefaultand the imports ofPipelineFilterhold #80's paths and switches as well as the max-age.FiftyOneInterceptorTestsisrc's file with the max-age test added.ClientsidePropertyServiceCorereads Vary through #86's method and leaves an empty Vary out. Earlier merges brought #68.Tests
Tests first. 7b0cd655 adds or changes four tests on the merged code, and 5d419ebc holds the fix.
PipelineFilterTests.PipelineFilter_MatchingIfNoneMatch_304_NotProcessedandPipelineFilter_MatchingIfNoneMatch_Post_412_NotProcessedsendIf-None-Match: *to the real filter and check that nothing is stored as the request's FlowData, the rest of the chain is not called, and the answer is a 304 withprivate,max-age=1800and a weak ETag, or a 412 for a POST to the JSON path.PipelineFilter_OtherIfNoneMatch_Processedchecks that a tag that does not match is processed and gets a 200.FiftyOneInterceptorTests.FiftyOneInterceptor_MatchingIfNoneMatch_304_NotProcesseddoes the same through the interceptor with the real Spring services.ClientsidePropertyServiceTests.JavaScript_ProcessingErrors_MatchingIfNoneMatch_304now expects the headers of a good response on a 304 while processing records errors, as lines 313 to 319 of the specification ask.Red, run 43477586444 on the throwaway branch
tmp/java-67-red(7b0cd65 with a temporary workflow that runs the web integration's test classes onubuntu-latestwith the runner's own Java and Maven). The four failed and nothing else did, 3 of 76 inpipeline.weband 1 of 13 inpipeline.web.mvc. The three that check processing failed with Mockito's "Never wanted here" forsetAttribute("fiftyonedegrees.flowdata", <any>), and the service test withCache-Control"private, no-cache" where "private,max-age=1800" was wanted.The fix commit also adds nine tests of the new methods, which call methods that do not exist before it.
ClientsidePropertyServiceTestshasJavaScript_NotModified_Matching_304,Json_NotModified_Post_412,JavaScript_NotModified_OtherTag_NotAnswered,Json_NotModified_MissingBuilder_NotAnsweredandNotModified_NoPipeline_NotAnswered.FiftyOneJSServiceCoreTestshasNotModified_NoIfNoneMatch_NothingCreated,NotModified_OtherPath_NothingCreated,NotModified_ClientSideDisabled_NothingCreatedandNotModified_JavaScriptAndJson_AskTheirEndpoint, the last of which also checks that the FlowData is closed.Green, run 43481115579 on
tmp/java-67-green(5d419eb with the same temporary workflow). Every test passed, 85 of 85 inpipeline.weband 13 of 13 inpipeline.web.mvc, with OpenJDK 17.0.21 and Maven 3.10.0 as the runner has them. Two earlier attempts on the same branch, 43478134998 and 43479426796, stopped before the tests ran. The first met a raw type, which the build's-Werrorrefuses, and the second met the repository's PMD rule that every FlowData is created in a try-with-resources statement, which the four tests that stubcreateFlowDataon a mock now set aside because a mock creates nothing to close. Both are fixed in 5d419eb.Item 11, from the review of 23:03 UTC on 13 November. d03a796d adds
ClientsidePropertyServiceTests.Json_MatchingIfNoneMatch_Options_Ignored, which sends an OPTIONS with a matching If-None-Match to the JSON endpoint and expects a 200, andNotModified_IgnoredMethods_NotAnswered, which checks thatserveJavascriptNotModifiedandserveJsonNotModifiedanswer none of CONNECT, OPTIONS and TRACE. a9ab91d9 holds the fix. Red, run 43528259987 ontmp/java-67-red-b(d03a796 with the same temporary workflow), failed those two and nothing else, 2 of 87 inpipeline.web, with 13 of 13 passing inpipeline.web.mvc. Green, run 43528275058 ontmp/java-67-green-b(a9ab91d), passed 87 of 87 and 13 of 13.Earlier commits on this branch were checked the same way, with red and green runs 42394657601 and 42394664423 (the failed response and the null content), 42416471804 and 42416575313 (the 412 and the 304's headers), and 42726888694 (the missing builder, the empty Vary and the negative max-age).
JavaScript_ETag_SameInEveryProcessandJson_ETag_SameAcrossPipelinescompare the ETag with SHA-256 values worked out apart from the Java code, with Python'shashlibover the same encoding.Full build
Run 43530753981 built a9ab91d, this head, merged with
rc(30e4886) on nine configurations, Ubuntu x64 with Java 8, 11, 17 and 21, Ubuntu arm64 with Java 17, and Windows with Java 8, 11, 17 and 21, and every job passed. Each of its tenSetting 'PullRequestSha'lines names a9ab91d, and its Configure job printed "Left out 6 macOS entries".rchas moved to 6e03a25 since (#127, #128 and #130), and this branch merges with it without conflict.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.
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.Cache-Control: private, no-cacheand no ETag.clientside-cache-max-ageinit parameter of the servlet filter orFiftyOneInterceptorConfigDefault.setClientsideCacheMaxAgefor Spring. The default stays 1800 seconds.NullPointerException.PipelineResultServiceCore.createFlowData,FiftyOneJSServiceCore.serveNotModified,ClientsidePropertyServiceCore.serveJavascriptNotModifiedandserveJsonNotModified. An application that wires the web integration itself, rather than throughPipelineFilterorFiftyOneInterceptor, callsserveNotModifiedbefore processing to answer revalidations the same way.What remains
rc(line 281 at 30e48867). RFC 8259 section 8.1 says "JSON text exchanged between systems that are not part of a closed ecosystem MUST be encoded using UTF-8", so in an application that sets no encoding a value with a character outside Latin-1 reaches the browser as bytes that are not UTF-8. Content-Length now counts the bytes actually written, in either encoding. Which encoding the JavaScript should be sent in, and whether it names one in Content-Type, is a separate choice, so both are left to #149.