From 4f962d1f5b0e28496cf881b8e6c210e415e24a3c Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 11 Aug 2026 20:20:50 +0200 Subject: [PATCH] RFC 9211: optional Cache-Status response header Adds optional support for the RFC 9211 Cache-Status response header. When enabled through CacheConfig the caching executors record how each exchange was handled in a per-exchange CacheStatus, and CacheStatusHeaderGenerator serialises it into the Cache-Status header. CacheStatus is the single source of truth for the disposition of an exchange. The coarse CacheResponseStatus exposed on HttpCacheContext is now derived from it on the fly instead of being stored as a separate attribute, so the two can no longer drift apart. A locally generated response and a stale response served by the module under stale-while-revalidate or stale-if-error map to a module response, an internal error that falls back to the origin maps to a failure, a successful revalidation of a stored entry maps to validated, a response served from a stored entry to a hit, and any other forwarded response to a miss. --- .../client5/http/cache/HttpCacheContext.java | 59 +++-- .../http/impl/cache/AsyncCachingExec.java | 60 +++-- .../client5/http/impl/cache/CacheConfig.java | 40 ++- .../client5/http/impl/cache/CacheStatus.java | 183 ++++++++++++++ .../cache/CacheStatusHeaderGenerator.java | 72 ++++++ .../client5/http/impl/cache/CacheSupport.java | 15 ++ .../impl/cache/CacheableRequestPolicy.java | 3 +- .../client5/http/impl/cache/CachingExec.java | 61 +++-- .../http/impl/cache/CachingExecBase.java | 30 +++ .../impl/cache/ResponseCachingPolicy.java | 3 +- .../http/cache/example/ClientCacheStatus.java | 90 +++++++ .../http/impl/cache/TestAsyncCacheStatus.java | 97 +++++++ .../cache/TestCacheStatusHeaderGenerator.java | 113 +++++++++ .../http/impl/cache/TestCachingExecChain.java | 236 ++++++++++++++++++ 14 files changed, 1004 insertions(+), 58 deletions(-) create mode 100644 httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatus.java create mode 100644 httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheStatusHeaderGenerator.java create mode 100644 httpclient5-cache/src/test/java/org/apache/hc/client5/http/cache/example/ClientCacheStatus.java create mode 100644 httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCacheStatus.java create mode 100644 httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheStatusHeaderGenerator.java 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);