HTTPCLIENT-2277: Improve Age Header Handling and Calculation in Accordance with RFC9111.

This commit enhances the processing and calculation of the "Age" response header field to comply fully with Sections 5.1 and 4.2.3 of RFC9111.
This commit is contained in:
Arturo Bernal 2023-06-15 19:24:18 +02:00 committed by Oleg Kalnichevski
parent f8eb716c11
commit e26896596f
5 changed files with 98 additions and 39 deletions

View File

@ -28,6 +28,7 @@ package org.apache.hc.client5.http.impl.cache;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.utils.DateUtils;
@ -206,28 +207,53 @@ class CacheValidityPolicy {
return TimeValue.ofSeconds(diff.getSeconds());
}
/**
* Extracts and processes the Age value from an HttpCacheEntry by tokenizing the Age header value.
* The Age header value is interpreted as a sequence of tokens, and the first token is parsed into a number
* representing the age in delta-seconds. If the first token cannot be parsed into a number, the Age value is
* considered as invalid and this method returns 0. If the first token represents a negative number or a number
* that exceeds Integer.MAX_VALUE, the Age value is set to MAX_AGE (in seconds).
* This method uses CacheSupport.parseTokens to robustly handle the Age header value.
* <p>
* Note: If the HttpCacheEntry contains multiple Age headers, only the first one is considered.
*
* @param entry The HttpCacheEntry from which to extract the Age value.
* @return The Age value in delta-seconds, or MAX_AGE in seconds if the Age value exceeds Integer.MAX_VALUE or
* is negative. If the Age value is invalid (cannot be parsed into a number or contains non-numeric characters),
* this method returns 0.
*/
protected long getAgeValue(final HttpCacheEntry entry) {
// This is a header value, we leave as-is
long ageValue = 0;
for (final Header hdr : entry.getHeaders(HttpHeaders.AGE)) {
long hdrAge;
final Header age = entry.getFirstHeader(HttpHeaders.AGE);
if (age != null) {
try {
hdrAge = Long.parseLong(hdr.getValue());
if (hdrAge < 0) {
hdrAge = MAX_AGE.toSeconds();
final AtomicReference<String> firstToken = new AtomicReference<>();
CacheSupport.parseTokens(age, token -> firstToken.compareAndSet(null, token));
final String s = firstToken.get();
if (s != null) {
long ageValue = Long.parseLong(s);
if (ageValue < 0) {
ageValue = 0; // Handle negative age values as invalid
} else if (ageValue > Integer.MAX_VALUE) {
ageValue = MAX_AGE.toSeconds();
}
return ageValue;
}
} catch (final NumberFormatException ex) {
if (LOG.isDebugEnabled()) {
LOG.debug("Invalid Age header: '{}'. Ignoring.", age, ex);
}
} catch (final NumberFormatException nfe) {
hdrAge = MAX_AGE.toSeconds();
}
ageValue = (hdrAge > ageValue) ? hdrAge : ageValue;
}
return ageValue;
// If we've got here, there were no valid Age headers
return 0;
}
protected TimeValue getCorrectedReceivedAge(final HttpCacheEntry entry) {
final TimeValue apparentAge = getApparentAge(entry);
protected TimeValue getCorrectedAgeValue(final HttpCacheEntry entry) {
final long ageValue = getAgeValue(entry);
return (apparentAge.toSeconds() > ageValue) ? apparentAge : TimeValue.ofSeconds(ageValue);
final long responseDelay = getResponseDelay(entry).toSeconds();
return TimeValue.ofSeconds(ageValue + responseDelay);
}
protected TimeValue getResponseDelay(final HttpCacheEntry entry) {
@ -236,7 +262,9 @@ class CacheValidityPolicy {
}
protected TimeValue getCorrectedInitialAge(final HttpCacheEntry entry) {
return TimeValue.ofSeconds(getCorrectedReceivedAge(entry).toSeconds() + getResponseDelay(entry).toSeconds());
final long apparentAge = getApparentAge(entry).toSeconds();
final long correctedReceivedAge = getCorrectedAgeValue(entry).toSeconds();
return TimeValue.ofSeconds(Math.max(apparentAge, correctedReceivedAge));
}
protected TimeValue getResidentTime(final HttpCacheEntry entry, final Instant now) {

View File

@ -201,11 +201,6 @@ class ResponseCachingPolicy {
}
}
if (response.countHeaders(HttpHeaders.AGE) > 1) {
LOG.debug("Multiple Age headers");
return false;
}
if (response.countHeaders(HttpHeaders.EXPIRES) > 1) {
LOG.debug("Multiple Expires headers");
return false;

View File

@ -95,20 +95,14 @@ public class TestCacheValidityPolicy {
return TimeValue.ofSeconds(6);
}
};
assertEquals(TimeValue.ofSeconds(10), impl.getCorrectedReceivedAge(entry));
assertEquals(TimeValue.ofSeconds(10), impl.getCorrectedAgeValue(entry));
}
@Test
public void testCorrectedReceivedAgeIsApparentAgeIfLarger() {
public void testGetCorrectedAgeValue() {
final Header[] headers = new Header[] { new BasicHeader("Age", "6"), };
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(headers);
impl = new CacheValidityPolicy() {
@Override
protected TimeValue getApparentAge(final HttpCacheEntry ent) {
return TimeValue.ofSeconds(10);
}
};
assertEquals(TimeValue.ofSeconds(10), impl.getCorrectedReceivedAge(entry));
assertEquals(TimeValue.ofSeconds(6), impl.getCorrectedAgeValue(entry));
}
@Test
@ -122,7 +116,7 @@ public class TestCacheValidityPolicy {
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry();
impl = new CacheValidityPolicy() {
@Override
protected TimeValue getCorrectedReceivedAge(final HttpCacheEntry ent) {
protected TimeValue getCorrectedAgeValue(final HttpCacheEntry ent) {
return TimeValue.ofSeconds(7);
}
@ -131,7 +125,7 @@ public class TestCacheValidityPolicy {
return TimeValue.ofSeconds(13);
}
};
assertEquals(TimeValue.ofSeconds(20), impl.getCorrectedInitialAge(entry));
assertEquals(TimeValue.ofSeconds(7), impl.getCorrectedInitialAge(entry));
}
@Test
@ -345,11 +339,11 @@ public class TestCacheValidityPolicy {
}
@Test
public void testNegativeAgeHeaderValueReturnsMaxAge() {
public void testNegativeAgeHeaderValueReturnsZero() {
final Header[] headers = new Header[] { new BasicHeader("Age", "-100") };
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(headers);
// in seconds
assertEquals(CacheValidityPolicy.MAX_AGE.toSeconds(), impl.getAgeValue(entry));
assertEquals(0, impl.getAgeValue(entry));
}
@Test
@ -357,9 +351,43 @@ public class TestCacheValidityPolicy {
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(
new BasicHeader("Age", "asdf"));
// in seconds
assertEquals(CacheValidityPolicy.MAX_AGE.toSeconds(), impl.getAgeValue(entry));
assertEquals(0, impl.getAgeValue(entry));
}
@Test
public void testMalformedAgeHeaderMultipleWellFormedAges() {
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(
new BasicHeader("Age", "123,456,789"));
// in seconds
assertEquals(123, impl.getAgeValue(entry));
}
@Test
public void testMalformedAgeHeaderMultiplesMalformedAges() {
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(
new BasicHeader("Age", "123 456 789"));
// in seconds
assertEquals(0, impl.getAgeValue(entry));
}
@Test
public void testMalformedAgeHeaderNegativeAge() {
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(
new BasicHeader("Age", "-123"));
// in seconds
assertEquals(0, impl.getAgeValue(entry));
}
@Test
public void testMalformedAgeHeaderOverflow() {
final String reallyOldAge = "1" + Long.MAX_VALUE;
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(
new BasicHeader("Age", reallyOldAge));
// Expect the age value to be 0 in case of overflow
assertEquals(0, impl.getAgeValue(entry));
}
@Test
public void testMayReturnStaleIfErrorInResponseIsTrueWithinStaleness(){
final HttpCacheEntry entry = HttpTestUtils.makeCacheEntry(now, now,

View File

@ -26,6 +26,8 @@
*/
package org.apache.hc.client5.http.impl.cache;
import static org.hamcrest.MatcherAssert.assertThat;
import java.io.IOException;
import java.io.InputStream;
import java.net.SocketTimeoutException;
@ -2123,7 +2125,13 @@ public class TestProtocolRequirements {
final ClassicHttpResponse result = execute(request);
Assertions.assertEquals(200, result.getCode());
Assertions.assertEquals("11", result.getFirstHeader("Age").getValue());
// We calculate the age of the cache entry as per RFC 9111:
// We first find the "corrected_initial_age" which is the maximum of "apparentAge" and "correctedReceivedAge".
// In this case, max(1, 2) = 2 seconds.
// We then add the "residentTime" which is "now - responseTime",
// which is the current time minus the time the cache entry was created. In this case, that is 8 seconds.
// So, the total age is "corrected_initial_age" + "residentTime" = 2 + 8 = 10 seconds.
assertThat(result, ContainsHeaderMatcher.contains("Age", "10"));
}
/*
@ -4006,7 +4014,7 @@ public class TestProtocolRequirements {
final ClassicHttpResponse result = execute(request);
Assertions.assertEquals("2147483648",
Assertions.assertEquals(reallyOldAge,
result.getFirstHeader("Age").getValue());
}

View File

@ -530,10 +530,10 @@ public class TestResponseCachingPolicy {
}
@Test
public void testResponsesWithMultipleAgeHeadersAreNotCacheable() {
public void testResponsesWithMultipleAgeHeadersAreCacheable() {
response.addHeader("Age", "3");
response.addHeader("Age", "5");
Assertions.assertFalse(policy.isResponseCacheable(responseCacheControl, "GET", response));
Assertions.assertTrue(policy.isResponseCacheable(responseCacheControl, "GET", response));
}
@Test
@ -546,7 +546,7 @@ public class TestResponseCachingPolicy {
responseCacheControl = ResponseCacheControl.builder()
.setCachePublic(true)
.build();
Assertions.assertFalse(policy.isResponseCacheable(responseCacheControl, request, response));
Assertions.assertTrue(policy.isResponseCacheable(responseCacheControl, request, response));
}
@Test