Files
..

rate-limit

Companion code for Rate Limiting Spring Boot APIs with Bucket4j and Redis: Per-User, Per-Key and Global Limits on ankurm.com.

A servlet filter that applies three kinds of limit with Bucket4j, keeps every bucket in Redis so that two application instances share one limit, and answers 429 Too Many Requests with Retry-After and X-RateLimit-* headers. The test suite starts a real redis-server and two complete copies of the application.

Piece Version Checked against
Spring Boot 4.1.2 maven-metadata.xml for spring-boot (4.1.0 GA: 10 June 2026)
Bucket4j 8.21.0 maven-metadata.xml for com.bucket4j:bucket4j_jdk17-core (published 2 October 2026)
Lettuce 7.5.2.RELEASE what Spring Boot 4.1.2 manages
Redis 7.0.15 (redis-server binary) redis-server --version
JDK 25 Temurin 25.0.5

Bucket4j's artifact ids carry the JDK level: bucket4j_jdk17-core and bucket4j_jdk17-lettuce. The Java packages are still io.github.bucket4j.*.

Run it

export JAVA_HOME=/path/to/jdk-25
mvn -B test                   # 18 tests, transcripts 00-07 written to output/
./scripts/run-all.sh          # tests, then two real JVMs and curl (transcript 08)

redis-server and redis-cli must be on the PATH. The tests start throwaway servers on free ports and never touch a Redis on 6379. Docker was not available where this module was written, so no Testcontainers; swapping RedisServer for a GenericContainer changes only where the port comes from.

To run the application against your own Redis: mvn spring-boot:run (it uses spring.data.redis.host and spring.data.redis.port, default localhost:6379).

What is where

File What it is
RateLimitFilter the servlet filter: identifies the caller, asks the limiter, writes the headers and the 429
RateLimiter consults the caller's bucket, then the global bucket
RedisRateLimitConfig Bucket4j's Lettuce proxy manager on Boot's Redis client
RateLimitProperties every limit, from application.yml
application.yml 10/min per user, 5/min anonymous, free 5/min and pro 20/min plans, 1000/min global, /api/report costs 5

Endpoints

Path Limited Notes
GET /api/hello yes costs 1 token
GET /api/report yes costs 5 tokens
GET /health no outside /api/, the filter skips it

Identity: X-API-Key first (unknown key: 401, no bucket created), then X-User-Id, then the client address. X-User-Id stands in for an authenticated principal; do not trust a header like that in a real application.

Tests and the transcripts they write

Test Transcript
TokenBucketBasicsTest (3 tests, no Spring, no Redis) 01-token-bucket-basics.txt, 01b-burst-and-sustained.txt, 01c-per-instance-buckets.txt
PerUserLimitTest (4, MockMvc + real Redis + manual clock) 02-per-user.txt
ApiKeyLimitTest (4) 03-api-keys.txt
GlobalLimitTest (1) 04-global.txt
TwoInstancesTest (4, two applications in one JVM) 05-two-instances.txt
ConfigChangeTest (1) 06-config-change.txt
RedisOutageTest (1) 07-redis-outage.txt
scripts/two-jvms.sh (two real JVMs, curl) 08-two-jvms.txt
surefire totals 00-test-summary.txt (timings drift between runs)

The tests use a ManualClock (a Bucket4j TimeMeter the test advances), so "wait six seconds" costs no time. Because every transcript line is written by the test that asserts the same numbers, a line in output/ is something the build checks.

Things this module does not do

  • No authentication. See the note on X-User-Id above.
  • The client address is request.getRemoteAddr(). Behind a reverse proxy that is the proxy.
  • No metrics, no per-route configuration beyond the cost table, no rate limiting of the rejected requests themselves.

Licence: MIT, see the repository root.