tsikol 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +579 -0
- data/Rakefile +12 -0
- data/docs/README.md +69 -0
- data/docs/api/middleware.md +721 -0
- data/docs/api/prompt.md +858 -0
- data/docs/api/resource.md +651 -0
- data/docs/api/server.md +509 -0
- data/docs/api/test-helpers.md +591 -0
- data/docs/api/tool.md +527 -0
- data/docs/cookbook/authentication.md +651 -0
- data/docs/cookbook/caching.md +877 -0
- data/docs/cookbook/dynamic-tools.md +970 -0
- data/docs/cookbook/error-handling.md +887 -0
- data/docs/cookbook/logging.md +1044 -0
- data/docs/cookbook/rate-limiting.md +717 -0
- data/docs/examples/code-assistant.md +922 -0
- data/docs/examples/complete-server.md +726 -0
- data/docs/examples/database-manager.md +1198 -0
- data/docs/examples/devops-tools.md +1382 -0
- data/docs/examples/echo-server.md +501 -0
- data/docs/examples/weather-service.md +822 -0
- data/docs/guides/completion.md +472 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/guides/middleware.md +823 -0
- data/docs/guides/project-structure.md +434 -0
- data/docs/guides/prompts.md +920 -0
- data/docs/guides/resources.md +720 -0
- data/docs/guides/sampling.md +804 -0
- data/docs/guides/testing.md +863 -0
- data/docs/guides/tools.md +627 -0
- data/examples/README.md +92 -0
- data/examples/advanced_features.rb +129 -0
- data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
- data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
- data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
- data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
- data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
- data/examples/basic-migrated/server.rb +25 -0
- data/examples/basic.rb +73 -0
- data/examples/full_featured.rb +175 -0
- data/examples/middleware_example.rb +112 -0
- data/examples/sampling_example.rb +104 -0
- data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
- data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
- data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
- data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
- data/examples/weather-service/server.rb +28 -0
- data/exe/tsikol +6 -0
- data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
- data/lib/tsikol/cli/templates/README.md.erb +38 -0
- data/lib/tsikol/cli/templates/gitignore.erb +49 -0
- data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
- data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
- data/lib/tsikol/cli/templates/server.rb.erb +24 -0
- data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
- data/lib/tsikol/cli.rb +203 -0
- data/lib/tsikol/error_handler.rb +141 -0
- data/lib/tsikol/health.rb +198 -0
- data/lib/tsikol/http_transport.rb +72 -0
- data/lib/tsikol/lifecycle.rb +149 -0
- data/lib/tsikol/middleware.rb +168 -0
- data/lib/tsikol/prompt.rb +101 -0
- data/lib/tsikol/resource.rb +53 -0
- data/lib/tsikol/router.rb +190 -0
- data/lib/tsikol/server.rb +660 -0
- data/lib/tsikol/stdio_transport.rb +108 -0
- data/lib/tsikol/test_helpers.rb +261 -0
- data/lib/tsikol/tool.rb +111 -0
- data/lib/tsikol/version.rb +5 -0
- data/lib/tsikol.rb +72 -0
- metadata +219 -0
@@ -0,0 +1,717 @@
|
|
1
|
+
# Rate Limiting Recipe
|
2
|
+
|
3
|
+
This recipe shows various strategies for implementing rate limiting in your MCP server to prevent abuse and ensure fair usage.
|
4
|
+
|
5
|
+
## Basic Rate Limiting
|
6
|
+
|
7
|
+
### Simple Request Counter
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
class BasicRateLimiter < Tsikol::Middleware
|
11
|
+
def initialize(app, options = {})
|
12
|
+
@app = app
|
13
|
+
@max_requests = options[:max_requests] || 60
|
14
|
+
@window_seconds = options[:window_seconds] || 60
|
15
|
+
@requests = Hash.new { |h, k| h[k] = [] }
|
16
|
+
@mutex = Mutex.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(request)
|
20
|
+
client_id = identify_client(request)
|
21
|
+
|
22
|
+
if rate_limited?(client_id)
|
23
|
+
return rate_limit_error(request["id"])
|
24
|
+
end
|
25
|
+
|
26
|
+
track_request(client_id)
|
27
|
+
@app.call(request)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def identify_client(request)
|
33
|
+
# Identify by various methods
|
34
|
+
request.dig("params", "_client_id") ||
|
35
|
+
request.dig("meta", "client_id") ||
|
36
|
+
request.dig("authenticated_user", "id") ||
|
37
|
+
"anonymous"
|
38
|
+
end
|
39
|
+
|
40
|
+
def rate_limited?(client_id)
|
41
|
+
@mutex.synchronize do
|
42
|
+
clean_old_requests(client_id)
|
43
|
+
@requests[client_id].size >= @max_requests
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def track_request(client_id)
|
48
|
+
@mutex.synchronize do
|
49
|
+
@requests[client_id] << Time.now
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def clean_old_requests(client_id)
|
54
|
+
cutoff = Time.now - @window_seconds
|
55
|
+
@requests[client_id].reject! { |time| time < cutoff }
|
56
|
+
end
|
57
|
+
|
58
|
+
def rate_limit_error(id)
|
59
|
+
{
|
60
|
+
jsonrpc: "2.0",
|
61
|
+
id: id,
|
62
|
+
error: {
|
63
|
+
code: -32005,
|
64
|
+
message: "Rate limit exceeded",
|
65
|
+
data: {
|
66
|
+
retry_after: @window_seconds,
|
67
|
+
limit: @max_requests
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
## Token Bucket Algorithm
|
76
|
+
|
77
|
+
### Advanced Rate Limiting with Bursts
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
class TokenBucketRateLimiter < Tsikol::Middleware
|
81
|
+
def initialize(app, options = {})
|
82
|
+
@app = app
|
83
|
+
@capacity = options[:capacity] || 10 # Max tokens
|
84
|
+
@refill_rate = options[:refill_rate] || 1 # Tokens per second
|
85
|
+
@buckets = Hash.new { |h, k| h[k] = create_bucket }
|
86
|
+
@mutex = Mutex.new
|
87
|
+
end
|
88
|
+
|
89
|
+
def call(request)
|
90
|
+
client_id = identify_client(request)
|
91
|
+
cost = request_cost(request)
|
92
|
+
|
93
|
+
if consume_tokens(client_id, cost)
|
94
|
+
@app.call(request)
|
95
|
+
else
|
96
|
+
rate_limit_error(request["id"], client_id)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def create_bucket
|
103
|
+
{
|
104
|
+
tokens: @capacity.to_f,
|
105
|
+
last_refill: Time.now
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
def consume_tokens(client_id, cost)
|
110
|
+
@mutex.synchronize do
|
111
|
+
bucket = @buckets[client_id]
|
112
|
+
refill_bucket(bucket)
|
113
|
+
|
114
|
+
if bucket[:tokens] >= cost
|
115
|
+
bucket[:tokens] -= cost
|
116
|
+
true
|
117
|
+
else
|
118
|
+
false
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def refill_bucket(bucket)
|
124
|
+
now = Time.now
|
125
|
+
elapsed = now - bucket[:last_refill]
|
126
|
+
|
127
|
+
# Add tokens based on elapsed time
|
128
|
+
tokens_to_add = elapsed * @refill_rate
|
129
|
+
bucket[:tokens] = [bucket[:tokens] + tokens_to_add, @capacity].min
|
130
|
+
bucket[:last_refill] = now
|
131
|
+
end
|
132
|
+
|
133
|
+
def request_cost(request)
|
134
|
+
# Different costs for different operations
|
135
|
+
case request["method"]
|
136
|
+
when "tools/call"
|
137
|
+
case request.dig("params", "name")
|
138
|
+
when "expensive_operation" then 5
|
139
|
+
when "ai_generation" then 3
|
140
|
+
else 1
|
141
|
+
end
|
142
|
+
when "sampling/sample"
|
143
|
+
10 # AI sampling is expensive
|
144
|
+
else
|
145
|
+
1
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def rate_limit_error(id, client_id)
|
150
|
+
bucket = @buckets[client_id]
|
151
|
+
wait_time = (1 - bucket[:tokens]) / @refill_rate
|
152
|
+
|
153
|
+
{
|
154
|
+
jsonrpc: "2.0",
|
155
|
+
id: id,
|
156
|
+
error: {
|
157
|
+
code: -32005,
|
158
|
+
message: "Rate limit exceeded",
|
159
|
+
data: {
|
160
|
+
retry_after: wait_time.ceil,
|
161
|
+
tokens_remaining: bucket[:tokens].floor,
|
162
|
+
refill_rate: @refill_rate
|
163
|
+
}
|
164
|
+
}
|
165
|
+
}
|
166
|
+
end
|
167
|
+
end
|
168
|
+
```
|
169
|
+
|
170
|
+
## Sliding Window Rate Limiter
|
171
|
+
|
172
|
+
### Precise Rate Limiting
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
class SlidingWindowRateLimiter < Tsikol::Middleware
|
176
|
+
def initialize(app, options = {})
|
177
|
+
@app = app
|
178
|
+
@max_requests = options[:max_requests] || 100
|
179
|
+
@window_seconds = options[:window_seconds] || 60
|
180
|
+
@precision = options[:precision] || 10 # Sub-windows
|
181
|
+
@counters = Hash.new { |h, k| h[k] = Array.new(@precision, 0) }
|
182
|
+
@mutex = Mutex.new
|
183
|
+
end
|
184
|
+
|
185
|
+
def call(request)
|
186
|
+
client_id = identify_client(request)
|
187
|
+
|
188
|
+
if exceeds_limit?(client_id)
|
189
|
+
return rate_limit_error(request["id"])
|
190
|
+
end
|
191
|
+
|
192
|
+
increment_counter(client_id)
|
193
|
+
@app.call(request)
|
194
|
+
end
|
195
|
+
|
196
|
+
private
|
197
|
+
|
198
|
+
def exceeds_limit?(client_id)
|
199
|
+
@mutex.synchronize do
|
200
|
+
current_count = calculate_current_count(client_id)
|
201
|
+
current_count >= @max_requests
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def calculate_current_count(client_id)
|
206
|
+
now = Time.now.to_f
|
207
|
+
window_size = @window_seconds.to_f / @precision
|
208
|
+
current_window = (now / window_size).floor % @precision
|
209
|
+
|
210
|
+
# Age out old windows
|
211
|
+
@counters[client_id].each_with_index do |count, i|
|
212
|
+
window_age = ((current_window - i) % @precision) * window_size
|
213
|
+
if window_age >= @window_seconds
|
214
|
+
@counters[client_id][i] = 0
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
@counters[client_id].sum
|
219
|
+
end
|
220
|
+
|
221
|
+
def increment_counter(client_id)
|
222
|
+
@mutex.synchronize do
|
223
|
+
window_size = @window_seconds.to_f / @precision
|
224
|
+
current_window = (Time.now.to_f / window_size).floor % @precision
|
225
|
+
@counters[client_id][current_window] += 1
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
```
|
230
|
+
|
231
|
+
## Distributed Rate Limiting
|
232
|
+
|
233
|
+
### Redis-Based Rate Limiter
|
234
|
+
|
235
|
+
```ruby
|
236
|
+
require 'redis'
|
237
|
+
|
238
|
+
class RedisRateLimiter < Tsikol::Middleware
|
239
|
+
def initialize(app, options = {})
|
240
|
+
@app = app
|
241
|
+
@redis = options[:redis] || Redis.new
|
242
|
+
@max_requests = options[:max_requests] || 100
|
243
|
+
@window_seconds = options[:window_seconds] || 60
|
244
|
+
@key_prefix = options[:key_prefix] || "mcp:rate_limit"
|
245
|
+
end
|
246
|
+
|
247
|
+
def call(request)
|
248
|
+
client_id = identify_client(request)
|
249
|
+
|
250
|
+
if rate_limited?(client_id)
|
251
|
+
return rate_limit_error(request["id"], client_id)
|
252
|
+
end
|
253
|
+
|
254
|
+
@app.call(request)
|
255
|
+
end
|
256
|
+
|
257
|
+
private
|
258
|
+
|
259
|
+
def rate_limited?(client_id)
|
260
|
+
key = "#{@key_prefix}:#{client_id}"
|
261
|
+
|
262
|
+
# Use Redis pipeline for atomic operations
|
263
|
+
result = @redis.pipelined do |pipeline|
|
264
|
+
pipeline.incr(key)
|
265
|
+
pipeline.expire(key, @window_seconds)
|
266
|
+
end
|
267
|
+
|
268
|
+
current_count = result[0]
|
269
|
+
current_count > @max_requests
|
270
|
+
end
|
271
|
+
|
272
|
+
def rate_limit_error(id, client_id)
|
273
|
+
key = "#{@key_prefix}:#{client_id}"
|
274
|
+
ttl = @redis.ttl(key)
|
275
|
+
|
276
|
+
{
|
277
|
+
jsonrpc: "2.0",
|
278
|
+
id: id,
|
279
|
+
error: {
|
280
|
+
code: -32005,
|
281
|
+
message: "Rate limit exceeded",
|
282
|
+
data: {
|
283
|
+
retry_after: ttl > 0 ? ttl : @window_seconds,
|
284
|
+
limit: @max_requests,
|
285
|
+
window: @window_seconds
|
286
|
+
}
|
287
|
+
}
|
288
|
+
}
|
289
|
+
end
|
290
|
+
end
|
291
|
+
```
|
292
|
+
|
293
|
+
### Redis Sliding Window
|
294
|
+
|
295
|
+
```ruby
|
296
|
+
class RedisSlidingWindowLimiter < Tsikol::Middleware
|
297
|
+
def initialize(app, options = {})
|
298
|
+
@app = app
|
299
|
+
@redis = options[:redis] || Redis.new
|
300
|
+
@max_requests = options[:max_requests] || 100
|
301
|
+
@window_seconds = options[:window_seconds] || 60
|
302
|
+
@key_prefix = options[:key_prefix] || "mcp:sliding"
|
303
|
+
end
|
304
|
+
|
305
|
+
def call(request)
|
306
|
+
client_id = identify_client(request)
|
307
|
+
|
308
|
+
if rate_limited?(client_id)
|
309
|
+
return rate_limit_error(request["id"])
|
310
|
+
end
|
311
|
+
|
312
|
+
@app.call(request)
|
313
|
+
end
|
314
|
+
|
315
|
+
private
|
316
|
+
|
317
|
+
def rate_limited?(client_id)
|
318
|
+
key = "#{@key_prefix}:#{client_id}"
|
319
|
+
now = Time.now.to_f
|
320
|
+
window_start = now - @window_seconds
|
321
|
+
|
322
|
+
# Redis Lua script for atomic sliding window
|
323
|
+
lua_script = <<~LUA
|
324
|
+
local key = KEYS[1]
|
325
|
+
local now = tonumber(ARGV[1])
|
326
|
+
local window_start = tonumber(ARGV[2])
|
327
|
+
local max_requests = tonumber(ARGV[3])
|
328
|
+
|
329
|
+
-- Remove old entries
|
330
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
|
331
|
+
|
332
|
+
-- Count current entries
|
333
|
+
local current = redis.call('ZCARD', key)
|
334
|
+
|
335
|
+
if current < max_requests then
|
336
|
+
-- Add new entry
|
337
|
+
redis.call('ZADD', key, now, now)
|
338
|
+
redis.call('EXPIRE', key, ARGV[4])
|
339
|
+
return 0
|
340
|
+
else
|
341
|
+
return 1
|
342
|
+
end
|
343
|
+
LUA
|
344
|
+
|
345
|
+
result = @redis.eval(lua_script, 1, key, now, window_start, @max_requests, @window_seconds + 1)
|
346
|
+
result == 1
|
347
|
+
end
|
348
|
+
end
|
349
|
+
```
|
350
|
+
|
351
|
+
## Tiered Rate Limiting
|
352
|
+
|
353
|
+
### Different Limits for Different Users
|
354
|
+
|
355
|
+
```ruby
|
356
|
+
class TieredRateLimiter < Tsikol::Middleware
|
357
|
+
def initialize(app, options = {})
|
358
|
+
@app = app
|
359
|
+
@tiers = options[:tiers] || default_tiers
|
360
|
+
@default_tier = options[:default_tier] || "free"
|
361
|
+
@limiters = {}
|
362
|
+
|
363
|
+
# Create limiter for each tier
|
364
|
+
@tiers.each do |tier, config|
|
365
|
+
@limiters[tier] = TokenBucketRateLimiter.new(nil, config)
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def call(request)
|
370
|
+
client_tier = determine_tier(request)
|
371
|
+
limiter = @limiters[client_tier] || @limiters[@default_tier]
|
372
|
+
|
373
|
+
# Use tier-specific limiter
|
374
|
+
limiter_response = limiter.call(request)
|
375
|
+
|
376
|
+
if limiter_response[:error]
|
377
|
+
# Add tier information to error
|
378
|
+
limiter_response[:error][:data][:tier] = client_tier
|
379
|
+
limiter_response[:error][:data][:upgrade_available] = can_upgrade?(client_tier)
|
380
|
+
return limiter_response
|
381
|
+
end
|
382
|
+
|
383
|
+
@app.call(request)
|
384
|
+
end
|
385
|
+
|
386
|
+
private
|
387
|
+
|
388
|
+
def default_tiers
|
389
|
+
{
|
390
|
+
"free" => {
|
391
|
+
capacity: 10,
|
392
|
+
refill_rate: 0.1 # 0.1 tokens/second = 6/minute
|
393
|
+
},
|
394
|
+
"basic" => {
|
395
|
+
capacity: 50,
|
396
|
+
refill_rate: 1 # 1 token/second = 60/minute
|
397
|
+
},
|
398
|
+
"premium" => {
|
399
|
+
capacity: 200,
|
400
|
+
refill_rate: 5 # 5 tokens/second = 300/minute
|
401
|
+
},
|
402
|
+
"enterprise" => {
|
403
|
+
capacity: 1000,
|
404
|
+
refill_rate: 20 # 20 tokens/second = 1200/minute
|
405
|
+
}
|
406
|
+
}
|
407
|
+
end
|
408
|
+
|
409
|
+
def determine_tier(request)
|
410
|
+
# Check authenticated user's subscription
|
411
|
+
user = request["authenticated_user"]
|
412
|
+
return @default_tier unless user
|
413
|
+
|
414
|
+
user["subscription_tier"] || user["tier"] || @default_tier
|
415
|
+
end
|
416
|
+
|
417
|
+
def can_upgrade?(tier)
|
418
|
+
tier_order = ["free", "basic", "premium", "enterprise"]
|
419
|
+
current_index = tier_order.index(tier) || 0
|
420
|
+
current_index < tier_order.length - 1
|
421
|
+
end
|
422
|
+
end
|
423
|
+
```
|
424
|
+
|
425
|
+
## Adaptive Rate Limiting
|
426
|
+
|
427
|
+
### Dynamic Limits Based on Load
|
428
|
+
|
429
|
+
```ruby
|
430
|
+
class AdaptiveRateLimiter < Tsikol::Middleware
|
431
|
+
def initialize(app, options = {})
|
432
|
+
@app = app
|
433
|
+
@base_limit = options[:base_limit] || 100
|
434
|
+
@min_limit = options[:min_limit] || 10
|
435
|
+
@max_limit = options[:max_limit] || 1000
|
436
|
+
@load_threshold = options[:load_threshold] || 0.8
|
437
|
+
@limiters = {}
|
438
|
+
@mutex = Mutex.new
|
439
|
+
|
440
|
+
# Start monitoring thread
|
441
|
+
start_monitoring
|
442
|
+
end
|
443
|
+
|
444
|
+
def call(request)
|
445
|
+
client_id = identify_client(request)
|
446
|
+
current_limit = calculate_current_limit
|
447
|
+
|
448
|
+
if exceeds_limit?(client_id, current_limit)
|
449
|
+
return adaptive_rate_limit_error(request["id"], current_limit)
|
450
|
+
end
|
451
|
+
|
452
|
+
track_request(client_id)
|
453
|
+
@app.call(request)
|
454
|
+
end
|
455
|
+
|
456
|
+
private
|
457
|
+
|
458
|
+
def calculate_current_limit
|
459
|
+
# Adjust limit based on system load
|
460
|
+
load_average = get_system_load
|
461
|
+
|
462
|
+
if load_average > @load_threshold
|
463
|
+
# Reduce limits under high load
|
464
|
+
reduction_factor = (load_average - @load_threshold) / (1 - @load_threshold)
|
465
|
+
reduced_limit = @base_limit * (1 - reduction_factor * 0.5)
|
466
|
+
[@min_limit, reduced_limit].max.to_i
|
467
|
+
else
|
468
|
+
# Increase limits under low load
|
469
|
+
increase_factor = 1 + (1 - load_average) * 0.5
|
470
|
+
increased_limit = @base_limit * increase_factor
|
471
|
+
[@max_limit, increased_limit].min.to_i
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
def get_system_load
|
476
|
+
# Get 1-minute load average normalized by CPU count
|
477
|
+
if File.exist?("/proc/loadavg")
|
478
|
+
load_avg = File.read("/proc/loadavg").split.first.to_f
|
479
|
+
cpu_count = `nproc`.to_i rescue 1
|
480
|
+
load_avg / cpu_count
|
481
|
+
else
|
482
|
+
# Fallback for non-Linux systems
|
483
|
+
0.5
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
def start_monitoring
|
488
|
+
Thread.new do
|
489
|
+
loop do
|
490
|
+
sleep 10
|
491
|
+
cleanup_old_requests
|
492
|
+
log_metrics if @app.respond_to?(:log)
|
493
|
+
end
|
494
|
+
end
|
495
|
+
end
|
496
|
+
|
497
|
+
def cleanup_old_requests
|
498
|
+
@mutex.synchronize do
|
499
|
+
cutoff = Time.now - 300 # 5 minutes
|
500
|
+
@limiters.each do |client_id, requests|
|
501
|
+
requests.reject! { |time| time < cutoff }
|
502
|
+
@limiters.delete(client_id) if requests.empty?
|
503
|
+
end
|
504
|
+
end
|
505
|
+
end
|
506
|
+
end
|
507
|
+
```
|
508
|
+
|
509
|
+
## Method-Specific Rate Limiting
|
510
|
+
|
511
|
+
### Different Limits for Different Operations
|
512
|
+
|
513
|
+
```ruby
|
514
|
+
class MethodRateLimiter < Tsikol::Middleware
|
515
|
+
def initialize(app, options = {})
|
516
|
+
@app = app
|
517
|
+
@method_limits = options[:method_limits] || default_method_limits
|
518
|
+
@default_limit = options[:default_limit] || 60
|
519
|
+
@window_seconds = options[:window_seconds] || 60
|
520
|
+
@counters = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = [] } }
|
521
|
+
@mutex = Mutex.new
|
522
|
+
end
|
523
|
+
|
524
|
+
def call(request)
|
525
|
+
method = request["method"]
|
526
|
+
client_id = identify_client(request)
|
527
|
+
limit = @method_limits[method] || @default_limit
|
528
|
+
|
529
|
+
if exceeds_method_limit?(client_id, method, limit)
|
530
|
+
return method_rate_limit_error(request["id"], method, limit)
|
531
|
+
end
|
532
|
+
|
533
|
+
track_method_request(client_id, method)
|
534
|
+
@app.call(request)
|
535
|
+
end
|
536
|
+
|
537
|
+
private
|
538
|
+
|
539
|
+
def default_method_limits
|
540
|
+
{
|
541
|
+
# Expensive operations
|
542
|
+
"sampling/sample" => 10,
|
543
|
+
"tools/call" => 60,
|
544
|
+
|
545
|
+
# Moderate operations
|
546
|
+
"completion/complete" => 100,
|
547
|
+
"resources/read" => 100,
|
548
|
+
|
549
|
+
# Cheap operations
|
550
|
+
"tools/list" => 300,
|
551
|
+
"resources/list" => 300,
|
552
|
+
"prompts/list" => 300,
|
553
|
+
|
554
|
+
# Very cheap
|
555
|
+
"ping" => 600
|
556
|
+
}
|
557
|
+
end
|
558
|
+
|
559
|
+
def exceeds_method_limit?(client_id, method, limit)
|
560
|
+
@mutex.synchronize do
|
561
|
+
clean_old_requests(client_id, method)
|
562
|
+
@counters[client_id][method].size >= limit
|
563
|
+
end
|
564
|
+
end
|
565
|
+
|
566
|
+
def track_method_request(client_id, method)
|
567
|
+
@mutex.synchronize do
|
568
|
+
@counters[client_id][method] << Time.now
|
569
|
+
end
|
570
|
+
end
|
571
|
+
|
572
|
+
def clean_old_requests(client_id, method)
|
573
|
+
cutoff = Time.now - @window_seconds
|
574
|
+
@counters[client_id][method].reject! { |time| time < cutoff }
|
575
|
+
end
|
576
|
+
|
577
|
+
def method_rate_limit_error(id, method, limit)
|
578
|
+
{
|
579
|
+
jsonrpc: "2.0",
|
580
|
+
id: id,
|
581
|
+
error: {
|
582
|
+
code: -32005,
|
583
|
+
message: "Rate limit exceeded for #{method}",
|
584
|
+
data: {
|
585
|
+
method: method,
|
586
|
+
limit: limit,
|
587
|
+
window: @window_seconds,
|
588
|
+
retry_after: @window_seconds
|
589
|
+
}
|
590
|
+
}
|
591
|
+
}
|
592
|
+
end
|
593
|
+
end
|
594
|
+
```
|
595
|
+
|
596
|
+
## Testing Rate Limiting
|
597
|
+
|
598
|
+
```ruby
|
599
|
+
require 'minitest/autorun'
|
600
|
+
|
601
|
+
class RateLimitTest < Minitest::Test
|
602
|
+
def setup
|
603
|
+
@server = create_rate_limited_server
|
604
|
+
@client = Tsikol::TestHelpers::TestClient.new(@server)
|
605
|
+
end
|
606
|
+
|
607
|
+
def test_allows_requests_under_limit
|
608
|
+
3.times do |i|
|
609
|
+
response = @client.call_tool("echo", { "message" => "Test #{i}" })
|
610
|
+
assert_successful_response(response)
|
611
|
+
end
|
612
|
+
end
|
613
|
+
|
614
|
+
def test_blocks_requests_over_limit
|
615
|
+
# Make requests up to limit
|
616
|
+
5.times do
|
617
|
+
@client.call_tool("echo", { "message" => "Test" })
|
618
|
+
end
|
619
|
+
|
620
|
+
# Next request should be rate limited
|
621
|
+
response = @client.call_tool("echo", { "message" => "Over limit" })
|
622
|
+
assert_error_response(response, -32005)
|
623
|
+
assert_match /rate limit/i, response[:error][:message]
|
624
|
+
end
|
625
|
+
|
626
|
+
def test_resets_after_window
|
627
|
+
# Exhaust limit
|
628
|
+
5.times { @client.call_tool("echo", { "message" => "Test" }) }
|
629
|
+
|
630
|
+
# Wait for window to reset
|
631
|
+
sleep(2) # Assuming 1-second window in test
|
632
|
+
|
633
|
+
# Should allow new requests
|
634
|
+
response = @client.call_tool("echo", { "message" => "After reset" })
|
635
|
+
assert_successful_response(response)
|
636
|
+
end
|
637
|
+
|
638
|
+
def test_token_bucket_allows_bursts
|
639
|
+
server = Tsikol::Server.new(name: "test")
|
640
|
+
server.use TokenBucketRateLimiter, capacity: 10, refill_rate: 1
|
641
|
+
server.tool "echo" do |message:| message end
|
642
|
+
|
643
|
+
client = Tsikol::TestHelpers::TestClient.new(server)
|
644
|
+
|
645
|
+
# Burst of 10 requests should succeed
|
646
|
+
10.times do
|
647
|
+
response = client.call_tool("echo", { "message" => "Burst" })
|
648
|
+
assert_successful_response(response)
|
649
|
+
end
|
650
|
+
|
651
|
+
# 11th request should fail
|
652
|
+
response = client.call_tool("echo", { "message" => "Over capacity" })
|
653
|
+
assert_error_response(response, -32005)
|
654
|
+
end
|
655
|
+
|
656
|
+
private
|
657
|
+
|
658
|
+
def create_rate_limited_server
|
659
|
+
server = Tsikol::Server.new(name: "test")
|
660
|
+
server.use BasicRateLimiter, max_requests: 5, window_seconds: 1
|
661
|
+
server.tool "echo" do |message:| message end
|
662
|
+
server
|
663
|
+
end
|
664
|
+
end
|
665
|
+
```
|
666
|
+
|
667
|
+
## Best Practices
|
668
|
+
|
669
|
+
1. **Choose the right algorithm** for your use case
|
670
|
+
2. **Consider distributed systems** for multi-server deployments
|
671
|
+
3. **Provide clear error messages** with retry information
|
672
|
+
4. **Monitor rate limiting metrics** to tune limits
|
673
|
+
5. **Different limits for different operations** based on cost
|
674
|
+
6. **Graceful degradation** under high load
|
675
|
+
7. **Consider user tiers** for SaaS applications
|
676
|
+
8. **Test rate limiting** thoroughly including edge cases
|
677
|
+
|
678
|
+
## Integration Example
|
679
|
+
|
680
|
+
```ruby
|
681
|
+
# Complete server with rate limiting
|
682
|
+
Tsikol.start(name: "production-server") do
|
683
|
+
# Authentication first
|
684
|
+
use JWTAuthMiddleware, secret: ENV['JWT_SECRET']
|
685
|
+
|
686
|
+
# Then rate limiting based on authenticated user
|
687
|
+
use TieredRateLimiter, tiers: {
|
688
|
+
"free" => { capacity: 10, refill_rate: 0.1 },
|
689
|
+
"pro" => { capacity: 100, refill_rate: 2 },
|
690
|
+
"enterprise" => { capacity: 1000, refill_rate: 20 }
|
691
|
+
}
|
692
|
+
|
693
|
+
# Method-specific limits
|
694
|
+
use MethodRateLimiter, method_limits: {
|
695
|
+
"sampling/sample" => 5, # AI operations
|
696
|
+
"tools/call" => 60, # General tools
|
697
|
+
"resources/read" => 200 # Read operations
|
698
|
+
}
|
699
|
+
|
700
|
+
# Adaptive limiting under load
|
701
|
+
use AdaptiveRateLimiter,
|
702
|
+
base_limit: 100,
|
703
|
+
load_threshold: 0.7
|
704
|
+
|
705
|
+
# Your application
|
706
|
+
tool AiTool
|
707
|
+
resource DataResource
|
708
|
+
prompt Assistant
|
709
|
+
end
|
710
|
+
```
|
711
|
+
|
712
|
+
## Next Steps
|
713
|
+
|
714
|
+
- Implement [Authentication](authentication.md) to identify users
|
715
|
+
- Add [Logging](logging.md) for monitoring
|
716
|
+
- Set up [Caching](caching.md) to reduce load
|
717
|
+
- Review [Error Handling](error-handling.md) for rate limit errors
|