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);