Spring Boot applications often need to control the rate at which clients can access them, and Bucket4j paired with Redis is a robust way to handle this.

Imagine you have an API endpoint that’s getting hammered. Without rate limiting, your service could become overloaded, leading to slow responses or even complete outages for legitimate users. Bucket4j is a Java library that implements the token bucket algorithm for rate limiting. Redis, an in-memory data structure store, acts as a shared, distributed state manager for these token buckets, ensuring that rate limits are enforced consistently across multiple instances of your Spring Boot application.

Let’s see this in action with a simple Spring Boot controller and Bucket4j configured to limit requests to 10 per minute per user IP.

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@RestController
public class RateLimitedController {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    @GetMapping("/limited")
    public ResponseEntity<String> getLimitedResource(HttpServletRequest request) {
        String ipAddress = request.getRemoteAddr();
        Bucket bucket = buckets.computeIfAbsent(ipAddress, this::newBucket);

        if (bucket.tryConsume(1)) {
            return ResponseEntity.ok("Resource accessed successfully!");
        } else {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Rate limit exceeded. Please try again later.");
        }
    }

    private Bucket newBucket(String key) {
        // Limit to 10 requests per minute per IP
        Refill refill = Refill.greedy(10, Duration.ofMinutes(1));
        Bandwidth limit = Bandwidth.classic(10, refill);
        return Bucket4j.builder().addLimit(limit).build();
    }
}

In this example, each incoming request gets its IP address. We then look for a Bucket associated with that IP in our buckets map. If one doesn’t exist, we create a new one using newBucket, which is configured to allow 10 tokens (requests) per minute. bucket.tryConsume(1) attempts to take one token; if successful, the request proceeds. If not, a 429 Too Many Requests response is returned.

This basic setup works fine for a single application instance. However, if you scale your application to multiple instances behind a load balancer, each instance will have its own independent Bucket4j state. This means an IP address could make 10 requests to instance A and another 10 requests to instance B within the same minute, bypassing your intended rate limit. This is where Redis comes in.

To achieve distributed rate limiting, we need a shared state. Bucket4j offers extensions for this, including bucket4j-redis. You’ll need to add the bucket4j-redis dependency to your pom.xml or build.gradle.

<!-- Maven -->
<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.6.0</version>
</dependency>

Then, you configure Bucket4j to use Redis as its backend. This involves setting up a Redis client (like Jedis or Lettuce) and creating a Redis backend for Bucket4j.

import io.github.bucket4j.Bucket;
import io.github.bucket4j.grid.jcache.JCacheProxyManager;
import io.github.bucket4j.redis.jedis.jedis.Bucket4jRedis;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.cache.CacheManager;
import javax.cache.Caching;
import java.time.Duration;

@Configuration
public class Bucket4jConfig {

    // Using Lettuce as the Redis client
    private final RedisClient redisClient = RedisClient.create("redis://localhost:6379");
    private final StatefulRedisConnection<String, String> connection = redisClient.connect();

    @Bean
    public Bucket4jRedis getBucket4jRedis() {
        // This is a simplified example. In a real application, you'd want to configure
        // your Redis connection more robustly and potentially use a connection pool.
        return Bucket4jRedis.builder()
                .withRedisConnection(connection)
                .build();
    }

    @Bean
    public Bucket getBucket(Bucket4jRedis bucket4jRedis) {
        // Define the rate limit: 10 requests per minute
        return bucket4jRedis.builder()
                .addLimit(io.github.bucket4j.Bandwidth.classic(10, io.github.bucket4j.Refill.greedy(10, Duration.ofMinutes(1))))
                .build();
    }

    // You might also need to add a JCacheManager if your Bucket4j configuration
    // relies on it for distributed caching, though bucket4j-redis often abstracts this.
    // For simplicity, we'll assume bucket4j-redis handles the direct Redis interaction.
}

Now, in your controller, instead of using a local ConcurrentHashMap, you inject the Bucket instance provided by the Bucket4jConfig. The Bucket4jRedis implementation ensures that when bucket.tryConsume(1) is called, it interacts with Redis to check and update the token count for the given key (e.g., IP address).

import io.github.bucket4j.Bucket;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@RestController
public class DistributedRateLimitedController {

    private final Bucket bucket; // Injected from Bucket4jConfig

    public DistributedRateLimitedController(Bucket bucket) {
        this.bucket = bucket;
    }

    @GetMapping("/distributed-limited")
    public ResponseEntity<String> getDistributedLimitedResource(HttpServletRequest request) {
        String ipAddress = request.getRemoteAddr(); // Key for rate limiting

        // The Bucket instance is now aware of the distributed Redis backend
        if (bucket.tryConsume(1)) {
            return ResponseEntity.ok("Resource accessed successfully (distributed limit)!");
        } else {
            return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Rate limit exceeded. Please try again later.");
        }
    }
}

The core idea here is that Bucket4jRedis uses Redis commands like INCR and EXPIRE (or more sophisticated atomic operations) to manage the token counts and their expiration times. When tryConsume is called, it atomically checks the current token count in Redis for the given key (the IP address in this case) and decrements it if tokens are available. If the key doesn’t exist, it initializes it with the maximum tokens and sets an expiration time based on the refill strategy. This guarantees that even across multiple application instances, the rate limit is enforced based on a single, shared state in Redis.

A subtle but powerful aspect of Bucket4j’s Redis integration is its ability to handle network partitions or Redis unavailability gracefully, depending on the configuration. By default, if Redis is unreachable, Bucket4j might still allow requests or block them, depending on how you’ve wired up your Bucket4jRedis builder and error handling. You can configure fallback mechanisms or specific error responses when the distributed backend is down, ensuring your application remains somewhat functional or fails predictably.

The next step is to explore more advanced Bucket4j features like multi-tiered limits, different refill strategies (e.g., intervally), and how to integrate it with API gateways for centralized rate limiting.

Want structured learning?

Take the full Spring-boot course →