The security is very much essential for any system whether it is deployed on public domain or in on-premises.

Let's start to implement the Rate Limiter using Bucket4j.

Step-1:

Add Required Bucket4j Maven Dependency in Spring MVC application

	<dependencies>
		<dependency>
			<groupId>com.github.vladimir-bukhtoyarov</groupId>
			<artifactId>bucket4j-core</artifactId>
			<version>7.3.0</version>
		</dependency>
	</dependencies>
	

All maven dependencies used in this code are as follows:

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.errorConsole</groupId>
	<artifactId>RequestRateLimiter</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.3.7.RELEASE</version>
		<relativePath />
	</parent>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
			<exclusions>
				<exclusion>
					<groupId>org.springframework.boot</groupId>
					<artifactId>spring-boot-starter-logging</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.apache.logging.log4j</groupId>
			<artifactId>log4j-api</artifactId>
			<!-- <version>2.17.2</version> -->
		</dependency>
		<dependency>
			<groupId>org.apache.logging.log4j</groupId>
			<artifactId>log4j-core</artifactId>
			<!-- <version>2.17.2</version> -->
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-tomcat</artifactId>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.session</groupId>
			<artifactId>spring-session-core</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-lang3</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-cache</artifactId>
		</dependency>
		<dependency>
			<groupId>com.github.vladimir-bukhtoyarov</groupId>
			<artifactId>bucket4j-core</artifactId>
			<version>7.3.0</version>
		</dependency>
	</dependencies>


	<build>
		<plugins>

			<!-- Maven Compiler Plugin -->
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<version>3.8.0</version>
				<configuration>
					<source>8</source>
					<target>8</target>
				</configuration>
			</plugin>

		</plugins>
	</build>

</project>

Step-2:

Create SpringBoot based RequestRateLimiterApplication.java class to start the application

package com.errorConsole;

import java.io.Serializable;

import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

@SpringBootApplication
public class RequestRateLimiterApplication extends SpringBootServletInitializer implements Serializable {

	/**
	 * 
	 */
	private static final long serialVersionUID = 912138882851139387L;
	private static final Logger LOG = LogManager.getLogger(RequestRateLimiterApplication.class);

	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
		return application.sources(RequestRateLimiterApplication.class); // Application main class
	}

	public static void main(String[] args) {
		try {

			SpringApplication.run(RequestRateLimiterApplication.class, args);

		} catch (Exception e) {
			LOG.error("Error : {}", ExceptionUtils.getStackTrace(e));
		}
	}
}

Step-3:

Create LimitTestController.class as Spring REST Controller

package com.errorConsole.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LimitTestController {

	@GetMapping("/testController/{id}")
	Integer getNextValue(@PathVariable Integer id) {
		
		//as demo the below loop is added as computation operation for each request
		//the respective services layer call can be added here in actual scenarios
		
		int sum = 0;
		for(int i=0; i<1000000; i++) {
			sum = sum + i;
		}
		return id+sum;

	}

}

Step-4:

Add RateLimiterService.class file

package com.errorConsole.service;

import io.github.bucket4j.Bucket;

public interface RateLimiterService {

	Bucket resolveBucket(String ipAddress);
}

Step-5:

Add the service implementor class RateLimiterServiceImpl.class

package com.errorConsole.service.impl;

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

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Service;

import com.errorConsole.service.RateLimiterService;

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;

@Service
public class RateLimiterServiceImpl implements RateLimiterService {

	private static final Logger LOG = LogManager.getLogger(RateLimiterServiceImpl.class);
	
	//Temporary Cache to store ipAddress in token bucket ( we can use any other caching method as well like ehcache )
	Map<String, Bucket> bucketCache = new ConcurrentHashMap<>();
	
	private static Integer maxBucketSize = 100;

	@Override
	public Bucket resolveBucket(String ipAddress) {
		
		LOG.info("bucketCache size = {}", bucketCache.size());
		
		return bucketCache.computeIfAbsent(ipAddress, this::newBucket);
	}

	private Bucket newBucket(String s) {
		//return Bucket4j.builder().addLimit(Bandwidth.classic(10, Refill.intervally(1000, Duration.ofMinutes(1)))).build();
		
		Refill refill = Refill.intervally(20, Duration.ofSeconds(10));
		
		return Bucket.builder().addLimit(Bandwidth.classic(maxBucketSize, refill)).build();
	}

}

Step-6:

Add HttpRequestUtil.class to retrieve the IP address from the HTTP request

package com.errorConsole.utils;

import java.io.Serializable;

import javax.servlet.http.HttpServletRequest;

public class HttpRequestUtil implements Serializable{

	public static String getIpAddress(HttpServletRequest request) { 
	    String ip = request.getHeader("x-forwarded-for"); 
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("Proxy-Client-IP");  
	    }  
	    
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("x-real-ip");  
	    }

	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("x-forwarded-server");  
	    }
	    
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("x-forwarded-host");  
	    }
	    
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("cf-connecting-ip");  
	    }
	    
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("WL-Proxy-Client-IP");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_X_FORWARDED_FOR");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_X_FORWARDED");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_X_CLUSTER_CLIENT_IP");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_CLIENT_IP");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_FORWARDED_FOR");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_FORWARDED");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("HTTP_VIA");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getHeader("REMOTE_ADDR");  
	    }  
	    if (ip == null || ip.length() == 0 || ip.equalsIgnoreCase("unknown")) {  
	        ip = request.getRemoteAddr();  
	    }  
	    return ip; 
	  } 
}

Step-7:

Add HTTP Security using HttpSecurityConfig.java class which implements WebMvcConfigurer interface from Spring-MVC JAR.

We need to override the addInterceptors method. This method uses the InterceptorRegistry.class where we need to add a new interceptor HandlerInterceptor and override the preHandle method to put our custom logic.

If the incoming request is in between the defined threshold range and time frame then the request is allowed to process otherwise the error response code with status 429 is return alongwith the message Too many requests !!!

package com.errorConsole.interceptor;

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.errorConsole.service.RateLimiterService;
import com.errorConsole.utils.HttpRequestUtil;

import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;

@Configuration
public class HttpSecurityConfig implements WebMvcConfigurer {
	
	@Autowired
	private RateLimiterService rateLimiterService;
	
	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        
		registry.addInterceptor(new HandlerInterceptor() {
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {

            	String ipAddress = HttpRequestUtil.getIpAddress(request);
        		
        		ipAddress = ipAddress + "_" + request.getServletPath();
        		
        		Bucket bucket = rateLimiterService.resolveBucket(ipAddress);
        		ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(30);
        		
        		if (probe.isConsumed()) {
        			response.addHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
        			return true;
        		} else {
        			long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
        			response.addHeader("Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill));
        			
        			response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "API Request Limit Exhausted");
        			
        			response.setStatus(429);
        			response.setContentType("text/plain");
        			response.getWriter().append("Too many requests !!!");
        			
        			return false;
        		}
            }
        });
    }
}

Step-8:

Run the RequestRateLimiterApplication.java SpringBoot application.

The example is created using Eclipse, based on that below is screenshot of the Eclipse console.

ApplicationSrated

Step-9:

First request submitted on TestController The first request is submitted and processed successfully by the LimitTestController

FirstRequestSent

Step-10:

Send multiple requests by refeshing the webpage using F5 key or hit the http://localhost:8080/testController/1 URL very quikly. The submitted requests logs are captures at Eclipse console, please refer the yellow highlighted timestamp.

MultipleRequestsSubmitted

Step-11:

As the defined threshold of number of requests in given time frame is breached, The application returns the custom error message Too Many Requests !!! and defined HTTP Error Coe 429

RequestBlockedAfterThreshold