Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
86 lines
4.5 KiB
Markdown
86 lines
4.5 KiB
Markdown
# 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.
|