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,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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user