An example of a RESTful WebServer developed using Spring & SpringBoot to implement rate limiter with hazelcast cache. This example explained about spring boot3 with hazelcast cache.
Eang Sopheaktra
April 02 2024 08:14 pm
Caching is reduce the number of calls made to your endpoint and also improve the latency of requests to your API.
Using cache when your data is not volatile real-time data.
Rate limiting, also known as API rate limiting, is a crucial practice for ensuring the stability, security, and efficient operation of APIs. Let’s explore why it’s essential:
The fully fledged server uses the following:
There are a number of third-party dependencies used in the project. Browse the Maven pom.xml file for details of libraries and versions used.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-hazelcast</artifactId>
<version>8.10.1</version>
</dependency>
<!-- Hazelcast -->
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast</artifactId>
</dependency>
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast-spring</artifactId>
<version>5.3.6</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
package com.tra22.ratelimiter.config.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface RateLimiter {
TimeUnit timeUnit() default TimeUnit.MINUTES;
long timeValue() default 60;
long restriction() default 500;
}
package com.tra22.ratelimiter.config.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface RateLimiters {
RateLimiter[] value();
}
package com.tra22.ratelimiter.config.cache;
import com.hazelcast.client.config.ClientConfig;
import com.hazelcast.config.MapConfig;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.map.IMap;
import com.hazelcast.spring.cache.HazelcastCacheManager;
import com.tra22.ratelimiter.payloads.RateLimiterKey;
import io.github.bucket4j.grid.hazelcast.HazelcastProxyManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public HazelcastInstance hazelcastInstance() {
HazelcastInstance instance = Hazelcast.newHazelcastInstance();
instance.getConfig().addMapConfig(new MapConfig("rate-limiters"));
instance.getConfig().getNetworkConfig().getRestApiConfig().setEnabled(true);
return instance;
}
@Bean
public IMap<RateLimiterKey, byte[]> rateLimitingMap(HazelcastInstance hazelcastInstance) {
return hazelcastInstance.getMap("rate-limiters");
}
@Bean
public HazelcastProxyManager<RateLimiterKey> hazelcastProxyManager() {
return new HazelcastProxyManager<RateLimiterKey>(hazelcastInstance().getMap("rate-limiters"));
}
@Bean
public ClientConfig clientConfig() {
ClientConfig cfg = ClientConfig.load();
cfg.setClusterName("statsCluster");
return cfg;
}
@Bean
CacheManager hazelcastCacheManager() {
return new HazelcastCacheManager(hazelcastInstance());
}
}
package com.tra22.ratelimiter.utils;
import com.tra22.ratelimiter.config.annotation.RateLimiter;
import com.tra22.ratelimiter.config.annotation.RateLimiters;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConfigurationBuilder;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.helpers.MessageFormatter;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class RateLimiterUtils {
public static String getUniqueKeyName(HttpServletRequest httpServletRequest){
if(httpServletRequest != null){
return MessageFormatter.arrayFormat("{}:{}",
new Object[] {
StringUtils.hasText(httpServletRequest.getRemoteAddr()) ? httpServletRequest.getRemoteAddr() : "0.0.0.0",
StringUtils.hasText(httpServletRequest.getRemoteUser()) ? httpServletRequest.getRemoteUser() : "anonymous"
}
).getMessage();
}
return "0.0.0.0:anonymous:NONE_REQUEST_HOST:NONE_REQUEST_PORT";
}
public static BucketConfiguration rateLimiterAnnotationsToBucketConfiguration(List<RateLimiter> rateLimiters) {
ConfigurationBuilder configBuilder = new ConfigurationBuilder();
rateLimiters.forEach(limiter -> configBuilder.addLimit(buildBandwidth(limiter)));
return configBuilder.build();
}
public static Optional<List<RateLimiter>> getRateLimiters(HandlerMethod handlerMethod) {
RateLimiters rateLimitersAnnotation = handlerMethod.getMethodAnnotation(RateLimiters.class);
if(rateLimitersAnnotation != null) {
return Optional.of(Arrays.asList(rateLimitersAnnotation.value()));
}
RateLimiter rateLimiterAnnotation = handlerMethod.getMethodAnnotation(RateLimiter.class);
if(rateLimiterAnnotation != null) {
return Optional.of(List.of(rateLimiterAnnotation));
}
return Optional.empty();
}
private static Bandwidth buildBandwidth(RateLimiter rateLimiter) {
TimeUnit timeUnit = rateLimiter.timeUnit();
long timeValue = rateLimiter.timeValue();
long restriction = rateLimiter.restriction();
return Bandwidth.builder()
.capacity(restriction)
.refillGreedy(restriction, convert(timeValue, timeUnit))
.build();
}
private static Duration convert(long dur, TimeUnit timeUnit) {
return Duration.of(dur, timeUnit.toChronoUnit());
}
}
package com.tra22.ratelimiter.config.handlers;
import com.tra22.ratelimiter.config.annotation.RateLimiter;
import com.tra22.ratelimiter.exception.BucketRateLimiterException;
import com.tra22.ratelimiter.payloads.RateLimiterKey;
import com.tra22.ratelimiter.utils.RateLimiterUtils;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.grid.hazelcast.HazelcastProxyManager;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.List;
import java.util.Optional;
@Component
public class RateLimiterAnnotationHandlerInterceptorAdapter implements HandlerInterceptor {
private final HazelcastProxyManager<RateLimiterKey> proxyManager;
public RateLimiterAnnotationHandlerInterceptorAdapter(HazelcastProxyManager<RateLimiterKey> proxyManager) {
this.proxyManager = proxyManager;
}
@Override
public boolean preHandle(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler) throws Exception {
if (handler instanceof HandlerMethod handlerMethod) {
//if into handlerMethod is present RateLimiter or RateLimiters annotation, we get it, if not, we get empty Optional
Optional<List<RateLimiter>> rateLimiters = RateLimiterUtils.getRateLimiters(handlerMethod);
if (rateLimiters.isPresent()) {
//Get path from RequestMapping annotation(respectively we can get annotations such: GetMapping, PostMapping, PutMapping, DeleteMapping, because all of than annotations are extended from RequestMapping)
RequestMapping requestMapping = handlerMethod.getMethodAnnotation(RequestMapping.class);
//To get unique key we use bundle of 2-x values: path from RequestMapping and user id
assert requestMapping != null;
RateLimiterKey key = new RateLimiterKey(RateLimiterUtils.getUniqueKeyName(request), requestMapping.value());
//Further we set key in proxy to get Bucket from cache or create a new Bucket
Bucket bucket = this.proxyManager.builder().build(key, () -> RateLimiterUtils.rateLimiterAnnotationsToBucketConfiguration(rateLimiters.get()));
ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(1);
//Try to consume token, if we don’t do that, we throw custom exception
if (!consumptionProbe.isConsumed()) {
throw new BucketRateLimiterException("RateLimiter is applied.", consumptionProbe);
}
}
}
return true;
}
}
package com.tra22.ratelimiter.config;
import com.tra22.ratelimiter.config.handlers.RateLimiterAnnotationHandlerInterceptorAdapter;
import com.tra22.ratelimiter.payloads.RateLimiterKey;
import io.github.bucket4j.grid.hazelcast.HazelcastProxyManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfigHazelcastRateLimiter implements WebMvcConfigurer {
@Autowired
private HazelcastProxyManager<RateLimiterKey> hazelcastProxyManager;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RateLimiterAnnotationHandlerInterceptorAdapter(hazelcastProxyManager));
}
}
package com.tra22.ratelimiter.exception;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.github.bucket4j.ConsumptionProbe;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Getter
public class BucketRateLimiterException extends RuntimeException{
private final ConsumptionProbe consumptionProbe;
public BucketRateLimiterException(String message, ConsumptionProbe consumptionProbe) throws JsonProcessingException {
super(message);
this.consumptionProbe = consumptionProbe;
log.error("getNanosToWaitForReset: {} , getNanosToWaitForRefill: {}, getRemainingTokens: {}", this.consumptionProbe.getNanosToWaitForReset(), this.consumptionProbe.getNanosToWaitForRefill(), this.consumptionProbe.getRemainingTokens());
}
}
package com.tra22.ratelimiter.controllers;
import com.tra22.ratelimiter.config.annotation.RateLimiter;
import com.tra22.ratelimiter.config.annotation.RateLimiters;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@RestController
@RequiredArgsConstructor
public class ExampleController {
@RateLimiters({
@RateLimiter(timeUnit = TimeUnit.SECONDS, timeValue = 10, restriction = 2),
@RateLimiter(timeValue = 10, restriction = 5)
})
@GetMapping("/example")
public ResponseEntity<Object> example() {
return ResponseEntity.ok("Ok");
}
}
You will need:
Clone the project and use Maven to build the server
$ mvn clean install
Download the source code for the sample application Spring Boot 3 with Rest API for apply RateLimiter. After learning from this tutorial you will understand more about caching on API, RateLimiter for secure overload API request from unknown user and apply some configuration with latest Spring Boot 3.