# 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.