pr walkthrough
Per-token rate limits
Moves rate limiting from per-IP to per-token, so one office NAT can't exhaust the budget for every client behind it. Four files, one new bucket table, no behavior change for untokened requests.
Shape of the change
gateway/
limits.py +38 -9
middleware.py +2 -1
ratelimit.conf +1 -1
migrations/
0042_token_buckets.sql +6
The interesting part is
gateway/limits.py:41 on GitHub: the bucket key changes from the remote address to the token id when one is
present. Everything else is plumbing that key through.
The ceilings themselves
None of these numbers move. What moves is what they are counted against: a plan's ceiling used to be spent by every client behind one address, and is now spent per token.
| Plan | A minute | Burst | Counted against |
|---|---|---|---|
| Free | 60 | 120 | the token |
| Team | 600 | 1,200 | the token |
| Enterprise | 6,000 | 12,000 | the token, per environment |
| Untokened | 60 | 60 | the remote address, as before |
The reviewed rate-limit configuration
This is the exact branch snapshot the walkthrough was checked against, not a second rendering of the diff.
The core diff
diff --git a/gateway/limits.py b/gateway/limits.py
--- a/gateway/limits.py
+++ b/gateway/limits.py
@@ -38,6 +38,8 @@ class Limiter:
- def bucket_key(self, request):
- return request.remote_addr
+ def bucket_key(self, request):
+ if request.token:
+ return f"tok:{request.token.id}"
+ return f"ip:{request.remote_addr}"
def allow(self, request):
key = self.bucket_key(request)
return self.buckets[key].take()
diff --git a/gateway/middleware.py b/gateway/middleware.py
--- a/gateway/middleware.py
+++ b/gateway/middleware.py
@@ -12,3 +12,4 @@ def attach(app):
def wrap(request):
- limiter.check(request.remote_addr)
+ limiter.check(request)
+ request.limit_key = limiter.bucket_key(request)
return request
diff --git a/gateway/ratelimit.conf b/gateway/ratelimit.conf
--- a/gateway/ratelimit.conf
+++ b/gateway/ratelimit.conf
@@ -1,2 +1,2 @@
burst_multiplier = 2
-bucket_ttl_minutes = 10
+bucket_ttl_minutes = 30
\ No newline at end of file
diff --git a/gateway/migrations/0042_token_buckets.sql b/gateway/migrations/0042_token_buckets.sql
--- /dev/null
+++ b/gateway/migrations/0042_token_buckets.sql
@@ -0,0 +1,6 @@
+create table token_buckets (
+ key text primary key,
+ tokens integer not null,
+ refilled_at timestamptz not null
+);
+create index token_buckets_refilled_at_cleanup_idx on token_buckets (refilled_at) where tokens = 0;
Prefixes keep token and address buckets disjoint
def bucket_key(self, request):
if request.token:
return f"tok:{request.token.id}"
return f"ip:{request.remote_addr}"
Testing
| Suite | What it proves | Held by |
|---|---|---|
| Unit | The bucket key for tokened, untokened, and both-present requests. |
test_the_bucket_key_prefers_the_token_and_falls_back_to_the_address
|
| Integration | Two tokens behind one address each get a full budget; two addresses on one token share one. |
test_two_tokens_behind_one_address_each_get_a_full_budget,
test_two_addresses_on_one_token_share_one_budget
|
Both suites run under the rate-limit marker:
# the integration half needs the migration applied first
cd gateway && alembic upgrade head
pytest tests/ -m ratelimit --maxfail=1
The two-token case's transcript, from the branch as pushed:
$ pytest tests/ratelimit/test_bucket_identity.py::test_two_tokens_behind_one_address_each_get_a_full_budget -q .. [100%] 2 passed in 1.84s