From 08edafa0a1c5367cc9703645e219427f78c73ccf Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 12 Mar 2026 10:21:03 +0100 Subject: [PATCH] Improve RFC 9111 cache control compliance --- .../cache/CachedHttpResponseGenerator.java | 10 +- .../CachedResponseSuitabilityChecker.java | 8 +- .../impl/cache/ResponseCachingPolicy.java | 32 ++- .../TestCachedHttpResponseGenerator.java | 8 +- .../TestCachedResponseSuitabilityChecker.java | 30 +++ .../impl/cache/TestProtocolRequirements.java | 10 +- .../impl/cache/TestResponseCachingPolicy.java | 189 ++++++++++++------ 7 files changed, 194 insertions(+), 93 deletions(-) diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java index 7a4d03dc97..91b36b63a6 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java @@ -79,12 +79,10 @@ SimpleHttpResponse generateResponse(final HttpRequest request, final HttpCacheEn } final TimeValue age = this.validityStrategy.getCurrentAge(entry, now); - if (TimeValue.isPositive(age)) { - if (age.compareTo(CacheSupport.MAX_AGE) >= 0) { - response.setHeader(HttpHeaders.AGE, Long.toString(CacheSupport.MAX_AGE.toSeconds())); - } else { - response.setHeader(HttpHeaders.AGE, Long.toString(age.toSeconds())); - } + if (age.compareTo(CacheSupport.MAX_AGE) >= 0) { + response.setHeader(HttpHeaders.AGE, Long.toString(CacheSupport.MAX_AGE.toSeconds())); + } else { + response.setHeader(HttpHeaders.AGE, Long.toString(age.toSeconds())); } return response; diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedResponseSuitabilityChecker.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedResponseSuitabilityChecker.java index 069bf406bf..d930473fd7 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedResponseSuitabilityChecker.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedResponseSuitabilityChecker.java @@ -102,11 +102,6 @@ public CacheSuitability assessSuitability(final RequestCacheControl requestCache return CacheSuitability.MISMATCH; } - if (!requestHeadersMatch(request, entry)) { - LOG.debug("Request headers nominated by the cached response do not match those of the request associated with the cache entry"); - return CacheSuitability.MISMATCH; - } - if (requestCacheControl.isNoCache()) { LOG.debug("Request contained no-cache directive; the cache entry must be re-validated"); return CacheSuitability.REVALIDATION_REQUIRED; @@ -148,7 +143,8 @@ public CacheSuitability assessSuitability(final RequestCacheControl requestCache return CacheSuitability.REVALIDATION_REQUIRED; } - if (!fresh && sharedCache && responseCacheControl.isProxyRevalidate()) { + if (!fresh && sharedCache + && (responseCacheControl.isProxyRevalidate() || responseCacheControl.getSharedMaxAge() > -1)) { LOG.debug("Response from cache is not suitable due to the response proxy-revalidate requirement"); return CacheSuitability.REVALIDATION_REQUIRED; } diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java index 9672fe2dcc..f5d25c35c6 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java @@ -131,13 +131,18 @@ public boolean isResponseCacheable(final ResponseCacheControl cacheControl, fina } } - if (cacheControl.isMustUnderstand() && !understoodStatusCode(code)) { - // must-understand cache directive overrides no-store - LOG.debug("Response contains a status code that the cache does not understand, so it's not cacheable"); - return false; - } - - if (isExplicitlyNonCacheable(cacheControl)) { + if (cacheControl.isMustUnderstand()) { + if (!understoodStatusCode(code)) { + // must-understand: cache does not understand the status code, do not store + LOG.debug("Response contains a status code that the cache does not understand, so it's not cacheable"); + return false; + } + // Status code is in a recognized range; treat no-store as overridden. + if (sharedCache && cacheControl.isCachePrivate()) { + LOG.debug("Response is private and cannot be cached by a shared cache"); + return false; + } + } else if (isExplicitlyNonCacheable(cacheControl)) { LOG.debug("Response is explicitly non-cacheable per cache control directive"); return false; } @@ -185,12 +190,19 @@ public boolean isResponseCacheable(final ResponseCacheControl cacheControl, fina return isExplicitlyCacheable(cacheControl, response) || isHeuristicallyCacheable(cacheControl, code, responseDate, responseExpires); } + // Heuristically cacheable status codes private static boolean isKnownCacheableStatusCode(final int status) { return status == HttpStatus.SC_OK || status == HttpStatus.SC_NON_AUTHORITATIVE_INFORMATION || + status == HttpStatus.SC_NO_CONTENT || status == HttpStatus.SC_MULTIPLE_CHOICES || status == HttpStatus.SC_MOVED_PERMANENTLY || - status == HttpStatus.SC_GONE; + status == 308 /* Permanent Redirect */ || + status == HttpStatus.SC_NOT_FOUND || + status == HttpStatus.SC_METHOD_NOT_ALLOWED || + status == HttpStatus.SC_GONE || + status == HttpStatus.SC_REQUEST_URI_TOO_LONG || + status == HttpStatus.SC_NOT_IMPLEMENTED; } private static boolean isKnownNonCacheableStatusCode(final int status) { @@ -345,8 +357,8 @@ private Duration calculateFreshnessLifetime(final ResponseCacheControl cacheCont return DEFAULT_FRESHNESS_DURATION; // 5 minutes } - // Check if s-maxage is present and use its value if it is - if (cacheControl.getSharedMaxAge() != -1) { + // s-maxage applies only to shared caches + if (sharedCache && cacheControl.getSharedMaxAge() != -1) { return Duration.ofSeconds(cacheControl.getSharedMaxAge()); } else if (cacheControl.getMaxAge() != -1) { return Duration.ofSeconds(cacheControl.getMaxAge()); diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedHttpResponseGenerator.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedHttpResponseGenerator.java index 59eca4b3a6..a909630056 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedHttpResponseGenerator.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedHttpResponseGenerator.java @@ -56,6 +56,8 @@ void setUp() { entry = HttpTestUtils.makeCacheEntry(); request = HttpTestUtils.makeDefaultRequest(); mockValidityPolicy = mock(CacheValidityPolicy.class); + when(mockValidityPolicy.getCurrentAge(isA(HttpCacheEntry.class), isA(Instant.class))) + .thenReturn(TimeValue.ofSeconds(0L)); impl = new CachedHttpResponseGenerator(mockValidityPolicy); } @@ -93,15 +95,17 @@ void testAgeHeaderIsPopulatedWithCurrentAgeOfCacheEntryIfNonZero() throws Except } @Test - void testAgeHeaderIsNotPopulatedIfCurrentAgeOfCacheEntryIsZero() throws Exception { + void testAgeHeaderIsPopulatedIfCurrentAgeOfCacheEntryIsZero() throws Exception { currentAge(TimeValue.ofSeconds(0L)); final SimpleHttpResponse response = impl.generateResponse(request, entry); verify(mockValidityPolicy).getCurrentAge(same(entry), isA(Instant.class)); + // A cache MUST generate an Age header field final Header ageHdr = response.getFirstHeader("Age"); - Assertions.assertNull(ageHdr); + Assertions.assertNotNull(ageHdr); + Assertions.assertEquals(0L, Long.parseLong(ageHdr.getValue())); } @Test diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedResponseSuitabilityChecker.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedResponseSuitabilityChecker.java index 416fe15f3c..61df079728 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedResponseSuitabilityChecker.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachedResponseSuitabilityChecker.java @@ -457,6 +457,36 @@ void testNotSuitableIfGetRequestWithHeadCacheEntry() { Assertions.assertEquals(CacheSuitability.MISMATCH, impl.assessSuitability(requestCacheControl, responseCacheControl, request, entry, now)); } + @Test + void testNotSuitableIfStaleWithSharedMaxAgeInSharedCache() { + final CacheConfig sharedConfig = CacheConfig.custom() + .setSharedCache(true) + .build(); + impl = new CachedResponseSuitabilityChecker(sharedConfig); + entry = makeEntry( + new BasicHeader("Date", DateUtils.formatStandardDate(tenSecondsAgo))); + responseCacheControl = ResponseCacheControl.builder() + .setSharedMaxAge(5) + .build(); + Assertions.assertEquals(CacheSuitability.REVALIDATION_REQUIRED, + impl.assessSuitability(requestCacheControl, responseCacheControl, request, entry, now)); + } + + @Test + void testSuitableIfFreshWithSharedMaxAgeInSharedCache() { + final CacheConfig sharedConfig = CacheConfig.custom() + .setSharedCache(true) + .build(); + impl = new CachedResponseSuitabilityChecker(sharedConfig); + entry = makeEntry( + new BasicHeader("Date", DateUtils.formatStandardDate(tenSecondsAgo))); + responseCacheControl = ResponseCacheControl.builder() + .setSharedMaxAge(3600) + .build(); + Assertions.assertEquals(CacheSuitability.FRESH, + impl.assessSuitability(requestCacheControl, responseCacheControl, request, entry, now)); + } + @Test void testSuitableIfErrorRequestCacheControl() { // Prepare a cache entry with HEAD method diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestProtocolRequirements.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestProtocolRequirements.java index 4a11becef3..416965372e 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestProtocolRequirements.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestProtocolRequirements.java @@ -1638,7 +1638,7 @@ void testUnknownMethodRequestsAreWrittenThroughToOrigin() throws Exception { } @Test - void testTransmitsAgeHeaderIfIncomingAgeHeaderTooBig() throws Exception { + void testReplacesAgeHeaderIfIncomingAgeHeaderTooBig() throws Exception { final String reallyOldAge = "1" + Long.MAX_VALUE; originResponse.setHeader("Age",reallyOldAge); @@ -1646,8 +1646,12 @@ void testTransmitsAgeHeaderIfIncomingAgeHeaderTooBig() throws Exception { final ClassicHttpResponse result = execute(request); - Assertions.assertEquals(reallyOldAge, - result.getFirstHeader("Age").getValue()); + // A cache MUST generate an Age header field, replacing any present in the response + // with a value equal to the stored response's current_age + final Header ageHdr = result.getFirstHeader("Age"); + Assertions.assertNotNull(ageHdr); + final long ageValue = Long.parseLong(ageHdr.getValue()); + Assertions.assertTrue(ageValue >= 0); } @Test diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java index bdddf7cfeb..fe263e9065 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java @@ -29,7 +29,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Random; @@ -370,14 +369,15 @@ void testControlAnyCacheControlCacheable() { } @Test - void testControlWithout200Cacheable() { - HttpResponse response404 = new BasicHttpResponse(HttpStatus.SC_NOT_FOUND, ""); + void testControlWithNonCacheableStatusCode() { + // 402 Payment Required is not a heuristically cacheable status code + HttpResponse response402 = new BasicHttpResponse(HttpStatus.SC_PAYMENT_REQUIRED, ""); - Assertions.assertFalse(policy.isResponseCacheable(responseCacheControl, request, response404)); + Assertions.assertFalse(policy.isResponseCacheable(responseCacheControl, request, response402)); - response404 = new BasicHttpResponse(HttpStatus.SC_GATEWAY_TIMEOUT, ""); + response402 = new BasicHttpResponse(HttpStatus.SC_GATEWAY_TIMEOUT, ""); - Assertions.assertFalse(policy.isResponseCacheable(responseCacheControl, request, response404)); + Assertions.assertFalse(policy.isResponseCacheable(responseCacheControl, request, response402)); } @Test @@ -814,80 +814,24 @@ void otherStatusCodesAreCacheableWithExplicitCachingHeaders() { } @Test - void testIsResponseCacheableNullCacheControl() { - - // Set up test data - final Duration tenSecondsFromNow = Duration.ofSeconds(10); - + void testCacheableWithExpiresAndMaxAge() { response = new BasicHttpResponse(HttpStatus.SC_OK, ""); response.setHeader(HttpHeaders.DATE, DateUtils.formatStandardDate(Instant.now())); - response.setHeader(HttpHeaders.EXPIRES, DateUtils.formatStandardDate(Instant.now().plus(tenSecondsFromNow))); + response.setHeader(HttpHeaders.EXPIRES, DateUtils.formatStandardDate(Instant.now().plusSeconds(10))); - - // Create ResponseCachingPolicy instance and test the method policy = new ResponseCachingPolicy(true, false, false); request = new BasicHttpRequest("GET", "/foo"); - assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); - } - - @Test - void testIsResponseCacheableNotNullCacheControlSmaxAge60() { - - // Set up test data - final Duration tenSecondsFromNow = Duration.ofSeconds(10); - - response = new BasicHttpResponse(HttpStatus.SC_OK, ""); - response.setHeader(HttpHeaders.DATE, DateUtils.formatStandardDate(Instant.now())); - response.setHeader(HttpHeaders.EXPIRES, DateUtils.formatStandardDate(Instant.now().plus(tenSecondsFromNow))); - - - // Create ResponseCachingPolicy instance and test the method - policy = new ResponseCachingPolicy(true, false, false); - request = new BasicHttpRequest("GET", "/foo"); - responseCacheControl = ResponseCacheControl.builder() - .setMaxAge(60) - .build(); + // Cacheable with Expires header alone assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); - } - - @Test - void testIsResponseCacheableNotNullCacheControlMaxAge60() { - // Set up test data - final Duration tenSecondsFromNow = Duration.ofSeconds(10); - - response = new BasicHttpResponse(HttpStatus.SC_OK, ""); - response.setHeader(HttpHeaders.DATE, DateUtils.formatStandardDate(Instant.now())); - response.setHeader(HttpHeaders.EXPIRES, DateUtils.formatStandardDate(Instant.now().plus(tenSecondsFromNow))); - - - // Create ResponseCachingPolicy instance and test the method - policy = new ResponseCachingPolicy(true, false, false); - request = new BasicHttpRequest("GET", "/foo"); + // Cacheable with explicit max-age responseCacheControl = ResponseCacheControl.builder() .setMaxAge(60) .build(); assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); } - @Test - void testIsResponseCacheableNotExsiresAndDate() { - - // Set up test data - final Duration tenSecondsFromNow = Duration.ofSeconds(10); - - response = new BasicHttpResponse(HttpStatus.SC_OK, ""); - response.setHeader(HttpHeaders.DATE, DateUtils.formatStandardDate(Instant.now())); - response.setHeader(HttpHeaders.EXPIRES, DateUtils.formatStandardDate(Instant.now().plus(tenSecondsFromNow))); - - - // Create ResponseCachingPolicy instance and test the method - policy = new ResponseCachingPolicy(true, false, false); - request = new BasicHttpRequest("GET", "/foo"); - assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); - } - private int getRandomStatus() { final int rnd = new Random().nextInt(acceptableCodes.length); @@ -1000,4 +944,117 @@ void testMustRevalidateWithAuthorizationIsCacheable() { "Response with must-revalidate and Authorization header should be cacheable in shared cache."); } + @Test + void testMustUnderstandWithNoStoreAndUnderstoodStatusIsCacheable() { + response = new BasicHttpResponse(HttpStatus.SC_OK, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + responseCacheControl = ResponseCacheControl.builder() + .setMustUnderstand(true) + .setNoStore(true) + .setCachePublic(true) + .build(); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testMustUnderstandWithNoStoreAndUnknownStatusIsNotCacheable() { + response = new BasicHttpResponse(600, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + responseCacheControl = ResponseCacheControl.builder() + .setMustUnderstand(true) + .setNoStore(true) + .setCachePublic(true) + .build(); + assertFalse(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testNoStoreWithoutMustUnderstandIsNotCacheable() { + response = new BasicHttpResponse(HttpStatus.SC_OK, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + responseCacheControl = ResponseCacheControl.builder() + .setNoStore(true) + .build(); + assertFalse(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testMustUnderstandWithNoStoreAndPrivateNotCacheableBySharedCache() { + // must-understand overrides no-store but private must still be enforced + response = new BasicHttpResponse(HttpStatus.SC_OK, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + responseCacheControl = ResponseCacheControl.builder() + .setMustUnderstand(true) + .setNoStore(true) + .setCachePrivate(true) + .build(); + assertFalse(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testMustUnderstandWithNoStoreAndPrivateCacheableByNonSharedCache() { + policy = new ResponseCachingPolicy(false, false, false); + response = new BasicHttpResponse(HttpStatus.SC_OK, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + responseCacheControl = ResponseCacheControl.builder() + .setMustUnderstand(true) + .setNoStore(true) + .setCachePrivate(true) + .build(); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testPrivateCacheIgnoresSharedMaxAgeForFreshness() { + policy = new ResponseCachingPolicy(false, false, false); + response = new BasicHttpResponse(HttpStatus.SC_OK, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + responseCacheControl = ResponseCacheControl.builder() + .setSharedMaxAge(0) + .setMaxAge(3600) + .build(); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testHeuristicallyCacheable204() { + response = new BasicHttpResponse(HttpStatus.SC_NO_CONTENT, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Last-Modified", DateUtils.formatStandardDate(sixSecondsAgo)); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testHeuristicallyCacheable404() { + response = new BasicHttpResponse(HttpStatus.SC_NOT_FOUND, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + response.setHeader("Last-Modified", DateUtils.formatStandardDate(sixSecondsAgo)); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testHeuristicallyCacheable405() { + response = new BasicHttpResponse(HttpStatus.SC_METHOD_NOT_ALLOWED, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + response.setHeader("Last-Modified", DateUtils.formatStandardDate(sixSecondsAgo)); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + + @Test + void testHeuristicallyCacheable501() { + response = new BasicHttpResponse(HttpStatus.SC_NOT_IMPLEMENTED, ""); + response.setHeader("Date", DateUtils.formatStandardDate(now)); + response.setHeader("Content-Length", "0"); + response.setHeader("Last-Modified", DateUtils.formatStandardDate(sixSecondsAgo)); + assertTrue(policy.isResponseCacheable(responseCacheControl, request, response)); + } + } \ No newline at end of file