Skip to content

Commit 9fce110

Browse files
spring-buildsspencergibb
authored andcommitted
Correct LocalResponseCache behaviour w/ "no-cache", "must-revalidate", "max-age" and "no-store" directives
summary: 1. Inputs / expected result in order assuming Cache TTL configured to 120s * Request contains `no-store` or `private` * (done) Cache is skipped without storing response and `Cache-Control` will come from upstream * Request contains `no-cache` * The cached response is ignored by SCG dispatching to upstream(we cannot return 304 - imagine a client sends no-cache waiting for the server to revalidate and returns fresh data but instead it receives a bodyless response 304) * A) Fresh response coming from upstream is renewed in the SCG cache and `Cache-Control: max-age=120s`. Other clients will see max-age increased * B) Fresh response coming from upstream is not stored. Other clients will not be affected. * Otherwise (ignoring `ETag`, `If-Modified-Since` and other request directives * if Response is not cached in SCG yet * `Cache-Control: max-age=120` (no-cache, no-store and must-revalidate doesn't make sense) * if Response is cached in SCG with remaining TTL = 30s * `Cache-Control: max-age=30` (no-cache, no-store and must-revalidate doesn't make sense) * if Response is cached in SCG with remaining TTL = 0s * `Cache-Control: max-age=0,no-cache,must-revalidate` (max-age=0+must-revalidate is equivalent to no-cache) 2. Revalidation is out of scope but it can be added in the future (ETag, If-Modified-Since, Modified-Since)
1 parent 75c5dba commit 9fce110

21 files changed

+652
-152
lines changed

README.adoc

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Edit the files in the src/main/asciidoc/ directory instead.
66

77

88
image::https://github.com/spring-cloud/spring-cloud-gateway/workflows/Build/badge.svg?style=svg["Actions Status", link="https://github.com/spring-cloud/spring-cloud-gateway/actions"]
9-
image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main/graph/badge.svg["Codecov", link="https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main"]
9+
image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/4.0.x/graph/badge.svg["Codecov", link="https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main"]
1010

1111

1212
This project provides an API Gateway built on top of the Spring Ecosystem, including: Spring 6, Spring Boot 3 and Project Reactor. Spring Cloud Gateway aims to provide a simple, yet effective way to route to APIs and provide cross cutting concerns to them such as: security, monitoring/metrics, and resiliency.
@@ -109,7 +109,7 @@ from the `file` menu.
109109

110110
== Contributing
111111

112-
:spring-cloud-build-branch: 4.0.x
112+
:spring-cloud-build-branch: master
113113

114114
Spring Cloud is released under the non-restrictive Apache 2.0 license,
115115
and follows a very standard Github development process, using Github
@@ -126,7 +126,7 @@ author credit if we do. Active contributors might be asked to join the core tea
126126
given the ability to merge pull requests.
127127

128128
=== Code of Conduct
129-
This project adheres to the Contributor Covenant https://github.com/spring-cloud/spring-cloud-build/blob/master/docs/src/main/asciidoc/code-of-conduct.adoc[code of
129+
This project adheres to the Contributor Covenant https://github.com/spring-cloud/spring-cloud-build/blob/{spring-cloud-build-branch}/docs/src/main/asciidoc/code-of-conduct.adoc[code of
130130
conduct]. By participating, you are expected to uphold this code. Please report
131131
unacceptable behavior to [email protected].
132132

@@ -137,7 +137,7 @@ added after the original pull request but before a merge.
137137
* Use the Spring Framework code format conventions. If you use Eclipse
138138
you can import formatter settings using the
139139
`eclipse-code-formatter.xml` file from the
140-
https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/master/spring-cloud-dependencies-parent/eclipse-code-formatter.xml[Spring
140+
https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/{spring-cloud-build-branch}/spring-cloud-dependencies-parent/eclipse-code-formatter.xml[Spring
141141
Cloud Build] project. If using IntelliJ, you can use the
142142
https://plugins.jetbrains.com/plugin/6546[Eclipse Code Formatter
143143
Plugin] to import the same file.

spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public GlobalLocalResponseCacheGatewayFilter globalLocalResponseCacheGatewayFilt
6565
@Qualifier(RESPONSE_CACHE_MANAGER_NAME) CacheManager cacheManager,
6666
LocalResponseCacheProperties properties) {
6767
return new GlobalLocalResponseCacheGatewayFilter(responseCacheManagerFactory, responseCache(cacheManager),
68-
properties.getTimeToLive());
68+
properties.getTimeToLive(), properties.getRequest());
6969
}
7070

7171
@Bean(name = RESPONSE_CACHE_MANAGER_NAME)
@@ -78,7 +78,7 @@ public CacheManager gatewayCacheManager(LocalResponseCacheProperties cacheProper
7878
public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory(
7979
ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties) {
8080
return new LocalResponseCacheGatewayFilterFactory(responseCacheManagerFactory, properties.getTimeToLive(),
81-
properties.getSize());
81+
properties.getSize(), properties.getRequest());
8282
}
8383

8484
@Bean

spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalLocalResponseCacheGatewayFilter.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ public class GlobalLocalResponseCacheGatewayFilter implements GlobalFilter, Orde
4141
private final ResponseCacheGatewayFilter responseCacheGatewayFilter;
4242

4343
public GlobalLocalResponseCacheGatewayFilter(ResponseCacheManagerFactory cacheManagerFactory, Cache globalCache,
44-
Duration configuredTimeToLive) {
44+
Duration configuredTimeToLive, LocalResponseCacheRequestOptions requestOptions) {
4545
responseCacheGatewayFilter = new ResponseCacheGatewayFilter(
46-
cacheManagerFactory.create(globalCache, configuredTimeToLive));
46+
cacheManagerFactory.create(globalCache, configuredTimeToLive, requestOptions));
4747
}
4848

4949
@Override

spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,9 @@
3030

3131
/**
3232
* {@link org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory} of
33-
* {@link ResponseCacheGatewayFilter}.
34-
*
35-
* By default, a global cache (defined as properties in the application) is used. For
36-
* specific route configuration, parameters can be added following
37-
* {@link RouteCacheConfiguration} class.
33+
* {@link ResponseCacheGatewayFilter}. By default, a global cache (defined as properties
34+
* in the application) is used. For specific route configuration, parameters can be added
35+
* following {@link RouteCacheConfiguration} class.
3836
*
3937
* @author Marta Medio
4038
* @author Ignacio Lozano
@@ -49,23 +47,21 @@ public class LocalResponseCacheGatewayFilterFactory
4947
*/
5048
public static final String LOCAL_RESPONSE_CACHE_FILTER_APPLIED = "LocalResponseCacheGatewayFilter-Applied";
5149

52-
private ResponseCacheManagerFactory cacheManagerFactory;
50+
private final ResponseCacheManagerFactory cacheManagerFactory;
5351

54-
private Duration defaultTimeToLive;
52+
private final Duration defaultTimeToLive;
5553

56-
private DataSize defaultSize;
54+
private final DataSize defaultSize;
5755

58-
public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory,
59-
Duration defaultTimeToLive) {
60-
this(cacheManagerFactory, defaultTimeToLive, null);
61-
}
56+
private final LocalResponseCacheRequestOptions requestOptions;
6257

6358
public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory,
64-
Duration defaultTimeToLive, DataSize defaultSize) {
59+
Duration defaultTimeToLive, DataSize defaultSize, LocalResponseCacheRequestOptions requestOptions) {
6560
super(RouteCacheConfiguration.class);
6661
this.cacheManagerFactory = cacheManagerFactory;
6762
this.defaultTimeToLive = defaultTimeToLive;
6863
this.defaultSize = defaultSize;
64+
this.requestOptions = requestOptions;
6965
}
7066

7167
@Override
@@ -74,7 +70,8 @@ public GatewayFilter apply(RouteCacheConfiguration config) {
7470

7571
Cache routeCache = LocalResponseCacheAutoConfiguration.createGatewayCacheManager(cacheProperties)
7672
.getCache(config.getRouteId() + "-cache");
77-
return new ResponseCacheGatewayFilter(cacheManagerFactory.create(routeCache, cacheProperties.getTimeToLive()));
73+
return new ResponseCacheGatewayFilter(
74+
cacheManagerFactory.create(routeCache, cacheProperties.getTimeToLive(), requestOptions));
7875

7976
}
8077

spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public class LocalResponseCacheProperties {
4040

4141
private Duration timeToLive;
4242

43+
private LocalResponseCacheRequestOptions request = new LocalResponseCacheRequestOptions();
44+
4345
public DataSize getSize() {
4446
return size;
4547
}
@@ -64,9 +66,18 @@ public void setTimeToLive(Duration timeToLive) {
6466
this.timeToLive = timeToLive;
6567
}
6668

69+
public LocalResponseCacheRequestOptions getRequest() {
70+
return request;
71+
}
72+
73+
public void setRequest(LocalResponseCacheRequestOptions request) {
74+
this.request = request;
75+
}
76+
6777
@Override
6878
public String toString() {
69-
return "LocalResponseCacheProperties{" + "timeToLive=" + getTimeToLive() + '\'' + ", size='" + getSize() + '}';
79+
return "LocalResponseCacheProperties{" + "size=" + size + ", timeToLive=" + timeToLive + ", request=" + request
80+
+ '}';
7081
}
7182

7283
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2013-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.filter.factory.cache;
18+
19+
public class LocalResponseCacheRequestOptions {
20+
21+
private RequestNoCacheDirectiveStrategy noCache = RequestNoCacheDirectiveStrategy.SKIP_UPDATE_CACHE_ENTRY;
22+
23+
public RequestNoCacheDirectiveStrategy getNoCache() {
24+
return noCache;
25+
}
26+
27+
public void setNoCache(RequestNoCacheDirectiveStrategy noCache) {
28+
this.noCache = noCache;
29+
}
30+
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2013-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.filter.factory.cache;
18+
19+
import java.util.Optional;
20+
21+
import org.springframework.http.server.reactive.ServerHttpRequest;
22+
23+
public final class LocalResponseCacheUtils {
24+
25+
private LocalResponseCacheUtils() {
26+
}
27+
28+
public static boolean isNoCacheRequest(ServerHttpRequest request) {
29+
return Optional.ofNullable(request.getHeaders().getCacheControl())
30+
.filter(cc -> cc.matches(".*(\s|,|^)no-cache(\\s|,|$).*")).isPresent();
31+
}
32+
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2013-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.filter.factory.cache;
18+
19+
/**
20+
* When client sends "no-cache" directive in "Cache-Control" header, the response should
21+
* be re-validated from upstream. There are several strategies that indicates what to do
22+
* with the new fresh response.
23+
*/
24+
public enum RequestNoCacheDirectiveStrategy {
25+
26+
/**
27+
* Update the cache entry by the fresh response coming from upstream with a new time
28+
* to live.
29+
*/
30+
UPDATE_CACHE_ENTRY,
31+
/**
32+
* Skip the update. The client will receive the fresh response, other clients will
33+
* receive the old entry in cache.
34+
*/
35+
SKIP_UPDATE_CACHE_ENTRY
36+
37+
}

spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public int getOrder() {
6666

6767
private Mono<Void> filterWithCache(ServerWebExchange exchange, GatewayFilterChain chain) {
6868
final String metadataKey = responseCacheManager.resolveMetadataKey(exchange);
69-
Optional<CachedResponse> cached = responseCacheManager.getFromCache(exchange.getRequest(), metadataKey);
69+
Optional<CachedResponse> cached = getCachedResponse(exchange, metadataKey);
7070

7171
if (cached.isPresent()) {
7272
return responseCacheManager.processFromCache(exchange, metadataKey, cached.get());
@@ -77,6 +77,22 @@ private Mono<Void> filterWithCache(ServerWebExchange exchange, GatewayFilterChai
7777
}
7878
}
7979

80+
private Optional<CachedResponse> getCachedResponse(ServerWebExchange exchange, String metadataKey) {
81+
Optional<CachedResponse> cached;
82+
if (shouldRevalidate(exchange)) {
83+
cached = Optional.empty();
84+
}
85+
else {
86+
cached = responseCacheManager.getFromCache(exchange.getRequest(), metadataKey);
87+
}
88+
89+
return cached;
90+
}
91+
92+
private boolean shouldRevalidate(ServerWebExchange exchange) {
93+
return LocalResponseCacheUtils.isNoCacheRequest(exchange.getRequest());
94+
}
95+
8096
private class CachingResponseDecorator extends ServerHttpResponseDecorator {
8197

8298
private final String metadataKey;
@@ -94,7 +110,8 @@ public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
94110
final ServerHttpResponse response = exchange.getResponse();
95111

96112
Flux<DataBuffer> decoratedBody;
97-
if (responseCacheManager.isResponseCacheable(response)) {
113+
if (responseCacheManager.isResponseCacheable(response)
114+
&& !responseCacheManager.isNoCacheRequestWithoutUpdate(exchange.getRequest())) {
98115
decoratedBody = responseCacheManager.processFromUpstream(metadataKey, exchange, Flux.from(body));
99116
}
100117
else {

spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.springframework.cache.Cache;
3333
import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator;
3434
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.AfterCacheExchangeMutator;
35+
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator;
3536
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetMaxAgeHeaderAfterCacheExchangeMutator;
3637
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetResponseHeadersAfterCacheExchangeMutator;
3738
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetStatusCodeAfterCacheExchangeMutator;
@@ -63,12 +64,23 @@ public class ResponseCacheManager {
6364

6465
private final Cache cache;
6566

66-
public ResponseCacheManager(CacheKeyGenerator cacheKeyGenerator, Cache cache, Duration configuredTimeToLive) {
67+
private final boolean ignoreNoCacheUpdate;
68+
69+
public ResponseCacheManager(CacheKeyGenerator cacheKeyGenerator, Cache cache, Duration configuredTimeToLive,
70+
LocalResponseCacheRequestOptions requestOptions) {
6771
this.cacheKeyGenerator = cacheKeyGenerator;
6872
this.cache = cache;
73+
this.ignoreNoCacheUpdate = isSkipNoCacheUpdateActive(requestOptions);
6974
this.afterCacheExchangeMutators = List.of(new SetResponseHeadersAfterCacheExchangeMutator(),
7075
new SetStatusCodeAfterCacheExchangeMutator(),
71-
new SetMaxAgeHeaderAfterCacheExchangeMutator(configuredTimeToLive, Clock.systemDefaultZone()));
76+
new SetMaxAgeHeaderAfterCacheExchangeMutator(configuredTimeToLive, Clock.systemDefaultZone(),
77+
ignoreNoCacheUpdate),
78+
new SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator());
79+
}
80+
81+
private static boolean isSkipNoCacheUpdateActive(LocalResponseCacheRequestOptions requestOptions) {
82+
return Optional.ofNullable(requestOptions).map(LocalResponseCacheRequestOptions::getNoCache)
83+
.filter(RequestNoCacheDirectiveStrategy.SKIP_UPDATE_CACHE_ENTRY::equals).isPresent();
7284
}
7385

7486
private static final List<HttpStatusCode> statusesToCache = Arrays.asList(HttpStatus.OK, HttpStatus.PARTIAL_CONTENT,
@@ -132,13 +144,8 @@ Mono<Void> processFromCache(ServerWebExchange exchange, String metadataKey, Cach
132144
afterCacheExchangeMutators.forEach(processor -> processor.accept(exchange, cachedResponse));
133145
saveMetadataInCache(metadataKey, new CachedResponseMetadata(cachedResponse.headers().getVary()));
134146

135-
if (HttpStatus.NOT_MODIFIED.equals(response.getStatusCode())) {
136-
return response.writeWith(Mono.empty());
137-
}
138-
else {
139-
return response.writeWith(
140-
Flux.fromIterable(cachedResponse.body()).map(data -> response.bufferFactory().wrap(data)));
141-
}
147+
return response
148+
.writeWith(Flux.fromIterable(cachedResponse.body()).map(data -> response.bufferFactory().wrap(data)));
142149
}
143150

144151
private CachedResponseMetadata retrieveMetadata(String metadataKey) {
@@ -157,6 +164,10 @@ boolean isResponseCacheable(ServerHttpResponse response) {
157164
return isStatusCodeToCache(response) && isCacheControlAllowed(response) && !isVaryWildcard(response);
158165
}
159166

167+
boolean isNoCacheRequestWithoutUpdate(ServerHttpRequest request) {
168+
return LocalResponseCacheUtils.isNoCacheRequest(request) && ignoreNoCacheUpdate;
169+
}
170+
160171
private boolean isStatusCodeToCache(ServerHttpResponse response) {
161172
return statusesToCache.contains(response.getStatusCode());
162173
}

0 commit comments

Comments
 (0)