diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HttpCacheContext.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HttpCacheContext.java
index 34df6b79a2..d95789ec8d 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HttpCacheContext.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HttpCacheContext.java
@@ -42,6 +42,7 @@
import org.apache.hc.client5.http.cookie.CookieSpec;
import org.apache.hc.client5.http.cookie.CookieSpecFactory;
import org.apache.hc.client5.http.cookie.CookieStore;
+import org.apache.hc.client5.http.impl.cache.CacheStatus;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.protocol.RedirectLocations;
import org.apache.hc.core5.annotation.Internal;
@@ -73,6 +74,7 @@ public class HttpCacheContext extends HttpClientContext {
static final String REQUEST_CACHE_CONTROL = "http.cache.request-control";
static final String RESPONSE_CACHE_CONTROL = "http.cache.response-control";
static final String CACHE_ENTRY = "http.cache.entry";
+ static final String CACHE_STATUS = "http.cache.status";
/**
* @deprecated Use {@link #castOrCreate(HttpContext)}.
@@ -118,10 +120,10 @@ public static HttpCacheContext create() {
return new HttpCacheContext();
}
- private CacheResponseStatus responseStatus;
private RequestCacheControl requestCacheControl;
private ResponseCacheControl responseCacheControl;
private HttpCacheEntry cacheEntry;
+ private CacheStatus cacheStatus;
public HttpCacheContext(final HttpContext context) {
super(context);
@@ -133,20 +135,12 @@ public HttpCacheContext() {
/**
* Represents an outcome of the cache operation and the way the response has been
- * generated.
- *
- * This context attribute is expected to be populated by the protocol handler.
+ * generated. The value is derived on the fly from the {@link CacheStatus} recorded for the
+ * exchange, which is the single source of truth for how the request was handled.
*/
public CacheResponseStatus getCacheResponseStatus() {
- return responseStatus;
- }
-
- /**
- * @since 5.4
- */
- @Internal
- public void setCacheResponseStatus(final CacheResponseStatus responseStatus) {
- this.responseStatus = responseStatus;
+ final CacheStatus status = getCacheStatus();
+ return status != null ? status.toResponseStatus() : null;
}
/**
@@ -228,6 +222,25 @@ public void setCacheEntry(final HttpCacheEntry cacheEntry) {
this.cacheEntry = cacheEntry;
}
+ /**
+ * Records how the cache handled the current exchange, used to render the RFC 9211
+ * {@code Cache-Status} response header.
+ *
+ * @since 5.7
+ */
+ @Internal
+ public CacheStatus getCacheStatus() {
+ return cacheStatus;
+ }
+
+ /**
+ * @since 5.7
+ */
+ @Internal
+ public void setCacheStatus(final CacheStatus cacheStatus) {
+ this.cacheStatus = cacheStatus;
+ }
+
/**
* Internal adaptor class that delegates all its method calls to {@link HttpClientContext}.
* To be removed in the future.
@@ -243,16 +256,6 @@ static class Delegate extends HttpCacheContext {
this.clientContext = clientContext;
}
- @Override
- public CacheResponseStatus getCacheResponseStatus() {
- return clientContext.getAttribute(CACHE_RESPONSE_STATUS, CacheResponseStatus.class);
- }
-
- @Override
- public void setCacheResponseStatus(final CacheResponseStatus responseStatus) {
- clientContext.setAttribute(CACHE_RESPONSE_STATUS, responseStatus);
- }
-
@Override
public RequestCacheControl getRequestCacheControl() {
return clientContext.getAttribute(REQUEST_CACHE_CONTROL, RequestCacheControl.class);
@@ -283,6 +286,16 @@ public void setCacheEntry(final HttpCacheEntry cacheEntry) {
clientContext.setAttribute(CACHE_ENTRY, cacheEntry);
}
+ @Override
+ public CacheStatus getCacheStatus() {
+ return clientContext.getAttribute(CACHE_STATUS, CacheStatus.class);
+ }
+
+ @Override
+ public void setCacheStatus(final CacheStatus cacheStatus) {
+ clientContext.setAttribute(CACHE_STATUS, cacheStatus);
+ }
+
@Override
public RouteInfo getHttpRoute() {
return clientContext.getHttpRoute();
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java
index a928ca75b2..29c5391c3a 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java
@@ -47,7 +47,6 @@
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
-import org.apache.hc.client5.http.cache.CacheResponseStatus;
import org.apache.hc.client5.http.cache.HttpCacheContext;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.cache.RequestCacheControl;
@@ -215,6 +214,7 @@ public AsyncDataConsumer handleResponse(
final EntityDetails entityDetails) throws HttpException, IOException {
context.setRequest(request);
context.setResponse(response);
+ applyCacheStatus(response, HttpCacheContext.cast(context));
return asyncExecCallback.handleResponse(response, entityDetails);
}
@@ -253,11 +253,13 @@ public void doExecute(
LOG.debug("{} request via cache: {} {}", exchangeId, request.getMethod(), request.getRequestUri());
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MISS);
context.setCacheEntry(null);
+ final CacheStatus cacheStatus = new CacheStatus();
+ cacheStatus.forward(CacheStatus.ForwardReason.MISS);
+ context.setCacheStatus(cacheStatus);
if (clientRequestsOurOptions(request)) {
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus.suppress();
triggerResponse(SimpleHttpResponse.create(HttpStatus.SC_NOT_IMPLEMENTED), scope, asyncExecCallback);
return;
}
@@ -267,6 +269,12 @@ public void doExecute(
if (LOG.isDebugEnabled()) {
LOG.debug("{} request cannot be correctly executed and cached", exchangeId);
}
+ // The request could not be represented for caching. A cache-supported method whose
+ // body/representation prevents processing is a bypass; any other method is not handled
+ // by the cache at all.
+ cacheStatus.forward(CacheSupport.isMethodCacheSupported(request.getMethod())
+ ? CacheStatus.ForwardReason.BYPASS
+ : CacheStatus.ForwardReason.METHOD);
chain.proceed(request, entityProducer, scope, asyncExecCallback);
return;
}
@@ -288,6 +296,11 @@ public void doExecute(
if (LOG.isDebugEnabled()) {
LOG.debug("{} request cannot be served from cache", exchangeId);
}
+ // The cache lookup is bypassed here (non-cacheable method, or a no-store / no-cache
+ // request), so no stored response was ever selected; this is not fwd=request.
+ cacheStatus.forward(CacheSupport.isMethodCacheSupported(cacheRequest.getMethod())
+ ? CacheStatus.ForwardReason.BYPASS
+ : CacheStatus.ForwardReason.METHOD);
callChain(cacheRequest, scope, chain, asyncExecCallback);
return;
}
@@ -937,7 +950,6 @@ private void handleCacheHit(
LOG.debug("{} cache hit: {} {}", exchangeId, request.getMethod(), request.getRequestUri());
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_HIT);
cacheHits.getAndIncrement();
final Instant now = getCurrentDate();
@@ -953,17 +965,18 @@ private void handleCacheHit(
try {
final SimpleHttpResponse cacheResponse = generateCachedResponse(request, hit.entry, now);
context.setCacheEntry(hit.entry);
+ cacheStatus(context).hit();
triggerResponse(cacheResponse, scope, asyncExecCallback);
} catch (final ResourceIOException ex) {
if (requestCacheControl.isOnlyIfCached()) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} request marked only-if-cached", exchangeId);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
final SimpleHttpResponse cacheResponse = generateGatewayTimeout();
triggerResponse(cacheResponse, scope, asyncExecCallback);
} else {
- context.setCacheResponseStatus(CacheResponseStatus.FAILURE);
+ cacheStatus(context).fail();
callChain(request, scope, chain, asyncExecCallback);
}
}
@@ -972,7 +985,7 @@ private void handleCacheHit(
if (LOG.isDebugEnabled()) {
LOG.debug("{} cache entry not is not fresh and only-if-cached requested", exchangeId);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
final SimpleHttpResponse cacheResponse = generateGatewayTimeout();
triggerResponse(cacheResponse, scope, asyncExecCallback);
} else if (cacheSuitability == CacheSuitability.MISMATCH) {
@@ -989,6 +1002,16 @@ private void handleCacheHit(
if (LOG.isDebugEnabled()) {
LOG.debug("{} revalidation required; revalidating cache entry", exchangeId);
}
+ // fwd=request only when a still-fresh stored response could not be used because of
+ // request semantics; a stale entry that must be revalidated is fwd=stale.
+ final boolean stale = validityPolicy.getCurrentAge(hit.entry, now)
+ .compareTo(validityPolicy.getFreshnessLifetime(responseCacheControl, hit.entry)) >= 0;
+ final boolean requestForced = requestCacheControl.isNoCache()
+ || requestCacheControl.getMaxAge() >= 0
+ || requestCacheControl.getMinFresh() >= 0;
+ cacheStatus(context).forward(!stale && requestForced
+ ? CacheStatus.ForwardReason.REQUEST
+ : CacheStatus.ForwardReason.STALE);
revalidateCacheEntryWithoutFallback(requestCacheControl, responseCacheControl, hit, target, request, scope, chain, asyncExecCallback);
} else if (cacheSuitability == CacheSuitability.STALE_WHILE_REVALIDATED) {
if (cacheRevalidator != null) {
@@ -1014,9 +1037,10 @@ private void handleCacheHit(
hit.getEntryKey(),
asyncExecCallback,
c -> revalidateCacheEntry(requestCacheControl, responseCacheControl, hit, target, request, fork, chain, c));
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
final SimpleHttpResponse cacheResponse = responseGenerator.generateResponse(request, hit.entry);
context.setCacheEntry(hit.entry);
+ cacheStatus(context).hit();
+ cacheStatus(context).moduleResponse();
triggerResponse(cacheResponse, scope, asyncExecCallback);
} catch (final IOException ex) {
asyncExecCallback.failed(ex);
@@ -1098,7 +1122,7 @@ public void cancelled() {
AsyncExecCallback evaluateResponse(final HttpResponse backendResponse, final Instant responseDate) {
final int statusCode = backendResponse.getCode();
if (statusCode == HttpStatus.SC_NOT_MODIFIED || statusCode == HttpStatus.SC_OK) {
- context.setCacheResponseStatus(CacheResponseStatus.VALIDATED);
+ cacheStatus(context).forwardStatus(statusCode);
cacheUpdates.getAndIncrement();
}
if (statusCode == HttpStatus.SC_NOT_MODIFIED) {
@@ -1241,7 +1265,7 @@ public void failed(final Exception cause) {
LOG.debug("{} I/O error while revalidating cache entry", exchangeId, cause);
}
final SimpleHttpResponse cacheResponse = generateGatewayTimeout();
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
triggerResponse(cacheResponse, scope, asyncExecCallback);
} else {
asyncExecCallback.failed(cause);
@@ -1262,6 +1286,7 @@ void revalidateCacheEntryWithFallback(
final AsyncExecCallback asyncExecCallback) {
final String exchangeId = scope.exchangeId;
final HttpCacheContext context = HttpCacheContext.cast(scope.clientContext);
+ cacheStatus(context).forward(CacheStatus.ForwardReason.STALE);
revalidateCacheEntry(requestCacheControl, responseCacheControl, hit, target, request, scope, chain, new AsyncExecCallback() {
private final AtomicReference committed = new AtomicReference<>();
@@ -1274,6 +1299,8 @@ public AsyncDataConsumer handleResponse(final HttpResponse response, final Entit
if (LOG.isDebugEnabled()) {
LOG.debug("{} serving stale response due to {} status and stale-if-error enabled", exchangeId, status);
}
+ cacheStatus(context).forwardStatus(status);
+ cacheStatus(context).moduleResponse();
return null;
}
committed.set(response);
@@ -1290,7 +1317,6 @@ public void completed() {
final HttpResponse response = committed.get();
if (response == null) {
try {
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
final SimpleHttpResponse cacheResponse = responseGenerator.generateResponse(request, hit.entry);
context.setCacheEntry(hit.entry);
triggerResponse(cacheResponse, scope, asyncExecCallback);
@@ -1309,7 +1335,6 @@ public void failed(final Exception cause) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} I/O error while revalidating cache entry", exchangeId, cause);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
if (cause instanceof IOException &&
suitabilityChecker.isSuitableIfError(requestCacheControl, responseCacheControl, hit.entry, getCurrentDate())) {
if (LOG.isDebugEnabled()) {
@@ -1318,11 +1343,13 @@ public void failed(final Exception cause) {
try {
final SimpleHttpResponse cacheResponse = responseGenerator.generateResponse(request, hit.entry);
context.setCacheEntry(hit.entry);
+ cacheStatus(context).moduleResponse();
triggerResponse(cacheResponse, scope, asyncExecCallback);
} catch (final IOException ex) {
asyncExecCallback.failed(cause);
}
} else {
+ cacheStatus(context).suppress();
final SimpleHttpResponse cacheResponse = generateGatewayTimeout();
triggerResponse(cacheResponse, scope, asyncExecCallback);
}
@@ -1354,7 +1381,7 @@ private void handleCacheMiss(
LOG.debug("{} request marked only-if-cached", exchangeId);
}
final HttpCacheContext context = HttpCacheContext.cast(scope.clientContext);
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
final SimpleHttpResponse cacheResponse = generateGatewayTimeout();
triggerResponse(cacheResponse, scope, asyncExecCallback);
return;
@@ -1370,6 +1397,7 @@ public void completed(final Collection variants) {
if (variants != null && !variants.isEmpty()) {
negotiateResponseFromVariants(requestCacheControl, target, request, scope, chain, asyncExecCallback, variants);
} else {
+ cacheStatus(HttpCacheContext.cast(scope.clientContext)).forward(CacheStatus.ForwardReason.VARY_MISS);
callBackend(requestCacheControl, target, request, scope, chain, asyncExecCallback);
}
}
@@ -1386,6 +1414,9 @@ public void cancelled() {
}));
} else {
+ cacheStatus(HttpCacheContext.cast(scope.clientContext)).forward(partialMatch != null
+ ? CacheStatus.ForwardReason.VARY_MISS
+ : CacheStatus.ForwardReason.URI_MISS);
callBackend(requestCacheControl, target, request, scope, chain, asyncExecCallback);
}
}
@@ -1400,6 +1431,7 @@ void negotiateResponseFromVariants(
final Collection variants) {
final String exchangeId = scope.exchangeId;
final CancellableDependency operation = scope.cancellableDependency;
+ cacheStatus(HttpCacheContext.cast(scope.clientContext)).forward(CacheStatus.ForwardReason.VARY_MISS);
final Map variantMap = new HashMap<>();
for (final CacheHit variant : variants) {
final ETag eTag = variant.entry.getETag();
@@ -1419,8 +1451,8 @@ void negotiateResponseFromVariants(
void updateVariantCacheEntry(final HttpResponse backendResponse, final Instant responseDate, final CacheHit match) {
final HttpCacheContext context = HttpCacheContext.cast(scope.clientContext);
- context.setCacheResponseStatus(CacheResponseStatus.VALIDATED);
cacheUpdates.getAndIncrement();
+ cacheStatus(context).forwardStatus(HttpStatus.SC_NOT_MODIFIED);
operation.setDependency(responseCache.storeFromNegotiated(
match,
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheConfig.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheConfig.java
index eb6c769188..60397b1c0f 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheConfig.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheConfig.java
@@ -123,6 +123,9 @@ public class CacheConfig implements Cloneable {
/** Default setting for the request-collapsing hint. */
public static final boolean DEFAULT_REQUEST_COLLAPSING_ENABLED = false;
+ /** Default setting for emitting the RFC 9211 {@code Cache-Status} response header. */
+ public static final boolean DEFAULT_CACHE_STATUS_ENABLED = false;
+
public static final CacheConfig DEFAULT = new Builder().build();
private final long maxObjectSize;
@@ -137,6 +140,7 @@ public class CacheConfig implements Cloneable {
private final boolean neverCacheHTTP10ResponsesWithQuery;
private final boolean staleIfErrorEnabled;
private final boolean requestCollapsingEnabled;
+ private final boolean cacheStatusEnabled;
/**
@@ -158,7 +162,8 @@ public class CacheConfig implements Cloneable {
final boolean neverCacheHTTP10ResponsesWithQuery,
final boolean neverCacheHTTP11ResponsesWithQuery,
final boolean staleIfErrorEnabled,
- final boolean requestCollapsingEnabled) {
+ final boolean requestCollapsingEnabled,
+ final boolean cacheStatusEnabled) {
super();
this.maxObjectSize = maxObjectSize;
this.maxCacheEntries = maxCacheEntries;
@@ -173,6 +178,7 @@ public class CacheConfig implements Cloneable {
this.neverCacheHTTP11ResponsesWithQuery = neverCacheHTTP11ResponsesWithQuery;
this.staleIfErrorEnabled = staleIfErrorEnabled;
this.requestCollapsingEnabled = requestCollapsingEnabled;
+ this.cacheStatusEnabled = cacheStatusEnabled;
}
/**
@@ -322,6 +328,17 @@ public boolean isRequestCollapsingEnabled() {
return requestCollapsingEnabled;
}
+ /**
+ * Determines whether the RFC 9211 {@code Cache-Status} response header is added, reporting how
+ * the cache handled the exchange (hit, miss or revalidated). Disabled by default.
+ *
+ * @return this instance.
+ * @since 5.7
+ */
+ public boolean isCacheStatusEnabled() {
+ return cacheStatusEnabled;
+ }
+
@Override
protected CacheConfig clone() throws CloneNotSupportedException {
return (CacheConfig) super.clone();
@@ -345,7 +362,8 @@ public static Builder copy(final CacheConfig config) {
.setNeverCacheHTTP10ResponsesWithQueryString(config.isNeverCacheHTTP10ResponsesWithQuery())
.setNeverCacheHTTP11ResponsesWithQueryString(config.isNeverCacheHTTP11ResponsesWithQuery())
.setStaleIfErrorEnabled(config.isStaleIfErrorEnabled())
- .setRequestCollapsingEnabled(config.isRequestCollapsingEnabled());
+ .setRequestCollapsingEnabled(config.isRequestCollapsingEnabled())
+ .setCacheStatusEnabled(config.isCacheStatusEnabled());
}
public static class Builder {
@@ -363,6 +381,7 @@ public static class Builder {
private boolean neverCacheHTTP11ResponsesWithQuery;
private boolean staleIfErrorEnabled;
private boolean requestCollapsingEnabled;
+ private boolean cacheStatusEnabled;
Builder() {
this.maxObjectSize = DEFAULT_MAX_OBJECT_SIZE_BYTES;
@@ -376,6 +395,7 @@ public static class Builder {
this.asynchronousWorkers = DEFAULT_ASYNCHRONOUS_WORKERS;
this.staleIfErrorEnabled = false;
this.requestCollapsingEnabled = DEFAULT_REQUEST_COLLAPSING_ENABLED;
+ this.cacheStatusEnabled = DEFAULT_CACHE_STATUS_ENABLED;
}
/**
@@ -559,6 +579,18 @@ public Builder setRequestCollapsingEnabled(final boolean requestCollapsingEnable
return this;
}
+ /**
+ * Enables the RFC 9211 {@code Cache-Status} response header, reporting how the cache
+ * handled the exchange (hit, miss or revalidated). Disabled by default.
+ *
+ * @return this instance.
+ * @since 5.7
+ */
+ public Builder setCacheStatusEnabled(final boolean cacheStatusEnabled) {
+ this.cacheStatusEnabled = cacheStatusEnabled;
+ return this;
+ }
+
public CacheConfig build() {
return new CacheConfig(
maxObjectSize,
@@ -573,7 +605,8 @@ public CacheConfig build() {
neverCacheHTTP10ResponsesWithQuery,
neverCacheHTTP11ResponsesWithQuery,
staleIfErrorEnabled,
- requestCollapsingEnabled);
+ requestCollapsingEnabled,
+ cacheStatusEnabled);
}
}
@@ -594,6 +627,7 @@ public String toString() {
.append(", neverCacheHTTP11ResponsesWithQuery=").append(this.neverCacheHTTP11ResponsesWithQuery)
.append(", staleIfErrorEnabled=").append(this.staleIfErrorEnabled)
.append(", requestCollapsingEnabled=").append(this.requestCollapsingEnabled)
+ .append(", cacheStatusEnabled=").append(this.cacheStatusEnabled)
.append("]");
return builder.toString();
}
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatus.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatus.java
new file mode 100644
index 0000000000..24ef3e325e
--- /dev/null
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatus.java
@@ -0,0 +1,183 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.cache;
+
+import org.apache.hc.client5.http.cache.CacheResponseStatus;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.HttpStatus;
+
+/**
+ * Mutable, per-exchange record of how the cache handled a request. The executor populates it at the
+ * point where it decides to satisfy the request from a stored response or to forward it to the next
+ * hop, and {@link CacheStatusHeaderGenerator} serialises it into the RFC 9211 {@code Cache-Status}
+ * response header. It is kept separate from {@link org.apache.hc.client5.http.cache.CacheResponseStatus}
+ * because the latter is too coarse to describe the disposition RFC 9211 requires.
+ *
+ * @since 5.7
+ */
+@Internal
+public final class CacheStatus {
+
+ /**
+ * Reason a request was forwarded to the next hop, as reported by the RFC 9211 {@code fwd}
+ * parameter. The token values are defined once here rather than inline at the call sites.
+ */
+ enum ForwardReason {
+
+ BYPASS("bypass"),
+ METHOD("method"),
+ URI_MISS("uri-miss"),
+ VARY_MISS("vary-miss"),
+ MISS("miss"),
+ REQUEST("request"),
+ STALE("stale"),
+ PARTIAL("partial");
+
+ final String token;
+
+ ForwardReason(final String token) {
+ this.token = token;
+ }
+
+ }
+
+ private boolean hit;
+ private ForwardReason forwardReason;
+ private Integer forwardStatus;
+ private boolean suppressed;
+ private boolean failed;
+ private boolean moduleResponse;
+
+ /**
+ * Records that the request was satisfied from a stored response without contacting the next hop.
+ */
+ void hit() {
+ this.hit = true;
+ this.forwardReason = null;
+ }
+
+ /**
+ * Records that the request was forwarded to the next hop for the given reason.
+ */
+ void forward(final ForwardReason reason) {
+ this.hit = false;
+ this.forwardReason = reason;
+ }
+
+ /**
+ * Records the status received from the next hop when it differs from the status delivered to the
+ * caller, for example a {@code 304} that produced a stored {@code 200}.
+ */
+ void forwardStatus(final int status) {
+ this.forwardStatus = status;
+ }
+
+ /**
+ * Marks the response as locally generated and not based on a stored response, so that no
+ * {@code Cache-Status} header is emitted (RFC 9211 section 2).
+ */
+ void suppress() {
+ this.suppressed = true;
+ }
+
+ /**
+ * Records that the cache could not serve or store the response because of an internal error and
+ * fell back to the next hop. This disposition is not part of the RFC 9211 {@code Cache-Status}
+ * vocabulary, so it is never serialised; it only feeds {@link #toResponseStatus()}.
+ */
+ void fail() {
+ this.failed = true;
+ }
+
+ /**
+ * Records that the cache served a stored response on a stale path that the module owns, such as
+ * stale-while-revalidate or stale-if-error. Unlike {@link #suppress()} this does not suppress the
+ * {@code Cache-Status} header; the underlying hit or forward disposition is still serialised. It
+ * only maps the exchange to {@link CacheResponseStatus#CACHE_MODULE_RESPONSE}.
+ */
+ void moduleResponse() {
+ this.moduleResponse = true;
+ }
+
+ boolean isHit() {
+ return hit;
+ }
+
+ ForwardReason getForwardReason() {
+ return forwardReason;
+ }
+
+ Integer getForwardStatus() {
+ return forwardStatus;
+ }
+
+ boolean isSuppressed() {
+ return suppressed;
+ }
+
+ boolean isFailed() {
+ return failed;
+ }
+
+ boolean isModuleResponse() {
+ return moduleResponse;
+ }
+
+ boolean isRecorded() {
+ return hit || forwardReason != null;
+ }
+
+ /**
+ * Derives the coarse {@link CacheResponseStatus} disposition from this record, which is the
+ * single source of truth for how the exchange was handled. A locally generated response or a
+ * stale response served by the module (stale-while-revalidate, stale-if-error) maps to
+ * {@link CacheResponseStatus#CACHE_MODULE_RESPONSE}, an internal failure to
+ * {@link CacheResponseStatus#FAILURE}, a response served from a stored entry to
+ * {@link CacheResponseStatus#CACHE_HIT}, a successful revalidation of a stored entry (a
+ * non-error status received from the next hop, i.e. a {@code 304} or a fresh {@code 200}) to
+ * {@link CacheResponseStatus#VALIDATED}, and any other forwarded response to
+ * {@link CacheResponseStatus#CACHE_MISS}. Returns {@code null} when nothing has been recorded.
+ */
+ public CacheResponseStatus toResponseStatus() {
+ if (suppressed || moduleResponse) {
+ return CacheResponseStatus.CACHE_MODULE_RESPONSE;
+ }
+ if (failed) {
+ return CacheResponseStatus.FAILURE;
+ }
+ if (hit) {
+ return CacheResponseStatus.CACHE_HIT;
+ }
+ if (forwardReason != null) {
+ return forwardStatus != null && forwardStatus < HttpStatus.SC_BAD_REQUEST
+ ? CacheResponseStatus.VALIDATED
+ : CacheResponseStatus.CACHE_MISS;
+ }
+ return null;
+ }
+
+}
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatusHeaderGenerator.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatusHeaderGenerator.java
new file mode 100644
index 0000000000..d35c457bd7
--- /dev/null
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatusHeaderGenerator.java
@@ -0,0 +1,72 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.cache;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.message.BasicHeader;
+
+/**
+ * Serialises a {@link CacheStatus} into the RFC 9211 {@code Cache-Status} response header. This
+ * class only formats the disposition recorded by the executor; it does not infer what happened.
+ *
+ * @since 5.7
+ */
+@Internal
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+final class CacheStatusHeaderGenerator {
+
+ public static final CacheStatusHeaderGenerator INSTANCE = new CacheStatusHeaderGenerator();
+
+ static final String HEADER_NAME = "Cache-Status";
+
+ /**
+ * Identifier of this cache in the {@code Cache-Status} list, a structured-field token per
+ * RFC 9211 section 2.
+ */
+ static final String CACHE_IDENTIFIER = "Apache-HttpClient";
+
+ Header generate(final CacheStatus status) {
+ if (status == null || status.isSuppressed() || !status.isRecorded()) {
+ return null;
+ }
+ final StringBuilder buf = new StringBuilder(CACHE_IDENTIFIER);
+ if (status.isHit()) {
+ buf.append("; hit");
+ } else {
+ buf.append("; fwd=").append(status.getForwardReason().token);
+ final Integer forwardStatus = status.getForwardStatus();
+ if (forwardStatus != null) {
+ buf.append("; fwd-status=").append(forwardStatus.intValue());
+ }
+ }
+ return new BasicHeader(HEADER_NAME, buf.toString());
+ }
+
+}
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheSupport.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheSupport.java
index f61e88eac3..19ce3cbb98 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheSupport.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheSupport.java
@@ -36,6 +36,7 @@
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.MessageHeaders;
+import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.URIScheme;
import org.apache.hc.core5.net.URIAuthority;
import org.apache.hc.core5.net.URIBuilder;
@@ -193,6 +194,20 @@ public static boolean isSameOrigin(final URI requestURI, final URI targetURI) {
return targetURI.isAbsolute() && Objects.equals(requestURI.getAuthority(), targetURI.getAuthority());
}
+ /**
+ * Determines whether the given request method is one the cache is able to handle, that is
+ * {@code GET}, {@code HEAD} or {@code QUERY}.
+ *
+ * @param method the request method
+ * @return {@code true} if the method is supported by the cache
+ * @since 5.7
+ */
+ public static boolean isMethodCacheSupported(final String method) {
+ return Method.GET.isSame(method)
+ || Method.HEAD.isSame(method)
+ || Method.QUERY.isSame(method);
+ }
+
public static final TimeValue MAX_AGE = TimeValue.ofSeconds(Integer.MAX_VALUE + 1L);
public static long deltaSeconds(final String s) {
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java
index 3006224763..dec09f5e04 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java
@@ -29,7 +29,6 @@
import org.apache.hc.client5.http.cache.RequestCacheControl;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpVersion;
-import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.ProtocolVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -57,7 +56,7 @@ public boolean canBeServedFromCache(final RequestCacheControl cacheControl, fina
return false;
}
- if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method) && !Method.QUERY.isSame(method)) {
+ if (!CacheSupport.isMethodCacheSupported(method)) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} request cannot be served from cache", method);
}
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java
index 1b745ffce7..116e994240 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java
@@ -39,7 +39,6 @@
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
-import org.apache.hc.client5.http.cache.CacheResponseStatus;
import org.apache.hc.client5.http.cache.HttpCacheContext;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.cache.HttpCacheStorage;
@@ -134,6 +133,7 @@ public ClassicHttpResponse execute(
context.setRequest(request);
context.setResponse(response);
+ applyCacheStatus(response, HttpCacheContext.cast(context));
return response;
}
@@ -150,11 +150,13 @@ ClassicHttpResponse doExecute(
LOG.debug("{} request via cache: {} {}", exchangeId, request.getMethod(), request.getRequestUri());
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MISS);
context.setCacheEntry(null);
+ final CacheStatus cacheStatus = new CacheStatus();
+ cacheStatus.forward(CacheStatus.ForwardReason.MISS);
+ context.setCacheStatus(cacheStatus);
if (clientRequestsOurOptions(request)) {
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus.suppress();
return new BasicClassicHttpResponse(HttpStatus.SC_NOT_IMPLEMENTED);
}
@@ -163,6 +165,12 @@ ClassicHttpResponse doExecute(
if (LOG.isDebugEnabled()) {
LOG.debug("{} request cannot be correctly executed and cached", exchangeId);
}
+ // The request could not be represented for caching. A cache-supported method whose
+ // body/representation prevents processing is a bypass; any other method is not handled
+ // by the cache at all.
+ cacheStatus.forward(CacheSupport.isMethodCacheSupported(request.getMethod())
+ ? CacheStatus.ForwardReason.BYPASS
+ : CacheStatus.ForwardReason.METHOD);
return chain.proceed(request, scope);
}
@@ -181,6 +189,11 @@ ClassicHttpResponse doExecute(
if (LOG.isDebugEnabled()) {
LOG.debug("{} request cannot be served from cache", exchangeId);
}
+ // The cache lookup is bypassed here (non-cacheable method, or a no-store / no-cache
+ // request), so no stored response was ever selected; this is not fwd=request.
+ cacheStatus.forward(CacheSupport.isMethodCacheSupported(request.getMethod())
+ ? CacheStatus.ForwardReason.BYPASS
+ : CacheStatus.ForwardReason.METHOD);
return callBackend(requestCacheControl, target, cacheRequest, scope, chain);
}
@@ -307,7 +320,6 @@ private ClassicHttpResponse handleCacheHit(
LOG.debug("{} cache hit: {} {}", exchangeId, request.getMethod(), request.getRequestUri());
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_HIT);
cacheHits.getAndIncrement();
final Instant now = getCurrentDate();
@@ -323,16 +335,17 @@ private ClassicHttpResponse handleCacheHit(
try {
final SimpleHttpResponse cacheResponse = generateCachedResponse(request, hit.entry, now);
context.setCacheEntry(hit.entry);
+ cacheStatus(context).hit();
return convert(cacheResponse);
} catch (final ResourceIOException ex) {
if (requestCacheControl.isOnlyIfCached()) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} request marked only-if-cached", exchangeId);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
return convert(generateGatewayTimeout());
}
- context.setCacheResponseStatus(CacheResponseStatus.FAILURE);
+ cacheStatus(context).fail();
return callChain(request, scope, chain);
}
}
@@ -340,7 +353,7 @@ private ClassicHttpResponse handleCacheHit(
if (LOG.isDebugEnabled()) {
LOG.debug("{} cache entry not is not fresh and only-if-cached requested", exchangeId);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
return convert(generateGatewayTimeout());
} else if (cacheSuitability == CacheSuitability.MISMATCH) {
if (LOG.isDebugEnabled()) {
@@ -356,6 +369,16 @@ private ClassicHttpResponse handleCacheHit(
if (LOG.isDebugEnabled()) {
LOG.debug("{} revalidation required; revalidating cache entry", exchangeId);
}
+ // fwd=request only when a still-fresh stored response could not be used because of
+ // request semantics; a stale entry that must be revalidated is fwd=stale.
+ final boolean stale = validityPolicy.getCurrentAge(hit.entry, now)
+ .compareTo(validityPolicy.getFreshnessLifetime(responseCacheControl, hit.entry)) >= 0;
+ final boolean requestForced = requestCacheControl.isNoCache()
+ || requestCacheControl.getMaxAge() >= 0
+ || requestCacheControl.getMinFresh() >= 0;
+ cacheStatus(context).forward(!stale && requestForced
+ ? CacheStatus.ForwardReason.REQUEST
+ : CacheStatus.ForwardReason.STALE);
return revalidateCacheEntryWithoutFallback(requestCacheControl, responseCacheControl, hit, target, request, scope, chain);
} else if (cacheSuitability == CacheSuitability.STALE_WHILE_REVALIDATED) {
if (cacheRevalidator != null) {
@@ -376,9 +399,10 @@ private ClassicHttpResponse handleCacheHit(
cacheRevalidator.revalidateCacheEntry(
hit.getEntryKey(),
() -> revalidateCacheEntry(requestCacheControl, responseCacheControl, hit, target, request, fork, chain));
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
final SimpleHttpResponse cacheResponse = responseGenerator.generateResponse(request, hit.entry);
context.setCacheEntry(hit.entry);
+ cacheStatus(context).hit();
+ cacheStatus(context).moduleResponse();
return convert(cacheResponse);
}
if (LOG.isDebugEnabled()) {
@@ -425,7 +449,7 @@ ClassicHttpResponse revalidateCacheEntry(
final int statusCode = backendResponse.getCode();
if (statusCode == HttpStatus.SC_NOT_MODIFIED || statusCode == HttpStatus.SC_OK) {
- context.setCacheResponseStatus(CacheResponseStatus.VALIDATED);
+ cacheStatus(context).forwardStatus(statusCode);
cacheUpdates.getAndIncrement();
}
if (statusCode == HttpStatus.SC_NOT_MODIFIED) {
@@ -457,7 +481,7 @@ ClassicHttpResponse revalidateCacheEntryWithoutFallback(
if (LOG.isDebugEnabled()) {
LOG.debug("{} I/O error while revalidating cache entry", exchangeId, ex);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
return convert(generateGatewayTimeout());
}
}
@@ -472,6 +496,7 @@ ClassicHttpResponse revalidateCacheEntryWithFallback(
final ExecChain chain) throws HttpException, IOException {
final String exchangeId = scope.exchangeId;
final HttpCacheContext context = HttpCacheContext.cast(scope.clientContext);
+ cacheStatus(context).forward(CacheStatus.ForwardReason.STALE);
final ClassicHttpResponse response;
try {
response = revalidateCacheEntry(requestCacheControl, responseCacheControl, hit, target, request, scope, chain);
@@ -479,15 +504,16 @@ ClassicHttpResponse revalidateCacheEntryWithFallback(
if (LOG.isDebugEnabled()) {
LOG.debug("{} I/O error while revalidating cache entry", exchangeId, ex);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
if (suitabilityChecker.isSuitableIfError(requestCacheControl, responseCacheControl, hit.entry, getCurrentDate())) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} serving stale response due to IOException and stale-if-error enabled", exchangeId);
}
final SimpleHttpResponse cacheResponse = responseGenerator.generateResponse(request, hit.entry);
context.setCacheEntry(hit.entry);
+ cacheStatus(context).moduleResponse();
return convert(cacheResponse);
}
+ cacheStatus(context).suppress();
return convert(generateGatewayTimeout());
}
final int status = response.getCode();
@@ -497,7 +523,8 @@ ClassicHttpResponse revalidateCacheEntryWithFallback(
LOG.debug("{} serving stale response due to {} status and stale-if-error enabled", exchangeId, status);
}
EntityUtils.consume(response.getEntity());
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).forwardStatus(status);
+ cacheStatus(context).moduleResponse();
final SimpleHttpResponse cacheResponse = responseGenerator.generateResponse(request, hit.entry);
context.setCacheEntry(hit.entry);
return convert(cacheResponse);
@@ -635,7 +662,7 @@ private ClassicHttpResponse handleCacheMiss(
if (LOG.isDebugEnabled()) {
LOG.debug("{} request marked only-if-cached", exchangeId);
}
- context.setCacheResponseStatus(CacheResponseStatus.CACHE_MODULE_RESPONSE);
+ cacheStatus(context).suppress();
return convert(generateGatewayTimeout());
}
if (partialMatch != null && partialMatch.entry.hasVariants() && request.getBody() == null) {
@@ -645,6 +672,11 @@ private ClassicHttpResponse handleCacheMiss(
}
}
+ // A URI-level match that could not select a stored variant is a vary-miss; no match at all
+ // is a uri-miss.
+ cacheStatus(context).forward(partialMatch != null
+ ? CacheStatus.ForwardReason.VARY_MISS
+ : CacheStatus.ForwardReason.URI_MISS);
return callBackend(requestCacheControl, target, request, scope, chain);
}
@@ -656,6 +688,7 @@ ClassicHttpResponse negotiateResponseFromVariants(
final ExecChain chain,
final List variants) throws IOException, HttpException {
final String exchangeId = scope.exchangeId;
+ cacheStatus(HttpCacheContext.cast(scope.clientContext)).forward(CacheStatus.ForwardReason.VARY_MISS);
final Map variantMap = new HashMap<>();
for (final CacheHit variant : variants) {
@@ -702,8 +735,8 @@ ClassicHttpResponse negotiateResponseFromVariants(
}
final HttpCacheContext context = HttpCacheContext.cast(scope.clientContext);
- context.setCacheResponseStatus(CacheResponseStatus.VALIDATED);
cacheUpdates.getAndIncrement();
+ cacheStatus(context).forwardStatus(HttpStatus.SC_NOT_MODIFIED);
final CacheHit hit = responseCache.storeFromNegotiated(match, target, request, backendResponse, requestDate, responseDate);
final SimpleHttpResponse cacheResponse = generateCachedResponse(request, hit.entry, responseDate);
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java
index 53b6a92b6c..3a90e2d6fa 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java
@@ -30,6 +30,7 @@
import java.util.concurrent.atomic.AtomicLong;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.cache.HttpCacheContext;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.cache.ResourceIOException;
import org.apache.hc.core5.http.EntityDetails;
@@ -129,6 +130,35 @@ SimpleHttpResponse generateGatewayTimeout() {
return SimpleHttpResponse.create(HttpStatus.SC_GATEWAY_TIMEOUT, "Gateway Timeout");
}
+ /**
+ * Returns the {@link CacheStatus} for the current exchange, creating and attaching a fresh one
+ * when absent.
+ */
+ CacheStatus cacheStatus(final HttpCacheContext context) {
+ CacheStatus cacheStatus = context.getCacheStatus();
+ if (cacheStatus == null) {
+ cacheStatus = new CacheStatus();
+ context.setCacheStatus(cacheStatus);
+ }
+ return cacheStatus;
+ }
+
+ /**
+ * Adds the RFC 9211 {@code Cache-Status} header to the response when enabled, describing how the
+ * cache handled the exchange as recorded on the context.
+ */
+ void applyCacheStatus(final HttpResponse response, final HttpCacheContext context) {
+ if (response != null && cacheConfig.isCacheStatusEnabled()) {
+ final CacheStatus cacheStatus = context.getCacheStatus();
+ if (cacheStatus != null) {
+ final Header header = CacheStatusHeaderGenerator.INSTANCE.generate(cacheStatus);
+ if (header != null) {
+ response.addHeader(header);
+ }
+ }
+ }
+ }
+
Instant getCurrentDate() {
return Instant.now();
}
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 77b38a493f..b49a7b4805 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
@@ -38,7 +38,6 @@
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.HttpVersion;
-import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.ProtocolVersion;
import org.apache.hc.core5.http.message.MessageSupport;
import org.slf4j.Logger;
@@ -108,7 +107,7 @@ public boolean isResponseCacheable(final RequestCacheControl requestCacheControl
// Presently only GET, HEAD and QUERY methods are supported
final String httpMethod = request.getMethod();
- if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod) && !Method.QUERY.isSame(httpMethod)) {
+ if (!CacheSupport.isMethodCacheSupported(httpMethod)) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} method response is not cacheable", httpMethod);
}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/cache/example/ClientCacheStatus.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/cache/example/ClientCacheStatus.java
new file mode 100644
index 0000000000..14001f1542
--- /dev/null
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/cache/example/ClientCacheStatus.java
@@ -0,0 +1,90 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.cache.example;
+
+import org.apache.hc.client5.http.cache.CacheContextBuilder;
+import org.apache.hc.client5.http.cache.HttpCacheContext;
+import org.apache.hc.client5.http.cache.RequestCacheControl;
+import org.apache.hc.client5.http.impl.cache.CacheConfig;
+import org.apache.hc.client5.http.impl.cache.CachingHttpClients;
+import org.apache.hc.client5.http.impl.cache.HeapResourceFactory;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
+import org.apache.hc.core5.http.message.StatusLine;
+
+/**
+ * This example demonstrates the RFC 9211 {@code Cache-Status} response header, which reports how
+ * the cache handled each exchange. It is opt-in through
+ * {@link CacheConfig.Builder#setCacheStatusEnabled(boolean)}.
+ */
+public class ClientCacheStatus {
+
+ public static void main(final String[] args) throws Exception {
+
+ final HttpHost target = new HttpHost("https", "www.apache.org");
+
+ try (final CloseableHttpClient httpclient = CachingHttpClients.custom()
+ .setCacheConfig(CacheConfig.custom()
+ .setMaxObjectSize(200000)
+ .setHeuristicCachingEnabled(true)
+ .setCacheStatusEnabled(true)
+ .build())
+ .setResourceFactory(HeapResourceFactory.INSTANCE)
+ .build()) {
+
+ final HttpCacheContext context = CacheContextBuilder.create()
+ .setCacheControl(RequestCacheControl.DEFAULT)
+ .build();
+
+ // The first request is forwarded to the origin (fwd=miss); a fresh, cacheable response
+ // then lets the second identical request be served from the cache (hit).
+ for (int i = 1; i <= 2; i++) {
+ final ClassicHttpRequest httpget = ClassicRequestBuilder.get()
+ .setHttpHost(target)
+ .setPath("/")
+ .build();
+
+ System.out.println("Executing request " + httpget.getMethod() + " " + httpget.getUri());
+ httpclient.execute(httpget, context, response -> {
+ System.out.println("----------------------------------------");
+ System.out.println(httpget + "->" + new StatusLine(response));
+ EntityUtils.consume(response.getEntity());
+ final Header cacheStatus = response.getFirstHeader("Cache-Status");
+ System.out.println("Cache-Status header: "
+ + (cacheStatus != null ? cacheStatus.getValue() : ""));
+ System.out.println("Cache response status: " + context.getCacheResponseStatus());
+ return null;
+ });
+ }
+ }
+ }
+
+}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCacheStatus.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCacheStatus.java
new file mode 100644
index 0000000000..59dd64dbe9
--- /dev/null
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCacheStatus.java
@@ -0,0 +1,97 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.cache;
+
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
+import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer;
+import org.apache.hc.client5.http.cache.HttpCacheContext;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.client5.http.utils.DateUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.sun.net.httpserver.HttpServer;
+
+class TestAsyncCacheStatus {
+
+ @Test
+ void testCacheStatusMissThenHit() throws Exception {
+ final HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext("/", exchange -> {
+ final byte[] body = "OK".getBytes(StandardCharsets.US_ASCII);
+ exchange.getResponseHeaders().add("Cache-Control", "public, max-age=60");
+ exchange.getResponseHeaders().add("Date", DateUtils.formatStandardDate(Instant.now()));
+ exchange.getResponseHeaders().add("ETag", "\"v1\"");
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(body);
+ }
+ });
+ final ExecutorService executorService = Executors.newCachedThreadPool();
+ server.setExecutor(executorService);
+ server.start();
+ try {
+ final HttpHost target = new HttpHost("http", "localhost", server.getAddress().getPort());
+ try (final CloseableHttpAsyncClient client = CachingHttpAsyncClients.custom()
+ .setCacheConfig(CacheConfig.custom().setCacheStatusEnabled(true).build())
+ .setResourceFactory(HeapResourceFactory.INSTANCE)
+ .build()) {
+ client.start();
+
+ final SimpleHttpResponse miss = client.execute(
+ SimpleRequestProducer.create(SimpleRequestBuilder.get().setHttpHost(target).setPath("/").build()),
+ SimpleResponseConsumer.create(), HttpCacheContext.create(), null).get(30, TimeUnit.SECONDS);
+ Assertions.assertEquals("Apache-HttpClient; fwd=uri-miss",
+ miss.getFirstHeader("Cache-Status").getValue());
+
+ final SimpleHttpResponse hit = client.execute(
+ SimpleRequestProducer.create(SimpleRequestBuilder.get().setHttpHost(target).setPath("/").build()),
+ SimpleResponseConsumer.create(), HttpCacheContext.create(), null).get(30, TimeUnit.SECONDS);
+ Assertions.assertEquals("Apache-HttpClient; hit",
+ hit.getFirstHeader("Cache-Status").getValue());
+
+ client.close(CloseMode.GRACEFUL);
+ }
+ } finally {
+ server.stop(0);
+ executorService.shutdownNow();
+ }
+ }
+
+}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheStatusHeaderGenerator.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheStatusHeaderGenerator.java
new file mode 100644
index 0000000000..cc993d476f
--- /dev/null
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheStatusHeaderGenerator.java
@@ -0,0 +1,113 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.cache;
+
+import org.apache.hc.core5.http.Header;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestCacheStatusHeaderGenerator {
+
+ private final CacheStatusHeaderGenerator generator = CacheStatusHeaderGenerator.INSTANCE;
+
+ private String value(final CacheStatus status) {
+ final Header header = generator.generate(status);
+ Assertions.assertNotNull(header);
+ Assertions.assertEquals("Cache-Status", header.getName());
+ return header.getValue();
+ }
+
+ @Test
+ void testHit() {
+ final CacheStatus status = new CacheStatus();
+ status.hit();
+ Assertions.assertEquals("Apache-HttpClient; hit", value(status));
+ }
+
+ @Test
+ void testForwardMiss() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.MISS);
+ Assertions.assertEquals("Apache-HttpClient; fwd=miss", value(status));
+ }
+
+ @Test
+ void testForwardRequest() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.REQUEST);
+ Assertions.assertEquals("Apache-HttpClient; fwd=request", value(status));
+ }
+
+ @Test
+ void testForwardStaleWithUpstreamStatus() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.STALE);
+ status.forwardStatus(304);
+ Assertions.assertEquals("Apache-HttpClient; fwd=stale; fwd-status=304", value(status));
+ }
+
+ @Test
+ void testForwardUriMiss() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.URI_MISS);
+ Assertions.assertEquals("Apache-HttpClient; fwd=uri-miss", value(status));
+ }
+
+ @Test
+ void testForwardVaryMissWithUpstreamStatus() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.VARY_MISS);
+ status.forwardStatus(304);
+ Assertions.assertEquals("Apache-HttpClient; fwd=vary-miss; fwd-status=304", value(status));
+ }
+
+ @Test
+ void testForwardBypass() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.BYPASS);
+ Assertions.assertEquals("Apache-HttpClient; fwd=bypass", value(status));
+ }
+
+ @Test
+ void testSuppressedYieldsNoHeader() {
+ final CacheStatus status = new CacheStatus();
+ status.forward(CacheStatus.ForwardReason.MISS);
+ status.suppress();
+ Assertions.assertNull(generator.generate(status));
+ }
+
+ @Test
+ void testUnrecordedYieldsNoHeader() {
+ Assertions.assertNull(generator.generate(new CacheStatus()));
+ }
+
+ @Test
+ void testNullYieldsNoHeader() {
+ Assertions.assertNull(generator.generate(null));
+ }
+
+}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java
index 2a05f79463..6af29565ab 100644
--- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java
@@ -158,6 +158,215 @@ void testResponseToRequestWithNoStoreIsNotCached() throws Exception {
Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
}
+ @Test
+ void testCacheStatusHeaderReportsMissThenHitWhenEnabled() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+
+ final ClassicHttpRequest req1 = HttpTestUtils.makeDefaultRequest();
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Cache-Control", "max-age=3600");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+
+ final ClassicHttpResponse miss = execute(req1);
+ Assertions.assertEquals("Apache-HttpClient; fwd=uri-miss",
+ miss.getFirstHeader("Cache-Status").getValue());
+
+ final ClassicHttpResponse hit = execute(HttpTestUtils.makeDefaultRequest());
+ Assertions.assertEquals("Apache-HttpClient; hit",
+ hit.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusHeaderAbsentByDefault() throws Exception {
+ final ClassicHttpRequest req1 = HttpTestUtils.makeDefaultRequest();
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Cache-Control", "max-age=3600");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+
+ final ClassicHttpResponse result = execute(req1);
+ Assertions.assertNull(result.getFirstHeader("Cache-Status"));
+ }
+
+ @Test
+ void testCacheStatusBypassOnNoCacheRequest() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Cache-Control", "max-age=3600");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+ execute(HttpTestUtils.makeDefaultRequest());
+
+ // A no-cache request bypasses cache lookup entirely; no stored response is ever selected,
+ // so the reason is 'bypass', not 'request'.
+ final ClassicHttpRequest req2 = HttpTestUtils.makeDefaultRequest();
+ req2.setHeader("Cache-Control", "no-cache");
+ final ClassicHttpResponse resp2 = HttpTestUtils.make200Response();
+ resp2.setHeader("Cache-Control", "max-age=3600");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp2);
+
+ final ClassicHttpResponse result = execute(req2);
+ Assertions.assertEquals("Apache-HttpClient; fwd=bypass",
+ result.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusMethodWhenRequestMethodNotSupportedByCache() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+
+ // POST is not a cache-supported method; even with a repeatable body it cannot be represented
+ // for caching, so the reason is 'method'.
+ final ClassicHttpRequest post = new BasicClassicHttpRequest("POST", "/");
+ post.setEntity(new StringEntity("payload"));
+ final ClassicHttpResponse resp = HttpTestUtils.make200Response();
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp);
+
+ final ClassicHttpResponse result = execute(post);
+ Assertions.assertEquals("Apache-HttpClient; fwd=method",
+ result.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusBypassWhenCacheSupportedMethodCannotBeRepresented() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+
+ // QUERY is cache-supported, but a non-repeatable body prevents the cache from representing
+ // the request, so the reason is 'bypass'.
+ final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/");
+ query.setEntity(new InputStreamEntity(new ByteArrayInputStream(new byte[]{1, 2, 3}),
+ ContentType.APPLICATION_OCTET_STREAM));
+ final ClassicHttpResponse resp = HttpTestUtils.make200Response();
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp);
+
+ final ClassicHttpResponse result = execute(query);
+ Assertions.assertEquals("Apache-HttpClient; fwd=bypass",
+ result.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusForwardRequestWhenRequestMaxAgeForcesRevalidationOfFreshEntry() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Date", DateUtils.formatStandardDate(Instant.now().minusSeconds(5)));
+ resp1.setHeader("Cache-Control", "max-age=3600"); // still fresh by the response's own lifetime
+ resp1.setHeader("ETag", "\"etag\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+ execute(HttpTestUtils.makeDefaultRequest());
+
+ // request max-age=0 forces revalidation of a still-fresh stored response -> fwd=request (not stale).
+ final ClassicHttpRequest req2 = HttpTestUtils.makeDefaultRequest();
+ req2.setHeader("Cache-Control", "max-age=0");
+ final ClassicHttpResponse resp304 = HttpTestUtils.make304Response();
+ resp304.setHeader("Date", DateUtils.formatStandardDate(Instant.now()));
+ resp304.setHeader("ETag", "\"etag\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp304);
+
+ final ClassicHttpResponse result = execute(req2);
+ Assertions.assertTrue(result.getFirstHeader("Cache-Status").getValue()
+ .startsWith("Apache-HttpClient; fwd=request"), result.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusVaryMissOnVariantNegotiation() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+
+ // Store a first variant keyed on Accept-Encoding.
+ final ClassicHttpRequest req1 = HttpTestUtils.makeDefaultRequest();
+ req1.setHeader("Accept-Encoding", "gzip");
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Cache-Control", "max-age=3600");
+ resp1.setHeader("Vary", "Accept-Encoding");
+ resp1.setHeader("ETag", "\"gzip\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+ execute(req1);
+
+ // A request for a different variant matches the URI but not the stored variant -> vary-miss.
+ final ClassicHttpRequest req2 = HttpTestUtils.makeDefaultRequest();
+ req2.setHeader("Accept-Encoding", "br");
+ final ClassicHttpResponse resp2 = HttpTestUtils.make200Response();
+ resp2.setHeader("Cache-Control", "max-age=3600");
+ resp2.setHeader("Vary", "Accept-Encoding");
+ resp2.setHeader("ETag", "\"br\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp2);
+
+ final ClassicHttpResponse result = execute(req2);
+ Assertions.assertEquals("Apache-HttpClient; fwd=vary-miss",
+ result.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusForwardStaleWithUpstream304() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+ final Instant now = Instant.now();
+
+ final ClassicHttpRequest req1 = HttpTestUtils.makeDefaultRequest();
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Date", DateUtils.formatStandardDate(now.minusSeconds(10)));
+ resp1.setHeader("Cache-Control", "max-age=5");
+ resp1.setHeader("ETag", "\"etag\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+ execute(req1);
+
+ // The stored entry is stale; revalidation returns 304, so fwd=stale and fwd-status reports the 304.
+ final ClassicHttpResponse resp304 = HttpTestUtils.make304Response();
+ resp304.setHeader("Date", DateUtils.formatStandardDate(now));
+ resp304.setHeader("ETag", "\"etag\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp304);
+
+ final ClassicHttpResponse result = execute(HttpTestUtils.makeDefaultRequest());
+ Assertions.assertEquals("Apache-HttpClient; fwd=stale; fwd-status=304",
+ result.getFirstHeader("Cache-Status").getValue());
+ }
+
+ @Test
+ void testCacheStatusNeverHitWhenRevalidationForwardsToFailingOrigin() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+ final Instant now = Instant.now();
+
+ final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+ resp1.setHeader("Date", DateUtils.formatStandardDate(now.minusSeconds(10)));
+ resp1.setHeader("Cache-Control", "max-age=5");
+ resp1.setHeader("ETag", "\"etag\"");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+ execute(HttpTestUtils.makeDefaultRequest());
+
+ final ClassicHttpResponse resp500 = HttpTestUtils.make500Response();
+ resp500.setHeader("Date", DateUtils.formatStandardDate(now));
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp500);
+
+ final ClassicHttpResponse result = execute(HttpTestUtils.makeDefaultRequest());
+ final String cacheStatus = result.getFirstHeader("Cache-Status").getValue();
+ Assertions.assertFalse(cacheStatus.contains("hit"), cacheStatus);
+ Assertions.assertTrue(cacheStatus.startsWith("Apache-HttpClient; fwd=stale"), cacheStatus);
+ }
+
+ @Test
+ void testCacheStatusSuppressedForLocallyGeneratedOnlyIfCached() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+ final ClassicHttpRequest req = HttpTestUtils.makeDefaultRequest();
+ req.setHeader("Cache-Control", "only-if-cached");
+
+ final ClassicHttpResponse result = execute(req);
+ Assertions.assertEquals(HttpStatus.SC_GATEWAY_TIMEOUT, result.getCode());
+ Assertions.assertNull(result.getFirstHeader("Cache-Status"));
+ }
+
+ @Test
+ void testExistingUpstreamCacheStatusPreservedAndAppended() throws Exception {
+ impl = new CachingExec(cache, null, CacheConfig.custom().setCacheStatusEnabled(true).build());
+ final ClassicHttpRequest req = HttpTestUtils.makeDefaultRequest();
+ final ClassicHttpResponse resp = HttpTestUtils.make200Response();
+ resp.setHeader("Cache-Control", "max-age=3600");
+ resp.addHeader("Cache-Status", "ExampleCDN; hit");
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp);
+
+ final ClassicHttpResponse result = execute(req);
+ final Header[] headers = result.getHeaders("Cache-Status");
+ Assertions.assertEquals(2, headers.length);
+ Assertions.assertEquals("ExampleCDN; hit", headers[0].getValue());
+ Assertions.assertEquals("Apache-HttpClient; fwd=uri-miss", headers[1].getValue());
+ }
+
@Test
void testOlderCacheableResponsesDoNotGoIntoCache() throws Exception {
final Instant now = Instant.now();
@@ -1219,10 +1428,37 @@ void testReturnssetStaleIfErrorEnabled() throws Exception {
Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp2);
final ClassicHttpResponse response2 = execute(req2);
Assertions.assertEquals(HttpStatus.SC_OK, response2.getCode());
+ // A stale response served under stale-if-error is reported as a cache module response.
+ Assertions.assertEquals(CacheResponseStatus.CACHE_MODULE_RESPONSE, context.getCacheResponseStatus());
Mockito.verify(cacheRevalidator, Mockito.never()).revalidateCacheEntry(Mockito.any(), Mockito.any());
}
+ @Test
+ void testSetsModuleResponseContextForStaleWhileRevalidate() throws Exception {
+ impl = new CachingExec(cache, cacheRevalidator, CacheConfig.DEFAULT);
+
+ final BasicClassicHttpRequest req1 = new BasicClassicHttpRequest("GET", "http://foo.example.com/");
+ final ClassicHttpResponse resp1 = new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK");
+ resp1.setEntity(HttpTestUtils.makeBody(128));
+ resp1.setHeader("Content-Length", "128");
+ resp1.setHeader("ETag", "\"abc\"");
+ resp1.setHeader("Date", DateUtils.formatStandardDate(Instant.now().minusSeconds(10)));
+ resp1.setHeader("Cache-Control", "public, max-age=1, stale-while-revalidate=3600");
+
+ Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+ execute(req1);
+
+ final BasicClassicHttpRequest req2 = new BasicClassicHttpRequest("GET", "http://foo.example.com/");
+ Mockito.when(mockExecRuntime.fork(Mockito.any())).thenReturn(mockExecRuntime);
+ final ClassicHttpResponse result = execute(req2);
+
+ // The stale response is served from cache while an asynchronous revalidation is scheduled.
+ Assertions.assertEquals(HttpStatus.SC_OK, result.getCode());
+ Mockito.verify(cacheRevalidator).revalidateCacheEntry(Mockito.any(), Mockito.any());
+ Assertions.assertEquals(CacheResponseStatus.CACHE_MODULE_RESPONSE, context.getCacheResponseStatus());
+ }
+
@Test
void testNotModifiedResponseUpdatesCacheEntry() throws Exception {
final HttpCache mockCache = mock(HttpCache.class);