Add rate-limit module: Bucket4j 8.21 token buckets on Redis in a Spring Boot 4.1 filter, per-user/per-key/global limits, 429 with Retry-After, two instances sharing one bucket

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
Claude
2026-10-28 18:27:06 +00:00
parent 99a8a0df42
commit fd0cf9b527
37 changed files with 1705 additions and 1 deletions
@@ -0,0 +1,58 @@
package com.ankurm.ratelimit;
import java.time.Duration;
import io.github.bucket4j.TimeMeter;
import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
import io.github.bucket4j.distributed.proxy.ProxyManager;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.RedisCodec;
import io.lettuce.core.codec.StringCodec;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
/**
* Wires Bucket4j to the Lettuce client that Spring Boot already configured, so
* {@code spring.data.redis.host/port/password/ssl} apply to the rate limiter too.
*/
@Configuration(proxyBeanMethods = false)
class RedisRateLimitConfig {
/**
* A second connection on Boot's RedisClient, with String keys and byte[] values. Bucket4j
* stores each bucket as a binary blob, so the value codec must not touch the bytes.
*/
@Bean(destroyMethod = "close")
StatefulRedisConnection<String, byte[]> rateLimitConnection(LettuceConnectionFactory factory) {
RedisClient client = (RedisClient) factory.getRequiredNativeClient();
StatefulRedisConnection<String, byte[]> connection =
client.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
connection.setTimeout(Duration.ofMillis(500));
return connection;
}
/**
* The proxy manager is the only Redis-aware object. Everything else talks to
* {@link ProxyManager}, so the same code runs on Hazelcast, JDBC or Redisson by swapping this
* bean.
*
* <p>The clock is a bean so a test can move time without sleeping. Production gets the system
* clock.
*/
@Bean
ProxyManager<String> rateLimitProxyManager(StatefulRedisConnection<String, byte[]> connection,
ObjectProvider<TimeMeter> clock) {
return Bucket4jLettuce.casBasedBuilder(connection)
// Delete a bucket's key once it has been full for 10 seconds: idle callers cost no memory.
.expirationAfterWrite(
ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(10)))
.clientClock(clock.getIfAvailable(() -> TimeMeter.SYSTEM_MILLISECONDS))
.build();
}
}