Skip to content

Commit b2f980f

Browse files
authored
Merge pull request #4072 from jmrt47/add-throw-on-limit-option
Add Exception on Limit Exceed Switch to RequestRateLimiterGatewayFilterFactory
2 parents b80d9c0 + 9319673 commit b2f980f

File tree

3 files changed

+80
-9
lines changed

3 files changed

+80
-9
lines changed

docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/requestratelimiter-factory.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
= `RequestRateLimiter` `GatewayFilter` Factory
33

44
The `RequestRateLimiter` `GatewayFilter` factory uses a `RateLimiter` implementation to determine if the current request is allowed to proceed. If it is not, a status of `HTTP 429 - Too Many Requests` (by default) is returned.
5+
The default status code can be configured using the `StatusCode` property of the `RequestRateLimiter` filter.
56

67
This filter takes an optional `keyResolver` parameter and parameters specific to the rate limiter (described xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/requestratelimiter-factory.adoc#key-resolver-section[later in this section]).
78

9+
It also supports the `throwOnLimit` option, which is `false` by default. When set to `true`, the filter will throw a `HttpClientErrorException` when the request is denied by the rate limiter, instead of just setting the response status.
10+
The `HttpClientErrorException` is created with the configured status code from the `StatusCode` property. Depending on the status code, Spring may return a more specific subclass (for example, if `StatusCode` is set to `HTTP 429 - Too Many Requests`, the exception will be a `HttpClientErrorException.TooManyRequests`).
11+
812
`keyResolver` is a bean that implements the `KeyResolver` interface.
913
In configuration, reference the bean by name using SpEL.
1014
`#{@myKeyResolver}` is a SpEL expression that references a bean named `myKeyResolver`.

spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.Objects;
2121

2222
import org.jspecify.annotations.Nullable;
23+
import reactor.core.publisher.Mono;
2324

2425
import org.springframework.boot.context.properties.ConfigurationProperties;
2526
import org.springframework.cloud.gateway.filter.GatewayFilter;
@@ -30,6 +31,7 @@
3031
import org.springframework.cloud.gateway.support.HttpStatusHolder;
3132
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
3233
import org.springframework.http.HttpStatus;
34+
import org.springframework.web.client.HttpClientErrorException;
3335

3436
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus;
3537

@@ -60,6 +62,12 @@ public class RequestRateLimiterGatewayFilterFactory
6062
/** HttpStatus to return when denyEmptyKey is true, defaults to FORBIDDEN. */
6163
private String emptyKeyStatusCode = HttpStatus.FORBIDDEN.name();
6264

65+
/**
66+
* Switch to throw a {@link HttpClientErrorException} when the request is denied by
67+
* the RateLimiter, defaults to false.
68+
*/
69+
private boolean throwOnLimit = false;
70+
6371
public RequestRateLimiterGatewayFilterFactory(RateLimiter defaultRateLimiter, KeyResolver defaultKeyResolver) {
6472
super(Config.class);
6573
this.defaultRateLimiter = defaultRateLimiter;
@@ -90,6 +98,14 @@ public void setEmptyKeyStatusCode(String emptyKeyStatusCode) {
9098
this.emptyKeyStatusCode = emptyKeyStatusCode;
9199
}
92100

101+
public boolean isThrowOnLimit() {
102+
return throwOnLimit;
103+
}
104+
105+
public void setThrowOnLimit(boolean throwOnLimit) {
106+
this.throwOnLimit = throwOnLimit;
107+
}
108+
93109
@SuppressWarnings("unchecked")
94110
@Override
95111
public GatewayFilter apply(Config config) {
@@ -98,6 +114,7 @@ public GatewayFilter apply(Config config) {
98114
boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey);
99115
HttpStatusHolder emptyKeyStatus = HttpStatusHolder
100116
.parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode));
117+
boolean throwLimit = getOrDefault(config.throwOnLimit, this.throwOnLimit);
101118

102119
return (exchange, chain) -> resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY).flatMap(key -> {
103120
if (EMPTY_KEY.equals(key)) {
@@ -122,6 +139,11 @@ public GatewayFilter apply(Config config) {
122139
return chain.filter(exchange);
123140
}
124141

142+
if (throwLimit) {
143+
return Mono.error(HttpClientErrorException.create(config.getStatusCode(), "Too Many Requests",
144+
exchange.getResponse().getHeaders(), null, null));
145+
}
146+
125147
setResponseStatus(exchange, config.getStatusCode());
126148
return exchange.getResponse().setComplete();
127149
});
@@ -144,6 +166,8 @@ public static class Config implements HasRouteId {
144166

145167
private @Nullable String emptyKeyStatus;
146168

169+
private @Nullable Boolean throwOnLimit;
170+
147171
private @Nullable String routeId;
148172

149173
public @Nullable KeyResolver getKeyResolver() {
@@ -191,6 +215,15 @@ public Config setEmptyKeyStatus(String emptyKeyStatus) {
191215
return this;
192216
}
193217

218+
public @Nullable Boolean getThrowOnLimit() {
219+
return throwOnLimit;
220+
}
221+
222+
public Config setThrowOnLimit(Boolean throwOnLimit) {
223+
this.throwOnLimit = throwOnLimit;
224+
return this;
225+
}
226+
194227
@Override
195228
public void setRouteId(String routeId) {
196229
this.routeId = routeId;

spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import org.junit.jupiter.api.Test;
2323
import reactor.core.publisher.Mono;
24+
import reactor.test.StepVerifier;
2425

2526
import org.springframework.beans.factory.annotation.Autowired;
2627
import org.springframework.beans.factory.annotation.Qualifier;
@@ -44,6 +45,7 @@
4445
import org.springframework.mock.web.server.MockServerWebExchange;
4546
import org.springframework.test.annotation.DirtiesContext;
4647
import org.springframework.test.context.bean.override.mockito.MockitoBean;
48+
import org.springframework.web.client.HttpClientErrorException;
4749

4850
import static org.assertj.core.api.Assertions.assertThat;
4951
import static org.mockito.Mockito.when;
@@ -83,22 +85,32 @@ public void notAllowedWorks() {
8385
assertFilterFactory(resolver2, "notallowedkey", false, HttpStatus.TOO_MANY_REQUESTS);
8486
}
8587

88+
@Test
89+
public void notAllowedThrows() {
90+
assertFilterFactory(null, "allowedkey", false, HttpStatus.TOO_MANY_REQUESTS, null, true);
91+
}
92+
8693
@Test
8794
public void emptyKeyDenied() {
8895
assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.FORBIDDEN);
8996
}
9097

9198
@Test
9299
public void emptyKeyAllowed() {
93-
assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.OK, false);
100+
assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.OK, false, false);
101+
}
102+
103+
@Test
104+
public void emptyKeyDeniedWithThrowOnLimit() {
105+
assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.FORBIDDEN, true, true);
94106
}
95107

96108
private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus) {
97-
assertFilterFactory(keyResolver, key, allowed, expectedStatus, null);
109+
assertFilterFactory(keyResolver, key, allowed, expectedStatus, null, false);
98110
}
99111

100112
private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus,
101-
Boolean denyEmptyKey) {
113+
Boolean denyEmptyKey, Boolean throwOnLimit) {
102114

103115
String tokensRemaining = allowed ? "1" : "0";
104116

@@ -122,18 +134,40 @@ private void assertFilterFactory(KeyResolver keyResolver, String key, boolean al
122134
if (denyEmptyKey != null) {
123135
factory.setDenyEmptyKey(denyEmptyKey);
124136
}
137+
if (throwOnLimit != null) {
138+
factory.setThrowOnLimit(throwOnLimit);
139+
}
125140
GatewayFilter filter = factory.apply(config -> {
126141
config.setRouteId("myroute");
127142
config.setKeyResolver(keyResolver);
128143
});
129144

130-
Mono<Void> response = filter.filter(exchange, this.filterChain);
131-
response.subscribe(aVoid -> {
132-
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(expectedStatus);
133-
assertThat(exchange.getResponse().getHeaders().asMultiValueMap()).containsEntry("X-Tokens-Remaining",
134-
Collections.singletonList(tokensRemaining));
135-
});
145+
StepVerifier.FirstStep<Void> voidFirstStep = StepVerifier.create(filter.filter(exchange, this.filterChain));
136146

147+
if (key == null) {
148+
// empty key case won't contain the header, so we just check the status code
149+
voidFirstStep.consumeSubscriptionWith(aVoid -> {
150+
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(expectedStatus);
151+
}).expectComplete().verify();
152+
}
153+
else if (throwOnLimit != null && throwOnLimit && !allowed) {
154+
// if throwOnLimit is true and the request is denied, we expect an error instead of a complete signal
155+
voidFirstStep.consumeErrorWith(throwable -> {
156+
assertThat(throwable).isInstanceOf(HttpClientErrorException.class);
157+
HttpClientErrorException ex = (HttpClientErrorException) throwable;
158+
assertThat(ex.getStatusCode()).isEqualTo(expectedStatus);
159+
assertThat(ex.getResponseHeaders().toSingleValueMap()).containsEntry("X-Tokens-Remaining",
160+
tokensRemaining);
161+
}).verify();
162+
}
163+
else {
164+
// allowed and denied, we expect a complete signal with status and headers
165+
voidFirstStep.consumeSubscriptionWith(aVoid -> {
166+
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(expectedStatus);
167+
assertThat(exchange.getResponse().getHeaders().toSingleValueMap()).containsEntry("X-Tokens-Remaining",
168+
tokensRemaining);
169+
}).expectComplete().verify();
170+
}
137171
}
138172

139173
@EnableAutoConfiguration

0 commit comments

Comments
 (0)