Rate limiting in Spring Boot with Bucket4j and Redis isn’t just about preventing abuse; it’s a fundamental mechanism for ensuring service stability and predictable performance under load.
Let’s see it in action. Imagine a simple Spring Boot controller endpoint that we want to protect:
@RestController
@RequestMapping("/api/resource")
public class ResourceController {
private final RateLimiter rateLimiter;
public ResourceController(RateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@GetMapping
public ResponseEntity<String> getResource(@RequestHeader("X-User-ID") String userId) {
if (!rateLimiter.tryConsume(userId, 1)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many requests. Please try again later.");
}
return ResponseEntity.ok("Here is your resource.");
}
}
Here, RateLimiter is an abstraction. The real magic happens when we wire it up with Bucket4j and Redis.
First, we need to add the necessary dependencies to our pom.xml:
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.6.0</version>
</dependency>
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-redis</artifactId>
<version>8.6.0</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.3.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Now, let’s configure Bucket4j to use Redis as its backend. This is crucial for distributed rate limiting across multiple instances of your Spring Boot application.
@Configuration
@EnableCaching
public class Bucket4jConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// You might want to configure serializers here for production
return template;
}
@Bean
public Bucket4jConfiguration bucket4jConfiguration() {
return new Bucket4jConfiguration() {
@Override
public RedisBucket4jConfiguration getRedisBucket4jConfiguration() {
return RedisBucket4jConfiguration.builder()
.setRedisKeyPrefix("bucket4j:") // Prefix for Redis keys
.build();
}
};
}
@Bean
public RateLimiter rateLimiter(RedisTemplate<Object, Object> redisTemplate) {
// Define the rate limiting strategy: 10 requests per minute per user ID
Bandwidth limit = Bandwidth.builder()
.capacity(10) // Maximum tokens
.refillIntervally(10, Duration.ofMinutes(1)) // Refill 10 tokens every minute
.build();
// Create a bucket factory that uses Redis for state storage
RedisBucket4j.Builder bucketBuilder = RedisBucket4j.builder(redisTemplate)
.withLimited(limit);
// The key generator determines how to uniquely identify a rate limit scope.
// Here, we use the X-User-ID header.
return bucketBuilder.withKeyBy(key -> key.toString()) // Use the user ID as the Redis key
.build();
}
}
In this configuration:
redisTemplate: Standard Spring Data Redis configuration.bucket4jConfiguration: Tells Bucket4j to use Redis and sets a key prefix for clarity in Redis.rateLimiter: This is where the core logic resides.Bandwidth.builder().capacity(10).refillIntervally(10, Duration.ofMinutes(1)).build(): Defines our rate limit. Each user ID gets a "bucket" that can hold up to 10 tokens. These tokens are replenished at a rate of 10 per minute.RedisBucket4j.builder(redisTemplate): Initializes the Bucket4j builder, specifying Redis as the backend.withLimited(limit): Applies the defined bandwidth limit.withKeyBy(key -> key.toString()): This is the critical part for distributed systems. It tells Bucket4j to use theuserId(which is passed as thekeytotryConsume) as the unique identifier for a rate limit bucket in Redis. This ensures that each user ID has its own independent rate limit, and this state is shared across all application instances.build(): Creates theRateLimiterinstance.
When a request comes in, rateLimiter.tryConsume(userId, 1) is called. Bucket4j will:
- Generate a Redis key based on the
userId(e.g.,bucket4j:some-user-id). - Query Redis for the current state of the token bucket associated with that key.
- Attempt to consume one token.
- Update the bucket’s state in Redis.
- Return
trueif successful,falseif the bucket is empty.
The surprising truth about this setup is that Bucket4j, by default, doesn’t just store the current token count in Redis. It actually stores the entire bucket state, including refill timers and consumption history, allowing for more sophisticated rate-shaping algorithms than simple token counting. This means that even if your application instances are out of sync, Redis acts as the single source of truth for rate limiting, ensuring consistency.
The withKeyBy method is your primary lever for defining the granularity of your rate limits. You could, for example, limit by IP address, API key, or a combination of both, by modifying how you construct the key passed to withKeyBy.
One thing most people don’t know is how Bucket4j handles concurrent requests to the same key. It uses Redis transactions (or Lua scripts, depending on the underlying Redis client and configuration) to atomically update the bucket state. This prevents race conditions where multiple requests might try to consume tokens from an empty bucket simultaneously, leading to over-limiting.
The next concept you’ll likely encounter is implementing more complex rate-shaping strategies beyond simple token buckets, such as leaky bucket or adaptive rate limiting, and how to manage these configurations dynamically.