Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
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-Idabove. - 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.