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:
Claude
2026-10-28 18:27:06 +00:00
parent 99a8a0df42
commit fd0cf9b527
37 changed files with 1705 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.935 s -- in com.ankurm.ratelimit.ConfigChangeTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.054 s -- in com.ankurm.ratelimit.TwoInstancesTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.573 s -- in com.ankurm.ratelimit.GlobalLimitTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.572 s -- in com.ankurm.ratelimit.RedisOutageTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.332 s -- in com.ankurm.ratelimit.ApiKeyLimitTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.342 s -- in com.ankurm.ratelimit.PerUserLimitTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.131 s -- in com.ankurm.ratelimit.TokenBucketBasicsTest
Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
@@ -0,0 +1,16 @@
== bucket: capacity 5, refill 5 tokens per 10 s
-- burst: five calls in the same instant
call 1: consumed=true remaining=4 waitForRefill=0s
call 2: consumed=true remaining=3 waitForRefill=0s
call 3: consumed=true remaining=2 waitForRefill=0s
call 4: consumed=true remaining=1 waitForRefill=0s
call 5: consumed=true remaining=0 waitForRefill=0s
call 6: consumed=false remaining=0 waitForRefill=2s
-- greedy refill: tokens trickle back at 1 per 2 s
after 1 s: available=0
after 2 s: available=1
after 4 s: available=2
after 8 s: available=4
-- intervally refill: nothing, then everything
after 9.999 s: available=0
after 10.000 s: available=5
@@ -0,0 +1,6 @@
== two limits: 3 per second AND 10 per minute (both must have a token)
second 0: 5 attempts, 3 allowed, running total 3
second 1: 5 attempts, 3 allowed, running total 6
second 2: 5 attempts, 3 allowed, running total 9
second 3: 5 attempts, 1 allowed, running total 10
second 4: 5 attempts, 0 allowed, running total 10
@@ -0,0 +1,2 @@
== the same 5-per-minute limit held in memory by two instances
12 requests alternating A, B: 10 allowed (the limit is 5)
+27
View File
@@ -0,0 +1,27 @@
== alice sends 12 requests with no pause
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=9 reset=6 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=8 reset=12 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=7 reset=18 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=6 reset=24 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=5 reset=30 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=4 reset=36 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=3 reset=42 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=2 reset=48 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=1 reset=54 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=0 reset=60 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 429 limit=10 remaining=0 reset=60 retry-after=6 scope=user
GET /api/hello X-User-Id: alice -> 429 limit=10 remaining=0 reset=60 retry-after=6 scope=user
-- body of the 429:
{"type":"about:blank","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded for scope 'user'. Retry in 6 second(s).","retryAfterSeconds":6}
== alice is out of tokens; bob is a different bucket
GET /api/hello X-User-Id: alice -> 429 limit=10 remaining=0 reset=60 retry-after=6 scope=user
GET /api/hello X-User-Id: bob -> 200 limit=10 remaining=9 reset=6 retry-after=null scope=user
== alice waits exactly Retry-After (6 s on the test clock) and tries again
GET /api/hello X-User-Id: alice -> 429 limit=10 remaining=0 reset=60 retry-after=6 scope=user
-- after 5 s: still too early
GET /api/hello X-User-Id: alice -> 429 limit=10 remaining=0 reset=55 retry-after=1 scope=user
-- after 6 s in total:
GET /api/hello X-User-Id: alice -> 200 limit=10 remaining=0 reset=60 retry-after=null scope=user
GET /api/hello X-User-Id: alice -> 429 limit=10 remaining=0 reset=60 retry-after=6 scope=user
== /health is outside /api/, so the filter skips it
30 requests to /health -> all 200, X-RateLimit-Limit header present: false
+25
View File
@@ -0,0 +1,25 @@
== free plan is 5 per minute, pro plan is 20 per minute
25 requests each: free-key-123 allowed 5, pro-key-456 allowed 20
== /api/report costs 5 tokens, /api/hello costs 1
GET /api/hello X-API-Key: pro-key-456 -> 200 limit=20 remaining=19 retry-after=null
GET /api/report X-API-Key: pro-key-456 -> 200 limit=20 remaining=14 retry-after=null
GET /api/report X-API-Key: pro-key-456 -> 200 limit=20 remaining=9 retry-after=null
GET /api/report X-API-Key: pro-key-456 -> 200 limit=20 remaining=4 retry-after=null
GET /api/report X-API-Key: pro-key-456 -> 429 limit=20 remaining=4 retry-after=3
-- 20 tokens = 1 hello + 3 reports (16), 4 left; a 4th report needs 5, so it waits for the refill
== an unknown API key
GET /api/hello X-API-Key: made-up-key -> 401 limit=null remaining=null retry-after=null
body: {"type":"about:blank","title":"Unauthorized","status":401,"detail":"Unknown API key"}
keys in Redis after the 401: 0
== what is in Redis after one request each from a key, a user and an anonymous caller
$ redis-cli --scan | sort
rl:global
rl:ip:127.0.0.1
rl:key:ea39d5800282edc4
rl:user:alice
$ redis-cli TYPE rl:key:ea39d5800282edc4
string
$ redis-cli STRLEN rl:key:ea39d5800282edc4
116
$ redis-cli PTTL rl:user:alice (a bucket expires once it has been full for 10 s)
positive and at most 70000: true
+18
View File
@@ -0,0 +1,18 @@
== global limit 12 per minute; four users send 3 requests each
GET /api/hello X-User-Id: u1 -> 200 limit=10 remaining=9 retry-after=null scope=user
GET /api/hello X-User-Id: u1 -> 200 limit=10 remaining=8 retry-after=null scope=user
GET /api/hello X-User-Id: u1 -> 200 limit=10 remaining=7 retry-after=null scope=user
GET /api/hello X-User-Id: u2 -> 200 limit=10 remaining=9 retry-after=null scope=user
GET /api/hello X-User-Id: u2 -> 200 limit=10 remaining=8 retry-after=null scope=user
GET /api/hello X-User-Id: u2 -> 200 limit=10 remaining=7 retry-after=null scope=user
GET /api/hello X-User-Id: u3 -> 200 limit=10 remaining=9 retry-after=null scope=user
GET /api/hello X-User-Id: u3 -> 200 limit=10 remaining=8 retry-after=null scope=user
GET /api/hello X-User-Id: u3 -> 200 limit=10 remaining=7 retry-after=null scope=user
GET /api/hello X-User-Id: u4 -> 200 limit=10 remaining=9 retry-after=null scope=user
GET /api/hello X-User-Id: u4 -> 200 limit=10 remaining=8 retry-after=null scope=user
GET /api/hello X-User-Id: u4 -> 200 limit=10 remaining=7 retry-after=null scope=user
== u5 has never called before, so their own bucket is full
GET /api/hello X-User-Id: u5 -> 429 limit=12 remaining=0 retry-after=5 scope=global
-- u5 was rejected by the global bucket, but their own bucket already paid: 9 of 10 tokens left
== five seconds later (Retry-After), u5 tries again
GET /api/hello X-User-Id: u5 -> 200 limit=10 remaining=8 retry-after=null scope=user
+18
View File
@@ -0,0 +1,18 @@
== free-key-123 allows 5 per minute; requests alternate between instance A and B
sent to A -> 200 remaining=4 (served by A)
sent to B -> 200 remaining=3 (served by B)
sent to A -> 200 remaining=2 (served by A)
sent to B -> 200 remaining=1 (served by B)
sent to A -> 200 remaining=0 (served by A)
sent to B -> 429 remaining=0 (served by -)
sent to A -> 429 remaining=0 (served by -)
allowed in total: 5 (not 10)
== one bucket in Redis, written by both instances
$ redis-cli --scan
rl:global
rl:key:c31ab6f47fba3609
== 200 concurrent requests for pro-key-456 (20 per minute), half to A and half to B
200 sent: 20 allowed, 180 rejected with 429
== instance B's clock is 30 s ahead of A's (user carol: 10 per minute, one token per 6 s)
A, bucket empty -> 429
B sees 30 s of refill that never happened on A's clock: 5 of 8 allowed
+10
View File
@@ -0,0 +1,10 @@
== deploy 1: user limit 10, config-version 1. alice makes 3 requests.
stored capacity in Redis: 10
== deploy 2: user limit raised to 20 in application.yml, config-version still 1
X-RateLimit-Limit header says 20
stored capacity in Redis: 10
X-RateLimit-Remaining: 6
== deploy 3: limit 20 and config-version 2
X-RateLimit-Limit header says 20
stored capacity in Redis: 20
X-RateLimit-Remaining: 11
+9
View File
@@ -0,0 +1,9 @@
== ratelimit.fail-open=true
Redis up: 200 X-RateLimit-Remaining=9
Redis down: 200 X-RateLimit-Status=unavailable X-RateLimit-Remaining=-
the request waited for the 500 ms command timeout: true
exception the filter caught: io.lettuce.core.RedisCommandTimeoutException
== ratelimit.fail-open=false
Redis up: 200 X-RateLimit-Remaining=9
Redis down: 503 X-RateLimit-Status=- X-RateLimit-Remaining=-
the request waited for the 500 ms command timeout: true
+23
View File
@@ -0,0 +1,23 @@
== two JVMs (A on 8081, B on 8082), one redis-server on 6411; free-key-123 allows 5 per minute
request 1 to :8081 -> 200
request 2 to :8082 -> 200
request 3 to :8081 -> 200
request 4 to :8082 -> 200
request 5 to :8081 -> 200
request 6 to :8082 -> 429
$ curl -si -H 'X-API-Key: free-key-123' http://localhost:8081/api/hello
HTTP/1.1 429
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60
X-RateLimit-Scope: api-key
Retry-After: 12
Content-Type: application/problem+json
{"type":"about:blank","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded for scope 'api-key'. Retry in 12 second(s).","retryAfterSeconds":12}
$ redis-cli -p 6411 --scan
rl:global
rl:key:ea39d5800282edc4