Building an API Rate Limiter with Spring Boot, Redis, and AOP

7 min readMay 3, 2025

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>
  1. 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 like INCR, EXPIRE, etc.
  2. spring-boot-starter-web : Used to expose REST APIs and handle HTTP requests/responses.
  3. lombok : Reduces boilerplate code by automatically generating getters, setters, constructors, toString()etc.
  4. 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 this
RedisRateLimiterApplication.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 command
docker 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 file
config/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 request
service/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 annotation
annotation/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:

  1. Performance: Redis’s in-memory operations ensure minimal latency
  2. Scalability: Works across multiple application instances
  3. Flexibility: Easy to adjust limits and strategies
  4. 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

Connect with me

--

--

No responses yet