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:
@@ -0,0 +1,85 @@
|
||||
# rate-limit
|
||||
|
||||
Companion code for **Rate Limiting Spring Boot APIs with Bucket4j and Redis: Per-User, Per-Key and
|
||||
Global Limits** on [ankurm.com](https://ankurm.com/spring-boot-rate-limiting-bucket4j-redis/).
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
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`](src/main/java/com/ankurm/ratelimit/RateLimitFilter.java) | the servlet filter: identifies the caller, asks the limiter, writes the headers and the 429 |
|
||||
| [`RateLimiter`](src/main/java/com/ankurm/ratelimit/RateLimiter.java) | consults the caller's bucket, then the global bucket |
|
||||
| [`RedisRateLimitConfig`](src/main/java/com/ankurm/ratelimit/RedisRateLimitConfig.java) | Bucket4j's Lettuce proxy manager on Boot's Redis client |
|
||||
| [`RateLimitProperties`](src/main/java/com/ankurm/ratelimit/RateLimitProperties.java) | every limit, from `application.yml` |
|
||||
| [`application.yml`](src/main/resources/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`](output/01-token-bucket-basics.txt), [`01b-burst-and-sustained.txt`](output/01b-burst-and-sustained.txt), [`01c-per-instance-buckets.txt`](output/01c-per-instance-buckets.txt) |
|
||||
| `PerUserLimitTest` (4, MockMvc + real Redis + manual clock) | [`02-per-user.txt`](output/02-per-user.txt) |
|
||||
| `ApiKeyLimitTest` (4) | [`03-api-keys.txt`](output/03-api-keys.txt) |
|
||||
| `GlobalLimitTest` (1) | [`04-global.txt`](output/04-global.txt) |
|
||||
| `TwoInstancesTest` (4, two applications in one JVM) | [`05-two-instances.txt`](output/05-two-instances.txt) |
|
||||
| `ConfigChangeTest` (1) | [`06-config-change.txt`](output/06-config-change.txt) |
|
||||
| `RedisOutageTest` (1) | [`07-redis-outage.txt`](output/07-redis-outage.txt) |
|
||||
| `scripts/two-jvms.sh` (two real JVMs, curl) | [`08-two-jvms.txt`](output/08-two-jvms.txt) |
|
||||
| surefire totals | [`00-test-summary.txt`](output/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.
|
||||
Reference in New Issue
Block a user