Rate Limiting with Bucket4j in Java

Introduction

Bucket4j is a Java rate-limiting library based on the token-bucket algorithm. It provides a powerful and flexible way to control the rate of operations in distributed and standalone applications, preventing abuse and ensuring fair resource usage.

Core Concepts

Token Bucket Algorithm

The token bucket algorithm works by:

  • Bucket Capacity: Maximum number of tokens the bucket can hold
  • Refill Rate: How quickly tokens are replenished
  • Tokens: Each operation consumes one or more tokens
  • Blocking: Operations wait if insufficient tokens are available

Basic Setup and Dependencies

Maven Dependencies

<dependencies>
<!-- Core Bucket4j library -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.0</version>
</dependency>
<!-- For distributed caching -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-jcache</artifactId>
<version>8.10.0</version>
</dependency>
<!-- For Hazelcast backend -->
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast</artifactId>
<version>5.3.6</version>
</dependency>
<!-- For Redis backend -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.3.1.RELEASE</version>
</dependency>
</dependencies>

Basic Rate Limiting

Local In-Memory Rate Limiter

public class BasicRateLimiter {
public void demonstrateBasicRateLimiting() {
// Create a bandwidth: 10 tokens, refill 10 tokens every 1 minute
Bandwidth limit = Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1)));
// Create bucket configuration
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit)
.build();
// Create bucket
Bucket bucket = Bucket4j.builder()
.addLimit(limit)
.build();
// Usage examples
for (int i = 1; i <= 15; i++) {
if (bucket.tryConsume(1)) {
System.out.println("Request " + i + ": Allowed");
} else {
System.out.println("Request " + i + ": Rate limited");
}
}
}
public void blockingOperations() throws InterruptedException {
Bandwidth limit = Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1)));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
// Block until tokens available
CompletableFuture<Boolean> future = bucket.asAsync().tryConsume(1);
boolean consumed = future.get(); // blocks until token available
// Or use blocking API
bucket.asBlocking().consume(1); // blocks until token available
}
}

Advanced Configuration

Multiple Bandwidth Limits

public class AdvancedRateLimiting {
public void multipleBandwidthLimits() {
// Multiple limits: 1000 requests per hour AND 100 requests per minute
Bucket bucket = Bucket4j.builder()
.addLimit(Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofHours(1))))
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
.build();
// Complex refill strategies
Bandwidth greedyRefill = Bandwidth.classic(10, 
Refill.greedy(10, Duration.ofMinutes(1)));
Bandwidth intervallyAligned = Bandwidth.classic(100,
Refill.intervallyAligned(100, Duration.ofHours(1), 
Instant.now(), true));
}
public void customBandwidthConfiguration() {
// Bandwidth with custom parameters
Bandwidth limit = Bandwidth.classic(50, Refill.intervally(50, Duration.ofMinutes(1)))
.withInitialTokens(25) // Start with 25 tokens
.withId("api-limit"); // Identifier for the limit
Bucket bucket = Bucket4j.builder()
.addLimit(limit)
.build();
}
}

Spring Boot Integration

Configuration Class

@Configuration
public class RateLimitConfig {
@Bean
public Bucket apiRateLimitBucket() {
Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
return Bucket4j.builder().addLimit(limit).build();
}
@Bean
public Bucket loginRateLimitBucket() {
Bandwidth limit = Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1)));
return Bucket4j.builder().addLimit(limit).build();
}
@Bean
public Bucket premiumUserBucket() {
Bandwidth premiumLimit = Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofHours(1)));
return Bucket4j.builder().addLimit(premiumLimit).build();
}
}

Rate Limit Service

@Service
public class RateLimitService {
private final Bucket apiRateLimitBucket;
private final Bucket loginRateLimitBucket;
private final Bucket premiumUserBucket;
// Cache for user-specific buckets
private final Cache<String, Bucket> userBuckets = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofHours(1))
.build();
public RateLimitService(Bucket apiRateLimitBucket, 
Bucket loginRateLimitBucket,
Bucket premiumUserBucket) {
this.apiRateLimitBucket = apiRateLimitBucket;
this.loginRateLimitBucket = loginRateLimitBucket;
this.premiumUserBucket = premiumUserBucket;
}
public boolean tryApiCall() {
return apiRateLimitBucket.tryConsume(1);
}
public boolean tryLogin(String username) {
return loginRateLimitBucket.tryConsume(1);
}
public boolean tryConsume(String bucketKey, long tokens) {
Bucket bucket = userBuckets.get(bucketKey, this::createUserBucket);
return bucket.tryConsume(tokens);
}
private Bucket createUserBucket(String key) {
Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
return Bucket4j.builder().addLimit(limit).build();
}
public ConsumptionProbe tryConsumeWithDetail(String bucketKey, long tokens) {
Bucket bucket = userBuckets.get(bucketKey, this::createUserBucket);
return bucket.tryConsumeAndReturnRemaining(tokens);
}
public Map<String, Object> getRateLimitInfo(String bucketKey) {
Bucket bucket = userBuckets.getIfPresent(bucketKey);
if (bucket == null) {
return Map.of("available", 0, "waitTime", 0);
}
return Map.of(
"available", bucket.getAvailableTokens(),
"waitTime", bucket.getAvailableTokens() > 0 ? 0 : 
bucket.estimateAbilityToConsume(1).getNanosToWait() / 1_000_000_000
);
}
}

Spring Web MVC Integration

Rate Limit Annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
String value() default "global";
long tokens() default 1;
String key() default "";
}

Rate Limit Interceptor

@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimitService rateLimitService;
public RateLimitInterceptor(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
@Override
public boolean preHandle(HttpServletRequest request, 
HttpServletResponse response, 
Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
RateLimited rateLimited = handlerMethod.getMethodAnnotation(RateLimited.class);
if (rateLimited != null) {
String bucketKey = resolveBucketKey(request, rateLimited);
long tokens = rateLimited.tokens();
ConsumptionProbe probe = rateLimitService.tryConsumeWithDetail(bucketKey, tokens);
if (!probe.isConsumed()) {
response.setStatus(429); // Too Many Requests
response.setContentType("application/json");
Map<String, Object> error = Map.of(
"error", "Rate limit exceeded",
"message", "Too many requests",
"retryAfter", probe.getNanosToWaitForRefill() / 1_000_000_000,
"availableTokens", probe.getRemainingTokens()
);
response.getWriter().write(new ObjectMapper().writeValueAsString(error));
return false;
}
// Add rate limit headers
addRateLimitHeaders(response, probe);
}
}
return true;
}
private String resolveBucketKey(HttpServletRequest request, RateLimited rateLimited) {
if (!rateLimited.key().isEmpty()) {
return rateLimited.key();
}
// Use IP address as default key
String ipAddress = getClientIpAddress(request);
return rateLimited.value() + ":" + ipAddress;
}
private String getClientIpAddress(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader != null) {
return xfHeader.split(",")[0];
}
return request.getRemoteAddr();
}
private void addRateLimitHeaders(HttpServletResponse response, ConsumptionProbe probe) {
response.setHeader("X-RateLimit-Limit", String.valueOf(probe.getConfiguration().getBandwidths()[0].getCapacity()));
response.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
response.setHeader("X-RateLimit-Reset", String.valueOf(
System.currentTimeMillis() + probe.getNanosToWaitForRefill() / 1_000_000));
}
}

Controller Usage

@RestController
@RequestMapping("/api")
public class ApiController {
private final RateLimitService rateLimitService;
public ApiController(RateLimitService rateLimitService) {
this.rateLimitService = rateLimitService;
}
@GetMapping("/public/data")
@RateLimited(value = "public-api", tokens = 1)
public ResponseEntity<?> getPublicData() {
return ResponseEntity.ok(Map.of("data", "Public data accessed"));
}
@PostMapping("/auth/login")
@RateLimited(value = "login", tokens = 1)
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
if (rateLimitService.tryLogin(request.getUsername())) {
// Process login
return ResponseEntity.ok(Map.of("status", "Login successful"));
} else {
return ResponseEntity.status(429).body(Map.of("error", "Too many login attempts"));
}
}
@GetMapping("/user/profile")
@RateLimited(value = "user-api", key = "#userId", tokens = 1)
public ResponseEntity<?> getUserProfile(@RequestParam String userId) {
return ResponseEntity.ok(Map.of("profile", "User profile data"));
}
@GetMapping("/premium/data")
@RateLimited(value = "premium-api", tokens = 5)
public ResponseEntity<?> getPremiumData() {
return ResponseEntity.ok(Map.of("data", "Premium data accessed"));
}
}

Distributed Rate Limiting

JCache (JSR-107) Integration

@Configuration
public class DistributedRateLimitConfig {
@Bean
public javax.cache.Cache<String, byte[]> cache() {
CachingProvider cachingProvider = Caching.getCachingProvider();
CacheManager cacheManager = cachingProvider.getCacheManager();
CompleteConfiguration<String, byte[]> config = 
new MutableConfiguration<String, byte[]>()
.setTypes(String.class, byte.class);
return cacheManager.createCache("rate-limit-cache", config);
}
@Bean
public ProxyManager<String> proxyManager(javax.cache.Cache<String, byte[]> cache) {
return new JCacheProxyManager<>(cache);
}
@Bean
public Bucket userBucket(ProxyManager<String> proxyManager) {
Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit)
.build();
return proxyManager.builder().build("user-bucket", configuration);
}
}

Hazelcast Backend

@Configuration
public class HazelcastConfig {
@Bean
public HazelcastInstance hazelcastInstance() {
Config config = new Config();
config.setClusterName("rate-limit-cluster");
// Configure map for rate limiting
MapConfig mapConfig = new MapConfig();
mapConfig.setName("rate-limit-buckets");
mapConfig.setTimeToLiveSeconds(3600);
config.addMapConfig(mapConfig);
return Hazelcast.newHazelcastInstance(config);
}
@Bean
public ProxyManager<String> hazelcastProxyManager(HazelcastInstance hazelcastInstance) {
return new HazelcastProxyManager<>(hazelcastInstance);
}
}

Redis Backend

@Configuration
public class RedisRateLimitConfig {
@Bean
public RedisClient redisClient() {
return RedisClient.create("redis://localhost:6379");
}
@Bean
public ProxyManager<String> redisProxyManager(RedisClient redisClient) {
return new RedisProxyManager<>(redisClient);
}
@Bean
public BucketConfiguration globalBucketConfig() {
return BucketConfiguration.builder()
.addLimit(Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofHours(1))))
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
.build();
}
}

Advanced Use Cases

Tiered Rate Limiting

@Service
public class TieredRateLimitService {
private final Map<String, BucketConfiguration> tierConfigs;
private final ProxyManager<String> proxyManager;
public TieredRateLimitService(ProxyManager<String> proxyManager) {
this.proxyManager = proxyManager;
this.tierConfigs = createTierConfigurations();
}
private Map<String, BucketConfiguration> createTierConfigurations() {
Map<String, BucketConfiguration> configs = new HashMap<>();
// Free tier
configs.put("free", BucketConfiguration.builder()
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofHours(1))))
.build());
// Basic tier
configs.put("basic", BucketConfiguration.builder()
.addLimit(Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofHours(1))))
.build());
// Premium tier
configs.put("premium", BucketConfiguration.builder()
.addLimit(Bandwidth.classic(10000, Refill.intervally(10000, Duration.ofHours(1))))
.build());
return configs;
}
public boolean tryConsume(String userId, String tier, long tokens) {
BucketConfiguration config = tierConfigs.getOrDefault(tier, tierConfigs.get("free"));
Bucket bucket = proxyManager.builder().build(userId, config);
return bucket.tryConsume(tokens);
}
public ConsumptionProbe tryConsumeWithDetail(String userId, String tier, long tokens) {
BucketConfiguration config = tierConfigs.getOrDefault(tier, tierConfigs.get("free"));
Bucket bucket = proxyManager.builder().build(userId, config);
return bucket.tryConsumeAndReturnRemaining(tokens);
}
}

Adaptive Rate Limiting

@Service
public class AdaptiveRateLimitService {
private final Map<String, Bucket> dynamicBuckets = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@PostConstruct
public void init() {
// Clean up expired buckets every hour
scheduler.scheduleAtFixedRate(this::cleanupExpiredBuckets, 1, 1, TimeUnit.HOURS);
}
public boolean tryConsumeAdaptive(String key, long baseRate, double loadFactor) {
Bucket bucket = dynamicBuckets.computeIfAbsent(key, k -> 
createAdaptiveBucket(baseRate, loadFactor));
return bucket.tryConsume(1);
}
private Bucket createAdaptiveBucket(long baseRate, double loadFactor) {
// Adjust rate based on system load
long adjustedRate = (long) (baseRate / loadFactor);
adjustedRate = Math.max(adjustedRate, 10); // Minimum rate
Bandwidth limit = Bandwidth.classic(adjustedRate, 
Refill.intervally(adjustedRate, Duration.ofMinutes(1)));
return Bucket4j.builder().addLimit(limit).build();
}
public void updateRateLimit(String key, long newRate) {
dynamicBuckets.computeIfPresent(key, (k, bucket) -> {
Bandwidth newLimit = Bandwidth.classic(newRate, 
Refill.intervally(newRate, Duration.ofMinutes(1)));
return Bucket4j.builder().addLimit(newLimit).build();
});
}
private void cleanupExpiredBuckets() {
long cutoff = System.currentTimeMillis() - Duration.ofHours(24).toMillis();
// Implement cleanup logic based on last access time
}
@PreDestroy
public void shutdown() {
scheduler.shutdown();
}
}

Monitoring and Metrics

Rate Limit Metrics

@Component
public class RateLimitMetrics {
private final MeterRegistry meterRegistry;
private final Counter allowedRequests;
private final Counter limitedRequests;
private final DistributionSummary waitTimeSummary;
public RateLimitMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.allowedRequests = Counter.builder("rate_limit.requests")
.tag("status", "allowed")
.register(meterRegistry);
this.limitedRequests = Counter.builder("rate_limit.requests")
.tag("status", "limited")
.register(meterRegistry);
this.waitTimeSummary = DistributionSummary.builder("rate_limit.wait_time")
.register(meterRegistry);
}
public void recordRequest(boolean allowed, long waitTimeNanos) {
if (allowed) {
allowedRequests.increment();
} else {
limitedRequests.increment();
}
if (waitTimeNanos > 0) {
waitTimeSummary.record(waitTimeNanos / 1_000_000.0); // Convert to milliseconds
}
}
public void recordBucketState(String bucketKey, long availableTokens, long capacity) {
Gauge.builder("rate_limit.available_tokens", () -> availableTokens)
.tag("bucket", bucketKey)
.register(meterRegistry);
Gauge.builder("rate_limit.capacity", () -> capacity)
.tag("bucket", bucketKey)
.register(meterRegistry);
}
}

Rate Limit Manager

@Service
public class RateLimitManager {
private final RateLimitMetrics metrics;
private final ProxyManager<String> proxyManager;
private final Map<String, BucketConfiguration> configurations;
public RateLimitManager(RateLimitMetrics metrics, ProxyManager<String> proxyManager) {
this.metrics = metrics;
this.proxyManager = proxyManager;
this.configurations = loadConfigurations();
}
public RateLimitResult tryConsume(String bucketKey, String configName, long tokens) {
BucketConfiguration config = configurations.get(configName);
if (config == null) {
return new RateLimitResult(true, 0, 0, 0);
}
Bucket bucket = proxyManager.builder().build(bucketKey, config);
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(tokens);
// Record metrics
metrics.recordRequest(probe.isConsumed(), probe.getNanosToWaitForRefill());
metrics.recordBucketState(bucketKey, probe.getRemainingTokens(), 
config.getBandwidths()[0].getCapacity());
return new RateLimitResult(
probe.isConsumed(),
probe.getRemainingTokens(),
probe.getNanosToWaitForRefill(),
config.getBandwidths()[0].getCapacity()
);
}
public void updateConfiguration(String configName, BucketConfiguration newConfig) {
configurations.put(configName, newConfig);
}
public Map<String, Object> getBucketStats(String bucketKey, String configName) {
BucketConfiguration config = configurations.get(configName);
if (config == null) {
return Map.of();
}
Bucket bucket = proxyManager.builder().build(bucketKey, config);
long availableTokens = bucket.getAvailableTokens();
return Map.of(
"availableTokens", availableTokens,
"capacity", config.getBandwidths()[0].getCapacity(),
"config", configName
);
}
private Map<String, BucketConfiguration> loadConfigurations() {
Map<String, BucketConfiguration> configs = new HashMap<>();
// Load from database or configuration file
configs.put("api-global", BucketConfiguration.builder()
.addLimit(Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofHours(1))))
.build());
configs.put("api-user", BucketConfiguration.builder()
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
.build());
return configs;
}
public static class RateLimitResult {
private final boolean allowed;
private final long remainingTokens;
private final long waitTimeNanos;
private final long capacity;
public RateLimitResult(boolean allowed, long remainingTokens, 
long waitTimeNanos, long capacity) {
this.allowed = allowed;
this.remainingTokens = remainingTokens;
this.waitTimeNanos = waitTimeNanos;
this.capacity = capacity;
}
// Getters
public boolean isAllowed() { return allowed; }
public long getRemainingTokens() { return remainingTokens; }
public long getWaitTimeNanos() { return waitTimeNanos; }
public long getCapacity() { return capacity; }
}
}

Testing Rate Limiting

Unit Tests

@ExtendWith(MockitoExtension.class)
class RateLimitServiceTest {
@InjectMocks
private RateLimitService rateLimitService;
private Bucket bucket;
@BeforeEach
void setUp() {
Bandwidth limit = Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1)));
bucket = Bucket4j.builder().addLimit(limit).build();
}
@Test
void testRateLimitAllowsWithinLimit() {
for (int i = 0; i < 5; i++) {
assertTrue(bucket.tryConsume(1), "Request " + (i + 1) + " should be allowed");
}
}
@Test
void testRateLimitBlocksBeyondLimit() {
// Consume all tokens
for (int i = 0; i < 5; i++) {
bucket.tryConsume(1);
}
// Next request should be blocked
assertFalse(bucket.tryConsume(1), "Request beyond limit should be blocked");
}
@Test
void testConsumptionProbe() {
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
assertTrue(probe.isConsumed());
assertEquals(4, probe.getRemainingTokens());
}
}
@SpringBootTest
class RateLimitIntegrationTest {
@Autowired
private RateLimitService rateLimitService;
@Test
void testDistributedRateLimiting() {
String user1 = "user1";
String user2 = "user2";
// User1 consumes tokens
for (int i = 0; i < 5; i++) {
assertTrue(rateLimitService.tryConsume(user1, 1));
}
// User1 should be limited
assertFalse(rateLimitService.tryConsume(user1, 1));
// User2 should still have tokens
assertTrue(rateLimitService.tryConsume(user2, 1));
}
}

Best Practices

Production Configuration

@Configuration
public class ProductionRateLimitConfig {
@Bean
@Primary
public ProxyManager<String> proxyManager(RedisClient redisClient) {
return new RedisProxyManager<>(redisClient);
}
@Bean
public BucketConfiguration apiGlobalConfig() {
return BucketConfiguration.builder()
.addLimit(Bandwidth.classic(10000, Refill.intervally(10000, Duration.ofHours(1))))
.addLimit(Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofMinutes(1))))
.build();
}
@Bean
public BucketConfiguration apiUserConfig() {
return BucketConfiguration.builder()
.addLimit(Bandwidth.classic(1000, Refill.intervally(1000, Duration.ofHours(1))))
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
.build();
}
@Bean
public BucketConfiguration loginConfig() {
return BucketConfiguration.builder()
.addLimit(Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1))))
.build();
}
}
// Configuration properties
@ConfigurationProperties(prefix = "rate-limiting")
@Data
public class RateLimitProperties {
private Map<String, RateLimitConfig> configurations = new HashMap<>();
@Data
public static class RateLimitConfig {
private long capacity;
private Duration period;
private long initialTokens;
}
}

Conclusion

Bucket4j provides a robust and flexible rate-limiting solution for Java applications. Key benefits include:

  • Multiple algorithms: Token bucket, leaky bucket support
  • Distributed support: Redis, Hazelcast, JCache backends
  • Flexible configuration: Multiple bandwidth limits, custom refill strategies
  • Spring integration: Easy integration with Spring Boot applications
  • Monitoring: Comprehensive metrics and monitoring support
  • Performance: High-performance implementation suitable for production use

By implementing rate limiting with Bucket4j, you can protect your APIs from abuse, ensure fair usage, and maintain system stability under high load conditions.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper