Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Limit API usage #3868

Merged
merged 28 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
64555df
configure InMemoryRateLimiter
kishkinova Oct 3, 2024
e4ac662
fix InMemoryRateLimiter
kishkinova Oct 7, 2024
88a7035
InMemoryRateLimiter test
kishkinova Oct 15, 2024
d07a0e6
InMemoryRateLimiterFilterFatoryIntergrationTest
kishkinova Oct 24, 2024
bda259a
add certificate
Oct 24, 2024
4103311
clean whitespace
kishkinova Oct 24, 2024
136004d
Merge branch 'v3.x.x' into reboot/throttling
kishkinova Oct 24, 2024
5c0eb93
test fix
kishkinova Oct 25, 2024
da43b95
Merge branch 'reboot/throttling' of https://github.com/zowe/api-layer…
kishkinova Oct 25, 2024
6e41a19
add whitespace
kishkinova Oct 25, 2024
5e921e4
add message service
kishkinova Oct 31, 2024
100b624
add test
kishkinova Oct 31, 2024
9d301ee
add message test
kishkinova Nov 4, 2024
12009ab
Merge branch 'v3.x.x' into reboot/throttling
kishkinova Nov 4, 2024
66ca02c
add rate limiter test
Nov 4, 2024
cd523ec
Merge branch 'reboot/throttling' of https://github.com/zowe/api-layer…
kishkinova Nov 4, 2024
e3ad38b
empty commmit
kishkinova Nov 4, 2024
6f551ce
remove dependency
kishkinova Nov 4, 2024
306345e
add variable configuration
kishkinova Nov 4, 2024
7768481
Merge branch 'v3.x.x' into reboot/throttling
achmelo Nov 4, 2024
de850e2
update bucket4j and add if statement
kishkinova Nov 5, 2024
ebefb34
Merge branch 'reboot/throttling' of https://github.com/zowe/api-layer…
kishkinova Nov 5, 2024
d0b1783
empty commit
kishkinova Nov 5, 2024
95f2ab1
add default cookie
kishkinova Nov 5, 2024
551a2f7
fix default cookie
kishkinova Nov 5, 2024
010e81b
add default value to KeyResolver
kishkinova Nov 5, 2024
a0eef6d
delete cookieName from scheme
kishkinova Nov 5, 2024
726d5a6
Merge branch 'v3.x.x' into reboot/throttling
kishkinova Nov 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added ehcache/.lock
Empty file.
3 changes: 3 additions & 0 deletions gateway-package/src/main/resources/bin/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} ${JAVA_BIN_DIR}java \
-Dapiml.gateway.cachePeriodSec=${ZWE_configs_apiml_gateway_registry_cachePeriodSec:-120} \
-Dapiml.gateway.registry.enabled=${ZWE_configs_apiml_gateway_registry_enabled:-false} \
-Dapiml.gateway.maxSimultaneousRequests=${ZWE_configs_gateway_registry_maxSimultaneousRequests:-20} \
-Dapiml.gateway.rateLimiterCapacity=${ZWE_configs_gateway_registry_rateLimiterCapacity:-3} \
-Dapiml.gateway.rateLimiterTokens=${ZWE_configs_gateway_registry_rateLimiterTokens:-3} \
-Dapiml.gateway.rateLimiterRefillDuration=${ZWE_configs_gateway_registry_rateLimiterRefillDuration:-1} \
-Dapiml.gateway.registry.metadata-key-allow-list=${ZWE_configs_gateway_registry_metadataKeyAllowList:-} \
-Dapiml.gateway.refresh-interval-ms=${ZWE_configs_gateway_registry_refreshIntervalMs:-30000} \
-Dserver.address=${ZWE_configs_zowe_network_server_listenAddresses_0:-${ZWE_zowe_network_server_listenAddresses_0:-"0.0.0.0"}} \
Expand Down
1 change: 1 addition & 0 deletions gateway-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dependencies {
api project(':apiml-tomcat-common')
api project(':apiml-security-common')

implementation group: 'com.bucket4j', name: 'bucket4j-core', version: '8.7.0'
kishkinova marked this conversation as resolved.
Show resolved Hide resolved
implementation libs.spring.boot.starter.security
implementation libs.spring.cloud.circuit.breaker
implementation libs.spring.cloud.starter.eureka.client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public List<FilterDefinition> filters() {
retryFilter.addArg("series", "");
filters.add(retryFilter);

FilterDefinition rateLimiterFilter = new FilterDefinition();
rateLimiterFilter.setName("InMemoryRateLimiterFilterFactory");
filters.add(rateLimiterFilter);

for (String headerName : ignoredHeadersWhenCorsEnabled.split(",")) {
FilterDefinition removeHeaders = new FilterDefinition();
removeHeaders.setName("RemoveRequestHeader");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.gateway.filters;

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class InMemoryRateLimiter implements RateLimiter<InMemoryRateLimiter.Config> {

private final Map<String, Bucket> cache = new ConcurrentHashMap<>();
@Value("${apiml.gateway.rateLimiterCapacity:20}")
int capacity;
@Value("${apiml.gateway.rateLimiterTokens:1}")
int tokens;
@Value("${apiml.gateway.rateLimiterRefillDuration:1}")
Long refillDuration;
kishkinova marked this conversation as resolved.
Show resolved Hide resolved

@Override
public Mono<Response> isAllowed(String routeId, String id) {
Bucket bucket = cache.computeIfAbsent(id, this::newBucket);
if (bucket.tryConsume(1)) {
achmelo marked this conversation as resolved.
Show resolved Hide resolved
return Mono.just(new Response(true, getHeaders(bucket)));
} else {
return Mono.just(new Response(false, getHeaders(bucket)));
}
}

private Bucket newBucket(String id) {
Bandwidth limit = Bandwidth.builder().capacity(capacity).refillGreedy(tokens, Duration.ofMinutes(refillDuration)).build();
return Bucket.builder().addLimit(limit).build();
}

private Map<String, String> getHeaders(Bucket bucket) {
Map<String, String> headers = new ConcurrentHashMap<>();
headers.put("X-RateLimit-Remaining", String.valueOf(bucket.getAvailableTokens()));
return headers;
}

@Override
public Map<String, Config> getConfig() {
Config defaultConfig = new Config();
defaultConfig.setCapacity(capacity);
defaultConfig.setTokens(tokens);
defaultConfig.setRefillDuration(refillDuration);

Map<String, Config> configMap = new ConcurrentHashMap<>();
configMap.put("default", defaultConfig);
return configMap;
}

@Override
public Class<Config> getConfigClass() {
return Config.class;
}

@Override
public Config newConfig() {
Config config = new Config();
config.setCapacity(capacity);
config.setTokens(tokens);
config.setRefillDuration(refillDuration);
return config;
}

@Setter
@Getter
public static class Config {
private int capacity;
private int tokens;
private Long refillDuration;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.gateway.filters;

import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.zowe.apiml.message.log.ApimlLogger;
import org.zowe.apiml.product.logging.annotations.InjectApimlLogger;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.List;

@Component
public class InMemoryRateLimiterFilterFactory extends AbstractGatewayFilterFactory<InMemoryRateLimiterFilterFactory.Config> {

@InjectApimlLogger
private final ApimlLogger apimlLog = ApimlLogger.empty();
private final InMemoryRateLimiter rateLimiter;
private final KeyResolver keyResolver;
@Value(value = "${apiml.routing.servicesToLimitRequestRate}")
List<String> serviceIds;

public InMemoryRateLimiterFilterFactory(InMemoryRateLimiter rateLimiter, KeyResolver keyResolver) {
super(Config.class);
this.rateLimiter = rateLimiter;
this.keyResolver = keyResolver;
}

@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String requestPath = exchange.getRequest().getPath().elements().get(1).value();
kishkinova marked this conversation as resolved.
Show resolved Hide resolved
if (serviceIds.contains(requestPath)) {
return keyResolver.resolve(exchange).flatMap(key -> {
if (key.isEmpty()){
return chain.filter(exchange);
}
return rateLimiter.isAllowed(config.getRouteId(), key).flatMap(response -> {
if (response.isAllowed()) {
return chain.filter(exchange);
} else {
apimlLog.log("org.zowe.apiml.gateway.connectionsLimitApproached", "Connections limit exceeded for service '{}'", requestPath);
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
exchange.getResponse().getHeaders().setContentType(MediaType.TEXT_HTML);
String errorHtml = "<html><body><h1>429 Too Many Requests</h1>" +
kishkinova marked this conversation as resolved.
Show resolved Hide resolved
"<p>The connection limit for the service '" + requestPath + "' has been exceeded. Please try again later.</p>" +
"</body></html>";
byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8);
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
}
});
});
} else {
return chain.filter(exchange);
}
};
}

@Getter
@Setter
public static class Config {
private String routeId;
private Integer capacity;
private Integer tokens;
private Integer refillIntervalSeconds;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.gateway.filters;

import org.springframework.http.HttpCookie;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.util.Collections;

@Component
public class KeyResolver implements org.springframework.cloud.gateway.filter.ratelimit.KeyResolver {

private final String cookieName;

public KeyResolver() {
this.cookieName = "apimlAuthenticationToken";
kishkinova marked this conversation as resolved.
Show resolved Hide resolved
}

@Override
public Mono<String> resolve(org.springframework.web.server.ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getCookies().getOrDefault(cookieName, Collections.emptyList())
.stream()
.findFirst()
.map(HttpCookie::getValue)
.orElse("")
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
import static org.zowe.apiml.constants.EurekaMetadataDefinition.*;

@Service
public class RouteLocator implements RouteDefinitionLocator {
public class
RouteLocator implements RouteDefinitionLocator {
kishkinova marked this conversation as resolved.
Show resolved Hide resolved

private static final EurekaMetadataParser metadataParser = new EurekaMetadataParser();

Expand Down
1 change: 1 addition & 0 deletions gateway-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ apiml:
routing:
instanceIdHeader: false
ignoredServices: discovery,zaas # to disable routing to the Discovery and ZAAS service
servicesToLimitRequestRate: discoverableclient
kishkinova marked this conversation as resolved.
Show resolved Hide resolved
service:
apimlId: apiml1
corsEnabled: true
Expand Down
7 changes: 7 additions & 0 deletions gateway-service/src/main/resources/gateway-log-messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,10 @@ messages:
text: "Cannot receive information about services on API Gateway with apimlId '%s' because: %s"
reason: "Cannot connect to the Gateway service."
action: "Make sure that the external Gateway service is running and the truststore of the both Gateways contain the corresponding certificate."

- key: org.zowe.apiml.gateway.connectionsLimitApproached
number: ZWESG429
type: ERROR
text: "Request was denied access."
reason: "Connections limit exceeded."
action: "Wait for the number of active connections to decrease before retrying your request."
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.gateway.filters;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;

public class InMemoryRateLimiterFilterFactoryTest {

private InMemoryRateLimiter rateLimiter;
private KeyResolver keyResolver;
private InMemoryRateLimiterFilterFactory filterFactory;
private ServerWebExchange exchange;
private GatewayFilterChain chain;
private String serviceId;
private MockServerHttpRequest request;
private InMemoryRateLimiterFilterFactory.Config config;

@BeforeEach
public void setUp() {
serviceId = "testService";
rateLimiter = mock(InMemoryRateLimiter.class);
keyResolver = mock(KeyResolver.class);
filterFactory = new InMemoryRateLimiterFilterFactory(rateLimiter, keyResolver);
filterFactory.serviceIds = List.of(serviceId);
request = MockServerHttpRequest.get("/" + serviceId).build();
exchange = MockServerWebExchange.from(request);
chain = mock(GatewayFilterChain.class);
config = mock(InMemoryRateLimiterFilterFactory.Config.class);
when(config.getRouteId()).thenReturn("testRoute");
}

@Test
public void apply_shouldAllowRequest_whenTokensAreAvailable() {
when(keyResolver.resolve(exchange)).thenReturn(Mono.just("testKey"));
when(rateLimiter.isAllowed(anyString(), anyString())).thenReturn(Mono.just(new RateLimiter.Response(true, Map.of())));
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());

StepVerifier.create(filterFactory.apply(config).filter(exchange, chain))
.expectComplete()
.verify();
verify(chain, times(1)).filter(exchange);
}

@Test
public void apply_shouldReturn429_whenTokensAreExhausted() {
when(keyResolver.resolve(exchange)).thenReturn(Mono.just("testKey"));
when(rateLimiter.isAllowed(anyString(), anyString())).thenReturn(Mono.just(new InMemoryRateLimiter.Response(false, Map.of())));

StepVerifier.create(filterFactory.apply(config).filter(exchange, chain))
.expectComplete()
.verify();
ServerHttpResponse response = exchange.getResponse();
assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.getStatusCode());
}

@Test
public void apply_shouldAllowRequest_whenKeyIsNull() {
when(keyResolver.resolve(exchange)).thenReturn(Mono.just(""));
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());

StepVerifier.create(filterFactory.apply(config).filter(exchange, chain))
.expectComplete()
.verify();
verify(chain, times(1)).filter(exchange);
}

@Test
public void apply_shouldAllowRequest_whenServiceIdDoesNotMatch() {
String nonMatchingId = "nonMatchingId";
when(keyResolver.resolve(exchange)).thenReturn(Mono.just("testKey"));
request = MockServerHttpRequest.get("/" + nonMatchingId).build();
exchange = MockServerWebExchange.from(request);
when(chain.filter(any(ServerWebExchange.class))).thenReturn(Mono.empty());

StepVerifier.create(filterFactory.apply(config).filter(exchange, chain))
.expectComplete()
.verify();
verify(chain, times(1)).filter(exchange);
}

}

Loading
Loading