Repository navigation
Expand file tree
/
Copy pathrate_limiter.py
More file actions
207 lines (168 loc) · 7.62 KB
/
Copy pathrate_limiter.py
File metadata and controls
207 lines (168 loc) · 7.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""Token bucket rate limiter for MCP server tool calls.
Applied per-client (by IP or process identity) to prevent abuse.
All limiting is optional: enabled only when ENABLE_RATE_LIMITING=1.
Usage:
from rate_limiter import check_rate_limit
result = check_rate_limit(client_id="default", bucket="tool_calls")
if not result["allowed"]:
return result # {"allowed": False, "error": "rate_limit_exceeded", "retry_after": N}
Windows-Safe: ASCII only (cp1252 compatible)
"""
import math
import os
import threading
import time
# Module-level bucket registry: keyed by (client_id, bucket_name)
_buckets = {} # type: dict
_buckets_lock = threading.Lock()
# Default bucket configurations: (capacity, refill_rate tokens/second)
_BUCKET_DEFAULTS = {
"tool_calls": (100, 100.0 / 60.0), # 100 per minute
"llm_calls": (10, 10.0 / 60.0), # 10 per minute
# For tools that fan out internally: one invocation issues several upstream
# requests, so the tool-call rate is not the request rate. A tool looping
# ten calls per invocation at the tool_calls rate would issue 1000 upstream
# requests a minute. Sized so that ten-fold amplification stays within a
# 600/minute upstream cap, which is the Google URL Inspection limit that
# motivated it.
"amplified_calls": (10, 10.0 / 60.0), # 10 per minute
}
_FALLBACK_BUCKET = (60, 60.0 / 60.0)
# Fallback advice when a bucket has a non-positive refill rate and can
# therefore never recover on its own.
_RETRY_AFTER_SECONDS = 60
# Registry size above which _get_or_create_bucket prunes provably-full buckets.
# client_id is caller-supplied (an IP or process identity), so the key space is
# unbounded; without pruning the registry grows for the process lifetime.
_PRUNE_THRESHOLD = 1024
class TokenBucket(object):
"""Thread-safe token bucket for rate limiting.
Tokens are refilled continuously based on elapsed monotonic time since
the last refill check. The bucket never exceeds its capacity.
A monotonic clock is used rather than wall-clock time: a backward wall-clock
step (NTP correction, manual change) would produce a negative elapsed
interval and subtract tokens, locking a client out until the clock caught
back up, while a forward step would refill the bucket instantly.
Args:
capacity: Maximum number of tokens the bucket can hold.
refill_rate: Tokens added per second (can be fractional).
"""
def __init__(self, capacity, refill_rate):
# type: (float, float) -> None
self._capacity = float(capacity)
self._refill_rate = float(refill_rate)
self._tokens = float(capacity) # start full
self._last_refill = time.monotonic()
self._lock = threading.Lock()
def _refill(self):
# type: () -> None
"""Add tokens based on elapsed time. Must be called under self._lock."""
now = time.monotonic()
elapsed = max(0.0, now - self._last_refill)
added = elapsed * self._refill_rate
self._tokens = min(self._capacity, self._tokens + added)
self._last_refill = now
def consume(self, tokens=1):
# type: (int) -> bool
"""Attempt to consume tokens from the bucket.
Args:
tokens: Number of tokens to consume (default 1).
Returns:
True if tokens were available and consumed, False if the bucket
did not have enough tokens.
"""
with self._lock:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return True
return False
def seconds_until_available(self, tokens=1):
# type: (int) -> int
"""Seconds until the bucket holds at least the requested tokens.
Args:
tokens: Number of tokens the caller needs.
Returns:
0 if the tokens are already available, otherwise the number of
whole seconds (rounded up) until they will be. Buckets with a
non-positive refill rate never recover and report the fallback
retry window instead.
"""
with self._lock:
self._refill()
deficit = tokens - self._tokens
if deficit <= 0:
return 0
if self._refill_rate <= 0:
return _RETRY_AFTER_SECONDS
return int(math.ceil(deficit / self._refill_rate))
def is_provably_full(self):
# type: () -> bool
"""Whether the bucket has been idle long enough to be certainly full.
A bucket idle for at least capacity/refill_rate seconds has refilled
from empty to capacity, so discarding it and recreating it on the next
request (which starts full) is observationally equivalent. This is what
makes registry pruning safe rather than a way to bypass the limit.
Returns:
True when the bucket can be discarded without weakening the limit.
"""
with self._lock:
if self._refill_rate <= 0:
return False
idle = max(0.0, time.monotonic() - self._last_refill)
return idle >= (self._capacity / self._refill_rate)
def _prune_full_buckets():
# type: () -> None
"""Drop provably-full buckets from the registry.
Must be called while holding _buckets_lock. Lock ordering is
_buckets_lock then TokenBucket._lock; no code path takes them in the
opposite order, so this cannot deadlock.
"""
stale = [key for key, bucket in _buckets.items() if bucket.is_provably_full()]
for key in stale:
del _buckets[key]
def _get_or_create_bucket(client_id, bucket_name):
# type: (str, str) -> TokenBucket
"""Return existing bucket or create a new one with default settings."""
key = (client_id, bucket_name)
with _buckets_lock:
bucket = _buckets.get(key)
if bucket is not None:
return bucket
if len(_buckets) >= _PRUNE_THRESHOLD:
_prune_full_buckets()
capacity, refill_rate = _BUCKET_DEFAULTS.get(bucket_name, _FALLBACK_BUCKET)
bucket = TokenBucket(capacity, refill_rate)
_buckets[key] = bucket
return bucket
def check_rate_limit(client_id="default", bucket="tool_calls"):
# type: (str, str) -> dict
"""Check whether a client is within its rate limit for a given bucket.
If ENABLE_RATE_LIMITING is not set to "1" this function always returns
allowed without creating any bucket state.
Args:
client_id: Identifier for the client (e.g. IP address, process ID,
or "default" for a shared anonymous bucket).
bucket: Name of the rate limit bucket. Predefined buckets:
"tool_calls" (100/min) for ordinary tools, "llm_calls"
(10/min) for calls that bill or consume inference per
invocation, and "amplified_calls" (10/min) for tools that
issue several upstream requests per invocation, where the
tool-call rate is not the upstream request rate.
Unknown bucket names fall back to 60/min defaults.
Returns:
dict with key "allowed" (bool).
On denial also includes "error" ("rate_limit_exceeded") and
"retry_after" (int seconds until the next token is actually available,
derived from the bucket's own refill rate).
"""
if os.environ.get("ENABLE_RATE_LIMITING") != "1":
return {"allowed": True}
token_bucket = _get_or_create_bucket(client_id, bucket)
if token_bucket.consume():
return {"allowed": True}
return {
"allowed": False,
"error": "rate_limit_exceeded",
"retry_after": token_bucket.seconds_until_available(),
}