Building an API Rate Limiter with Spring Boot, Redis, and AOP
Rate limiting is an essential technique for protecting your APIs from overuse, whether malicious or accidental. By limiting how many requests a client can make in a given time period, you can ensure your services remain stable, responsive, and secure.
In this post, I’ll walk you through building a robust rate limiter using Spring Boot, Redis, and Aspect-Oriented Programming (AOP). By the end, you’ll understand how to implement this mechanism in a clean, declarative way using custom annotations, ensuring your APIs are protected without cluttering your business logic.
What is Rate Limiting?
Rate limiting restricts how many requests a client can make to your API within a specific time window. For example, you might allow 100 requests per minute per user or 1000 requests per hour per IP address.
When implemented correctly, rate limiting:
- Prevents abuse of your services
- Protects against DoS/DDoS attacks
- Ensures fair resource allocation among users
- Helps maintain service quality under load
Why Redis for Rate Limiting?
Redis is perfect for rate limiting because it provides:
- Speed: In-memory operations for minimal latency
- Atomic operations: Critical for accurate counting
- TTL functionality: Built-in time-based expiration
- Distributed capabilities: Works across multiple application instances
Getting Started
Let’s build a Spring Boot application with Redis-based rate limiting. Our application will have a simple REST endpoint, which will be protected with a robust rate limiter.
Prerequisites
Make sure you have:
- Java 17 or later
- Maven
- Docker (for running Redis)
Step 1: Project Setup
First, initialize a Spring project with the following dependencies.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
spring-boot-starter-data-redis
: This is for Redis integration. We’ll use Redis to store and manage API usage counts efficiently using Redis commands likeINCR
,EXPIRE
, etc.spring-boot-starter-web
: Used to expose REST APIs and handle HTTP requests/responses.lombok
: Reduces boilerplate code by automatically generating getters, setters, constructors,toString()
etc.spring-boot-starter-aop
: Enables Aspect-Oriented Programming (AOP) in Spring. We use AOP to intercept API calls annotated with@RateLimit
and apply rate limiting logic without modifying the controller code.
After initialing the Spring Boot project, our main entry point will look like thisRedisRateLimiterApplication.java
package com.nayanprasad.redis_rate_limiter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RedisRateLimiterApplication {
public static void main(String[] args) {
SpringApplication.run(RedisRateLimiterApplication.class, args);
}
}
We will be following the folder structure below
spring-boot-redis-rate-limiter/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── nayanprasad/
│ │ │ └── redis_rate_limiter/
│ │ │ ├── annotation/
│ │ │ ├── aspect/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── exception/
│ │ │ ├── model/
│ │ │ ├── service/
│ │ │ └── RateLimiterApplication.java
│ │ └── resources/
│ │ ├── application.properties
└── pom.xml
Step 2: Add Exception Handlers
Now, let us code the basic models and exception handlers for the project.
model/ApiResponse.java
package com.nayanprasad.redis_rate_limiter.model;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ApiResponse {
private int status;
private String message;
private long timestamp;
}
This is used to define a standard response format for API responses, especially for error or custom messages. Helps maintain consistency across all endpoints.
exception/RateLimitExceededException.java
package com.nayanprasad.redis_rate_limiter.exception;
public class RateLimitExceededException extends Exception{
public RateLimitExceededException(String message) {
super(message);
}
}
This is a custom exception class that represents a specific case when a client exceeds the defined rate limit. This makes error handling more specific and manageable.
controller/GlobalExceptionHandler.java
package com.nayanprasad.redis_rate_limiter.controller;
import com.nayanprasad.redis_rate_limiter.exception.RateLimitExceededException;
import com.nayanprasad.redis_rate_limiter.model.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ApiResponse> handleRateLimitExceededException(RateLimitExceededException rateLimitExceededException) {
return new ResponseEntity<>(new ApiResponse(
HttpStatus.TOO_MANY_REQUESTS.value(),
rateLimitExceededException.getMessage(),
System.currentTimeMillis()), HttpStatus.TOO_MANY_REQUESTS);
}
}
Centralized handler for application-wide exceptions using @ControllerAdvice
. Specifically handles RateLimitExceededException
and returns a proper HTTP 429
response with a structured error message.
Step 3: Create an endpoint
Let's create a /hello
endpoint for testing the api
controller/HelloController.java
package com.nayanprasad.redis_rate_limiter.controller;
import com.nayanprasad.redis_rate_limiter.model.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class HelloController {
@GetMapping("/hello")
public ResponseEntity<ApiResponse> hello() {
log.info("Hello endpoint called");
return new ResponseEntity<>(new ApiResponse(HttpStatus.OK.value(), "Hello world", System.currentTimeMillis()), HttpStatus.OK);
}
}
Now we have a fully working backend api.
If you start the application and run the following command curl http://localhost:8000/hello
The output will look like this
{"status":200,"message":"Hello world","timestamp":1746217418813}
Step 4: Redis Setup
Now let's set up Redis!
To start Redis using Docker, run the following commanddocker run --name rate-limit-redis -p 6379:6379 -d redis
This will start the Redis server on port 6379
Add the following in application.properties
spring.data.redis.host=localhost
spring.data.redis.port=6379
Now let's create a Redis config fileconfig/RedisConfig.java
package com.nayanprasad.redis_rate_limiter.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericToStringSerializer<>(Long.class));
return template;
}
}
Now, Redis is configured and ready. The custom RedisTemplate
allows storing and reading request counts with proper serialization. This configuration is now ready to be injected into your rate-limiting service logic.
Now, create a rate limiter service file to check the requestservice/RateLimiterService.java
package com.nayanprasad.redis_rate_limiter.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class RateLimiterService {
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public RateLimiterService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// This method uses Redis simple key-value pairs with a fixed counter approach:
public boolean allowRequest(String key, int capacity, int timeWindowSeconds) {
String redisKey = "rate:limit:" + key;
// Check if key exists
Boolean keyExists = redisTemplate.hasKey(redisKey);
if (keyExists == null || !keyExists) {
// First request, initialize counter
redisTemplate.opsForValue().set(redisKey, 1, Duration.ofSeconds(timeWindowSeconds));
return true;
}
// Increment counter
Long currentCount = redisTemplate.opsForValue().increment(redisKey);
if (currentCount == null) {
// Something went wrong with Redis, allow by default but log error
log.error("Failed to increment rate limit counter for client: {}", key);
return true;
}
return currentCount <= capacity;
}
}
Step 5: AOP Setup (Annotation + Aspect)
We use Spring AOP (Aspect-Oriented Programming) to intercept annotated controller methods and apply rate limiting transparently, without polluting business logic.
Let's create a custom annotationannotation/RateLimited.java
package com.nayanprasad.redis_rate_limiter.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
int capacity() default 5; // Maximum number of requests allowed within the specified time period
int timeWindowSeconds() default 60; // Time period in seconds for the rate limit window
String key() default ""; // Key to identify the rate limit. Defaults to the method name.
}
Now let's create the Aspect to Enforce the Limit
aspect/RateLimiterAspect.java
package com.nayanprasad.redis_rate_limiter.aspect;
import com.nayanprasad.redis_rate_limiter.annotation.RateLimited;
import com.nayanprasad.redis_rate_limiter.exception.RateLimitExceededException;
import com.nayanprasad.redis_rate_limiter.service.RateLimiterService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
@Aspect
@Component
@Order(1)
@Slf4j
@ConditionalOnProperty(name = "rate-limiter.enabled", havingValue = "true", matchIfMissing = true)
public class RateLimiterAspect {
private final RateLimiterService rateLimiterService;
@Autowired
public RateLimiterAspect(RateLimiterService rateLimiterService) {
this.rateLimiterService = rateLimiterService;
}
@Before("@annotation(com.nayanprasad.redis_rate_limiter.annotation.RateLimited)")
public void rateLimit(JoinPoint joinPoint) throws RateLimitExceededException {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimited rateLimitedAnnotation = method.getAnnotation(RateLimited.class);
int capacity = rateLimitedAnnotation.capacity();
int timeWindowSeconds = rateLimitedAnnotation.timeWindowSeconds();
String key = rateLimitedAnnotation.key();
if(key.isEmpty()) {
key = method.getName();
}
String finalKey = key + ":" + clientIp;
log.debug("Checking rate limit for key: {}, capacity: {}, timeWindowSeconds: {}s", finalKey, capacity, timeWindowSeconds);
if(!rateLimiterService.allowRequest(finalKey, capacity, timeWindowSeconds)) {
log.warn("Rate limit exceeded for client: {} on method: {}", clientIp, method.getName());
throw new RateLimitExceededException("Rate limit exceeded. Try again later.");
}
}
}
The aspect listens for methods that are annotated with @RateLimited
. Using the @Before
advice, it hooks into the method call before the actual execution, allowing rate limiting checks to occur early.
To identify and track requests from different users or clients, the aspect generates a unique Redis key. This key is composed of:
- The custom
key
from the annotation (or the method name if not provided), - The client’s IP address.
This ensures that the rate limiting is user-specific and method-specific, which is ideal for REST APIs.
With this key as argument, we call RateLimiterService.allowRequest()
and determines if the request is within the allowed limits. If the rate limiter service returns false
, indicating that the client has exceeded the allowed number of requests in the defined time window, the aspect throws a RateLimitExceededException
. This results in an immediate error response to the client, preventing the target method from being executed.
Step 6: Add a rate limit to our endpoint
With the annotation and AOP logic in place, adding rate limiting to any endpoint is now as simple as adding the @RateLimited
annotation.
Let us add that in our HelloController
package com.nayanprasad.redis_rate_limiter.controller;
import com.nayanprasad.redis_rate_limiter.annotation.RateLimited;
import com.nayanprasad.redis_rate_limiter.model.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class HelloController {
@GetMapping("/hello")
@RateLimited(capacity = 1, timeWindowSeconds = 10, key = "hello")
public ResponseEntity<ApiResponse> hello() {
log.info("Hello endpoint called");
return new ResponseEntity<>(new ApiResponse(HttpStatus.OK.value(), "Hello world", System.currentTimeMillis()), HttpStatus.OK);
}
}
This will allow only one request in 10 seconds. When a user accesses /hello
, the first request will succeed; any further requests from the same IP within the next 10 seconds will be rate-limited.
Step 7: Test the endpoint
Now let's hit the endpoint continuously!!!
nayanprasad@pc:~$ curl http://localhost:8000/hello
{"status":200,"message":"Hello world","timestamp":1746221135670}
nayanprasad@pc:~$ curl http://localhost:8000/hello
{"status":429,"message":"Rate limit exceeded. Try again later.","timestamp":1746221136959}
nayanprasad@pc:~$ curl http://localhost:8000/hello
{"status":429,"message":"Rate limit exceeded. Try again later.","timestamp":1746221138106}
Conclusion
We’ve implemented a robust rate-limiting solution using Spring Boot, Redis, and AOP. This approach provides:
- Performance: Redis’s in-memory operations ensure minimal latency
- Scalability: Works across multiple application instances
- Flexibility: Easy to adjust limits and strategies
- Protection: Guards your APIs against abuse
Rate limiting is an essential component of any API infrastructure. By implementing it properly, you ensure your services remain stable and responsive even under heavy load or attempted abuse.
Resources
- Spring Boot Documentation: https://docs.spring.io/spring-boot/index.html
- Redis Documentation: https://redis.io/docs/latest/
- Aspect-Oriented Programming (AOP): https://docs.spring.io/spring-framework/reference/core/aop.html
- GitHub Repository: https://github.com/nayanprasad/spring-boot-redis-rate-limiter