throttle_machines 0.0.0 → 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 +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +187 -13
- data/Rakefile +12 -0
- data/lib/throttle_machines/async_limiter.rb +134 -0
- data/lib/throttle_machines/clock.rb +41 -0
- data/lib/throttle_machines/control.rb +95 -0
- data/lib/throttle_machines/controller_helpers.rb +79 -0
- data/lib/throttle_machines/dependency_error.rb +6 -0
- data/lib/throttle_machines/engine.rb +25 -0
- data/lib/throttle_machines/hedged_request.rb +137 -0
- data/lib/throttle_machines/instrumentation.rb +162 -0
- data/lib/throttle_machines/limiter.rb +167 -0
- data/lib/throttle_machines/middleware.rb +90 -0
- data/lib/throttle_machines/rack_middleware/allow2_ban.rb +62 -0
- data/lib/throttle_machines/rack_middleware/blocklist.rb +27 -0
- data/lib/throttle_machines/rack_middleware/configuration.rb +103 -0
- data/lib/throttle_machines/rack_middleware/fail2_ban.rb +87 -0
- data/lib/throttle_machines/rack_middleware/request.rb +12 -0
- data/lib/throttle_machines/rack_middleware/safelist.rb +27 -0
- data/lib/throttle_machines/rack_middleware/throttle.rb +95 -0
- data/lib/throttle_machines/rack_middleware/track.rb +51 -0
- data/lib/throttle_machines/rack_middleware.rb +87 -0
- data/lib/throttle_machines/storage/base.rb +93 -0
- data/lib/throttle_machines/storage/memory.rb +374 -0
- data/lib/throttle_machines/storage/null.rb +90 -0
- data/lib/throttle_machines/storage/redis.rb +451 -0
- data/lib/throttle_machines/throttled_error.rb +14 -0
- data/lib/throttle_machines/version.rb +5 -0
- data/lib/throttle_machines.rb +134 -5
- metadata +105 -9
- data/LICENSE.txt +0 -21
@@ -0,0 +1,451 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
|
5
|
+
module ThrottleMachines
|
6
|
+
module Storage
|
7
|
+
class Redis < Base
|
8
|
+
GCRA_SCRIPT = <<~LUA
|
9
|
+
local key = KEYS[1]
|
10
|
+
local emission_interval = tonumber(ARGV[1])
|
11
|
+
local delay_tolerance = tonumber(ARGV[2])
|
12
|
+
local ttl = tonumber(ARGV[3])
|
13
|
+
local now = tonumber(ARGV[4])
|
14
|
+
|
15
|
+
local tat = redis.call('GET', key)
|
16
|
+
if not tat then
|
17
|
+
tat = 0
|
18
|
+
else
|
19
|
+
tat = tonumber(tat)
|
20
|
+
end
|
21
|
+
|
22
|
+
tat = math.max(tat, now)
|
23
|
+
local allow = (tat - now) <= delay_tolerance
|
24
|
+
|
25
|
+
if allow then
|
26
|
+
local new_tat = tat + emission_interval
|
27
|
+
redis.call('SET', key, new_tat, 'EX', ttl)
|
28
|
+
end
|
29
|
+
|
30
|
+
return { allow and 1 or 0, tat }
|
31
|
+
LUA
|
32
|
+
|
33
|
+
TOKEN_BUCKET_SCRIPT = <<~LUA
|
34
|
+
local key = KEYS[1]
|
35
|
+
local capacity = tonumber(ARGV[1])
|
36
|
+
local refill_rate = tonumber(ARGV[2])
|
37
|
+
local ttl = tonumber(ARGV[3])
|
38
|
+
local now = tonumber(ARGV[4])
|
39
|
+
|
40
|
+
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
|
41
|
+
local tokens = tonumber(bucket[1]) or capacity
|
42
|
+
local last_refill = tonumber(bucket[2]) or now
|
43
|
+
|
44
|
+
-- Refill tokens
|
45
|
+
local elapsed = now - last_refill
|
46
|
+
local tokens_to_add = elapsed * refill_rate
|
47
|
+
tokens = math.min(tokens + tokens_to_add, capacity)
|
48
|
+
|
49
|
+
local allow = tokens >= 1
|
50
|
+
if allow then
|
51
|
+
tokens = tokens - 1
|
52
|
+
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
53
|
+
redis.call('EXPIRE', key, ttl)
|
54
|
+
end
|
55
|
+
|
56
|
+
return { allow and 1 or 0, tokens }
|
57
|
+
LUA
|
58
|
+
|
59
|
+
PEEK_GCRA_SCRIPT = <<~LUA
|
60
|
+
local key = KEYS[1]
|
61
|
+
local emission_interval = tonumber(ARGV[1])
|
62
|
+
local delay_tolerance = tonumber(ARGV[2])
|
63
|
+
local now = tonumber(ARGV[3])
|
64
|
+
|
65
|
+
local tat = redis.call('GET', key)
|
66
|
+
if not tat then
|
67
|
+
tat = 0
|
68
|
+
else
|
69
|
+
tat = tonumber(tat)
|
70
|
+
end
|
71
|
+
|
72
|
+
tat = math.max(tat, now)
|
73
|
+
local allow = (tat - now) <= delay_tolerance
|
74
|
+
|
75
|
+
return { allow and 1 or 0, tat }
|
76
|
+
LUA
|
77
|
+
|
78
|
+
PEEK_TOKEN_BUCKET_SCRIPT = <<~LUA
|
79
|
+
local key = KEYS[1]
|
80
|
+
local capacity = tonumber(ARGV[1])
|
81
|
+
local refill_rate = tonumber(ARGV[2])
|
82
|
+
local now = tonumber(ARGV[3])
|
83
|
+
|
84
|
+
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
|
85
|
+
local tokens = tonumber(bucket[1]) or capacity
|
86
|
+
local last_refill = tonumber(bucket[2]) or now
|
87
|
+
|
88
|
+
-- Calculate tokens without modifying
|
89
|
+
local elapsed = now - last_refill
|
90
|
+
local tokens_to_add = elapsed * refill_rate
|
91
|
+
tokens = math.min(tokens + tokens_to_add, capacity)
|
92
|
+
|
93
|
+
local allow = tokens >= 1
|
94
|
+
local tokens_after = allow and (tokens - 1) or 0
|
95
|
+
|
96
|
+
return { allow and 1 or 0, tokens_after }
|
97
|
+
LUA
|
98
|
+
|
99
|
+
def initialize(options = {})
|
100
|
+
super
|
101
|
+
@redis = options[:redis] || options[:client] || options[:pool]
|
102
|
+
@prefix = options[:prefix] || 'throttle:'
|
103
|
+
|
104
|
+
# Cache scripts to avoid repeated script loads
|
105
|
+
@gcra_sha = nil
|
106
|
+
@token_bucket_sha = nil
|
107
|
+
@peek_gcra_sha = nil
|
108
|
+
@peek_token_bucket_sha = nil
|
109
|
+
|
110
|
+
# Validate Redis connection
|
111
|
+
validate_redis_connection!
|
112
|
+
end
|
113
|
+
|
114
|
+
# Rate limiting operations
|
115
|
+
def increment_counter(key, window, amount = 1)
|
116
|
+
window_key = prefixed("#{key}:#{window}")
|
117
|
+
|
118
|
+
# Use Lua script for atomic increment with TTL
|
119
|
+
with_redis do |redis|
|
120
|
+
redis.eval(<<~LUA, keys: [window_key], argv: [amount, window.to_i])
|
121
|
+
local count = redis.call('INCRBY', KEYS[1], ARGV[1])
|
122
|
+
local ttl = redis.call('TTL', KEYS[1])
|
123
|
+
|
124
|
+
-- Set expiry if key is new (ttl == -2) or has no TTL (ttl == -1)
|
125
|
+
if ttl <= 0 then
|
126
|
+
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
127
|
+
end
|
128
|
+
|
129
|
+
return count
|
130
|
+
LUA
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def get_counter(key, window)
|
135
|
+
window_key = prefixed("#{key}:#{window}")
|
136
|
+
with_redis { |r| (r.get(window_key) || 0).to_i }
|
137
|
+
end
|
138
|
+
|
139
|
+
def get_counter_ttl(key, window)
|
140
|
+
window_key = prefixed("#{key}:#{window}")
|
141
|
+
ttl = with_redis { |r| r.ttl(window_key) }
|
142
|
+
[ttl, 0].max
|
143
|
+
end
|
144
|
+
|
145
|
+
def reset_counter(key, window)
|
146
|
+
window_key = prefixed("#{key}:#{window}")
|
147
|
+
with_redis { |r| r.del(window_key) }
|
148
|
+
end
|
149
|
+
|
150
|
+
# GCRA operations (atomic via Lua)
|
151
|
+
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
152
|
+
ensure_gcra_script_loaded!
|
153
|
+
|
154
|
+
result = with_redis do |redis|
|
155
|
+
redis.evalsha(
|
156
|
+
@gcra_sha,
|
157
|
+
keys: [prefixed(key)],
|
158
|
+
argv: [emission_interval, delay_tolerance, ttl, current_time]
|
159
|
+
)
|
160
|
+
end
|
161
|
+
|
162
|
+
allowed = result[0] == 1
|
163
|
+
tat = result[1]
|
164
|
+
now = current_time
|
165
|
+
|
166
|
+
{
|
167
|
+
allowed: allowed,
|
168
|
+
retry_after: allowed ? 0 : (tat - now - delay_tolerance),
|
169
|
+
tat: tat
|
170
|
+
}
|
171
|
+
rescue ::Redis::CommandError => e
|
172
|
+
raise unless e.message.include?('NOSCRIPT')
|
173
|
+
|
174
|
+
@gcra_sha = nil
|
175
|
+
retry
|
176
|
+
end
|
177
|
+
|
178
|
+
# Token bucket operations (atomic via Lua)
|
179
|
+
def check_token_bucket(key, capacity, refill_rate, ttl)
|
180
|
+
ensure_token_bucket_script_loaded!
|
181
|
+
|
182
|
+
result = with_redis do |redis|
|
183
|
+
redis.evalsha(
|
184
|
+
@token_bucket_sha,
|
185
|
+
keys: [prefixed(key)],
|
186
|
+
argv: [capacity, refill_rate, ttl, current_time]
|
187
|
+
)
|
188
|
+
end
|
189
|
+
|
190
|
+
allowed = result[0] == 1
|
191
|
+
tokens = result[1]
|
192
|
+
|
193
|
+
{
|
194
|
+
allowed: allowed,
|
195
|
+
retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
|
196
|
+
tokens_remaining: tokens.floor
|
197
|
+
}
|
198
|
+
rescue ::Redis::CommandError => e
|
199
|
+
raise unless e.message.include?('NOSCRIPT')
|
200
|
+
|
201
|
+
@token_bucket_sha = nil
|
202
|
+
retry
|
203
|
+
end
|
204
|
+
|
205
|
+
# Peek methods for non-consuming checks
|
206
|
+
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
207
|
+
ensure_peek_gcra_script_loaded!
|
208
|
+
|
209
|
+
result = with_redis do |redis|
|
210
|
+
redis.evalsha(
|
211
|
+
@peek_gcra_sha,
|
212
|
+
keys: [prefixed(key)],
|
213
|
+
argv: [emission_interval, delay_tolerance, current_time]
|
214
|
+
)
|
215
|
+
end
|
216
|
+
|
217
|
+
allowed = result[0] == 1
|
218
|
+
tat = result[1]
|
219
|
+
now = current_time
|
220
|
+
|
221
|
+
{
|
222
|
+
allowed: allowed,
|
223
|
+
retry_after: allowed ? 0 : (tat - now - delay_tolerance),
|
224
|
+
tat: tat
|
225
|
+
}
|
226
|
+
rescue ::Redis::CommandError => e
|
227
|
+
raise unless e.message.include?('NOSCRIPT')
|
228
|
+
|
229
|
+
@peek_gcra_sha = nil
|
230
|
+
retry
|
231
|
+
end
|
232
|
+
|
233
|
+
def peek_token_bucket(key, capacity, refill_rate)
|
234
|
+
ensure_peek_token_bucket_script_loaded!
|
235
|
+
|
236
|
+
result = with_redis do |redis|
|
237
|
+
redis.evalsha(
|
238
|
+
@peek_token_bucket_sha,
|
239
|
+
keys: [prefixed(key)],
|
240
|
+
argv: [capacity, refill_rate, current_time]
|
241
|
+
)
|
242
|
+
end
|
243
|
+
|
244
|
+
allowed = result[0] == 1
|
245
|
+
tokens_remaining = result[1]
|
246
|
+
|
247
|
+
{
|
248
|
+
allowed: allowed,
|
249
|
+
retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
|
250
|
+
tokens_remaining: tokens_remaining.floor
|
251
|
+
}
|
252
|
+
rescue ::Redis::CommandError => e
|
253
|
+
raise unless e.message.include?('NOSCRIPT')
|
254
|
+
|
255
|
+
@peek_token_bucket_sha = nil
|
256
|
+
retry
|
257
|
+
end
|
258
|
+
|
259
|
+
# Circuit breaker operations
|
260
|
+
def get_breaker_state(key)
|
261
|
+
breaker_key = prefixed("breaker:#{key}")
|
262
|
+
|
263
|
+
# Use Lua script for atomic read and potential state transition
|
264
|
+
result = with_redis do |redis|
|
265
|
+
redis.eval(<<~LUA, keys: [breaker_key], argv: [current_time])
|
266
|
+
local data = redis.call('HGETALL', KEYS[1])
|
267
|
+
if #data == 0 then
|
268
|
+
return {}
|
269
|
+
end
|
270
|
+
|
271
|
+
local state = {}
|
272
|
+
for i = 1, #data, 2 do
|
273
|
+
state[data[i]] = data[i + 1]
|
274
|
+
end
|
275
|
+
|
276
|
+
-- Auto-transition from open to half-open if timeout passed
|
277
|
+
if state['state'] == 'open' and state['opens_at'] then
|
278
|
+
local now = tonumber(ARGV[1])
|
279
|
+
local opens_at = tonumber(state['opens_at'])
|
280
|
+
#{' '}
|
281
|
+
if now >= opens_at then
|
282
|
+
redis.call('HSET', KEYS[1], 'state', 'half_open', 'half_open_attempts', '0')
|
283
|
+
state['state'] = 'half_open'
|
284
|
+
state['half_open_attempts'] = '0'
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
return state
|
289
|
+
LUA
|
290
|
+
end
|
291
|
+
|
292
|
+
return { state: :closed, failures: 0, last_failure: nil } if result.empty?
|
293
|
+
|
294
|
+
# Convert hash from Lua to Ruby format
|
295
|
+
state = {}
|
296
|
+
result.each_slice(2) { |k, v| state[k] = v }
|
297
|
+
|
298
|
+
{
|
299
|
+
state: state['state'].to_sym,
|
300
|
+
failures: state['failures'].to_i,
|
301
|
+
last_failure: state['last_failure']&.to_f,
|
302
|
+
opens_at: state['opens_at']&.to_f,
|
303
|
+
half_open_attempts: state['half_open_attempts']&.to_i
|
304
|
+
}
|
305
|
+
end
|
306
|
+
|
307
|
+
def record_breaker_success(key, _timeout, half_open_requests = 1)
|
308
|
+
breaker_key = prefixed("breaker:#{key}")
|
309
|
+
|
310
|
+
# Use Lua script for atomic success recording
|
311
|
+
with_redis do |redis|
|
312
|
+
redis.eval(<<~LUA, keys: [breaker_key], argv: [half_open_requests])
|
313
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
314
|
+
|
315
|
+
if state == 'half_open' then
|
316
|
+
-- Increment half-open attempts and potentially close the circuit
|
317
|
+
local attempts = redis.call('HINCRBY', KEYS[1], 'half_open_attempts', 1)
|
318
|
+
#{' '}
|
319
|
+
if attempts >= tonumber(ARGV[1]) then
|
320
|
+
redis.call('DEL', KEYS[1])
|
321
|
+
end
|
322
|
+
elseif state == 'closed' then
|
323
|
+
-- Reset failure count on success in closed state
|
324
|
+
local failures = redis.call('HGET', KEYS[1], 'failures')
|
325
|
+
if failures and tonumber(failures) > 0 then
|
326
|
+
redis.call('HSET', KEYS[1], 'failures', 0)
|
327
|
+
end
|
328
|
+
end
|
329
|
+
LUA
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
def record_breaker_failure(key, threshold, timeout)
|
334
|
+
breaker_key = prefixed("breaker:#{key}")
|
335
|
+
now = current_time
|
336
|
+
|
337
|
+
# Use Lua script for atomic failure recording
|
338
|
+
with_redis do |redis|
|
339
|
+
redis.eval(<<~LUA, keys: [breaker_key], argv: [threshold, timeout, now])
|
340
|
+
local state = redis.call('HGET', KEYS[1], 'state') or 'closed'
|
341
|
+
local now = ARGV[3]
|
342
|
+
local timeout = tonumber(ARGV[2])
|
343
|
+
|
344
|
+
if state == 'half_open' then
|
345
|
+
-- Failure in half-open state, just re-open the circuit
|
346
|
+
redis.call('HMSET', KEYS[1],
|
347
|
+
'state', 'open',
|
348
|
+
'opens_at', tonumber(now) + timeout,
|
349
|
+
'last_failure', now
|
350
|
+
)
|
351
|
+
else -- state is 'closed' or nil
|
352
|
+
local failures = redis.call('HINCRBY', KEYS[1], 'failures', 1)
|
353
|
+
redis.call('HSET', KEYS[1], 'last_failure', now)
|
354
|
+
#{' '}
|
355
|
+
if failures >= tonumber(ARGV[1]) then
|
356
|
+
redis.call('HMSET', KEYS[1],
|
357
|
+
'state', 'open',
|
358
|
+
'opens_at', tonumber(now) + timeout
|
359
|
+
)
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
redis.call('EXPIRE', KEYS[1], timeout * 2)
|
364
|
+
LUA
|
365
|
+
end
|
366
|
+
|
367
|
+
get_breaker_state(key)
|
368
|
+
end
|
369
|
+
|
370
|
+
def trip_breaker(key, timeout)
|
371
|
+
breaker_key = prefixed("breaker:#{key}")
|
372
|
+
now = current_time
|
373
|
+
|
374
|
+
with_redis do |redis|
|
375
|
+
redis.hmset(breaker_key,
|
376
|
+
'state', 'open',
|
377
|
+
'failures', 0,
|
378
|
+
'last_failure', now,
|
379
|
+
'opens_at', now + timeout)
|
380
|
+
redis.expire(breaker_key, (timeout * 2).to_i)
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
def reset_breaker(key)
|
385
|
+
with_redis { |r| r.del(prefixed("breaker:#{key}")) }
|
386
|
+
end
|
387
|
+
|
388
|
+
# Utility operations
|
389
|
+
def clear(pattern = nil)
|
390
|
+
# Use SCAN instead of KEYS to avoid blocking in production
|
391
|
+
cursor = '0'
|
392
|
+
scan_pattern = pattern ? prefixed(pattern) : "#{@prefix}*"
|
393
|
+
|
394
|
+
with_redis do |redis|
|
395
|
+
loop do
|
396
|
+
cursor, keys = redis.scan(cursor, match: scan_pattern, count: 100)
|
397
|
+
redis.del(*keys) unless keys.empty?
|
398
|
+
break if cursor == '0'
|
399
|
+
end
|
400
|
+
end
|
401
|
+
end
|
402
|
+
|
403
|
+
def healthy?
|
404
|
+
with_redis { |r| r.ping == 'PONG' }
|
405
|
+
rescue StandardError
|
406
|
+
false
|
407
|
+
end
|
408
|
+
|
409
|
+
private
|
410
|
+
|
411
|
+
def prefixed(key)
|
412
|
+
"#{@prefix}#{key}"
|
413
|
+
end
|
414
|
+
|
415
|
+
def ensure_gcra_script_loaded!
|
416
|
+
@ensure_gcra_script_loaded ||= with_redis { |r| r.script(:load, GCRA_SCRIPT) }
|
417
|
+
end
|
418
|
+
|
419
|
+
def ensure_token_bucket_script_loaded!
|
420
|
+
@ensure_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, TOKEN_BUCKET_SCRIPT) }
|
421
|
+
end
|
422
|
+
|
423
|
+
def ensure_peek_gcra_script_loaded!
|
424
|
+
@ensure_peek_gcra_script_loaded ||= with_redis { |r| r.script(:load, PEEK_GCRA_SCRIPT) }
|
425
|
+
end
|
426
|
+
|
427
|
+
def ensure_peek_token_bucket_script_loaded!
|
428
|
+
@ensure_peek_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, PEEK_TOKEN_BUCKET_SCRIPT) }
|
429
|
+
end
|
430
|
+
|
431
|
+
def validate_redis_connection!
|
432
|
+
raise ArgumentError, 'Redis client not provided' unless @redis
|
433
|
+
|
434
|
+
# Test connection
|
435
|
+
with_redis(&:ping)
|
436
|
+
rescue StandardError => e
|
437
|
+
raise ArgumentError, "Invalid Redis connection: #{e.message}"
|
438
|
+
end
|
439
|
+
|
440
|
+
def with_redis(&)
|
441
|
+
if @redis.respond_to?(:with)
|
442
|
+
# Connection pool
|
443
|
+
@redis.with(&)
|
444
|
+
else
|
445
|
+
# Regular Redis client
|
446
|
+
yield @redis
|
447
|
+
end
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|
451
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThrottleMachines
|
4
|
+
class ThrottledError < StandardError
|
5
|
+
attr_reader :limiter
|
6
|
+
|
7
|
+
def initialize(limiter)
|
8
|
+
@limiter = limiter
|
9
|
+
super("Rate limit exceeded for #{limiter.key}")
|
10
|
+
end
|
11
|
+
|
12
|
+
delegate :retry_after, to: :@limiter
|
13
|
+
end
|
14
|
+
end
|
data/lib/throttle_machines.rb
CHANGED
@@ -1,9 +1,138 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'json'
|
4
|
+
require 'timeout'
|
5
|
+
require 'zeitwerk'
|
6
|
+
require 'active_support/configurable'
|
7
|
+
|
8
|
+
# Ecosystem dependencies
|
9
|
+
require 'chrono_machines'
|
10
|
+
require 'breaker_machines'
|
11
|
+
|
12
|
+
# Set up Zeitwerk loader
|
13
|
+
loader = Zeitwerk::Loader.for_gem
|
14
|
+
loader.ignore("#{__dir__}/throttle_machines/engine.rb") unless defined?(Rails::Engine)
|
15
|
+
loader.setup
|
16
|
+
|
17
|
+
# Load conditional dependencies manually
|
18
|
+
require_relative 'throttle_machines/storage/redis' if defined?(Redis)
|
19
|
+
|
3
20
|
module ThrottleMachines
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
21
|
+
include ActiveSupport::Configurable
|
22
|
+
|
23
|
+
# Define configuration options with defaults
|
24
|
+
config_accessor :default_limit, default: 100
|
25
|
+
config_accessor :default_period, default: 60 # 1 minute
|
26
|
+
config_accessor :default_storage, default: :memory
|
27
|
+
config_accessor :clock, default: nil
|
28
|
+
config_accessor :instrumentation_enabled, default: true
|
29
|
+
config_accessor :instrumentation_backend
|
30
|
+
config_accessor :_storage_instance
|
31
|
+
|
32
|
+
class << self
|
33
|
+
# Delegate monotonic time to BreakerMachines for consistency
|
34
|
+
delegate :monotonic_time, to: :BreakerMachines
|
35
|
+
|
36
|
+
def configure
|
37
|
+
yield(config) if block_given?
|
38
|
+
|
39
|
+
# Apply instrumentation settings
|
40
|
+
Instrumentation.enabled = config.instrumentation_enabled
|
41
|
+
Instrumentation.backend = config.instrumentation_backend if config.instrumentation_backend
|
42
|
+
end
|
43
|
+
|
44
|
+
def storage
|
45
|
+
config._storage_instance ||= create_storage(config.default_storage)
|
46
|
+
end
|
47
|
+
|
48
|
+
def storage=(value)
|
49
|
+
config._storage_instance = create_storage(value)
|
50
|
+
end
|
51
|
+
|
52
|
+
def reset!(key = nil)
|
53
|
+
if key
|
54
|
+
storage.clear("#{key}*")
|
55
|
+
else
|
56
|
+
storage.clear
|
57
|
+
# Reset storage instance to force recreation with defaults
|
58
|
+
config._storage_instance = nil
|
59
|
+
# Re-apply instrumentation settings from the configuration
|
60
|
+
Instrumentation.enabled = config.instrumentation_enabled
|
61
|
+
Instrumentation.backend = config.instrumentation_backend
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def control(key, &block)
|
66
|
+
control = Control.new(key)
|
67
|
+
control.instance_eval(&block) if block
|
68
|
+
control
|
69
|
+
end
|
70
|
+
|
71
|
+
def limit(key, limit:, period:, algorithm: :fixed_window, &)
|
72
|
+
limiter = limiter(key, limit: limit, period: period, algorithm: algorithm)
|
73
|
+
limiter.throttle!(&)
|
74
|
+
end
|
75
|
+
|
76
|
+
def break_circuit(key, failures:, timeout:, &)
|
77
|
+
# Delegate to breaker_machines
|
78
|
+
breaker = BreakerMachines::Circuit.new(
|
79
|
+
key: key,
|
80
|
+
failure_threshold: failures,
|
81
|
+
timeout: timeout
|
82
|
+
)
|
83
|
+
breaker.call(&)
|
84
|
+
end
|
85
|
+
|
86
|
+
def retry_with(max_attempts: 3, backoff: :exponential, &)
|
87
|
+
# Delegate to chrono_machines
|
88
|
+
policy_options = {
|
89
|
+
max_attempts: max_attempts,
|
90
|
+
jitter_factor: backoff == :exponential ? 1.0 : 0.0
|
91
|
+
}
|
92
|
+
ChronoMachines.retry(policy_options, &)
|
93
|
+
end
|
94
|
+
|
95
|
+
def limiter(key, limit:, period:, algorithm: :fixed_window)
|
96
|
+
Limiter.new(key, limit: limit, period: period, algorithm: algorithm, storage: storage)
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def create_storage(storage)
|
102
|
+
case storage
|
103
|
+
when Symbol
|
104
|
+
create_storage_from_symbol(storage)
|
105
|
+
when Class
|
106
|
+
storage.new
|
107
|
+
when Storage::Base
|
108
|
+
storage
|
109
|
+
else
|
110
|
+
raise ArgumentError, "Invalid storage: #{storage.inspect}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def create_storage_from_symbol(symbol)
|
115
|
+
case symbol
|
116
|
+
when :memory
|
117
|
+
Storage::Memory.new
|
118
|
+
when :redis
|
119
|
+
raise ArgumentError, 'Redis storage requires redis gem' unless defined?(Redis)
|
120
|
+
|
121
|
+
raise ArgumentError, 'Redis storage requires a Redis client instance. ' \
|
122
|
+
'Configure with: config.storage = Storage::Redis.new(redis: Redis.new)'
|
123
|
+
|
124
|
+
when :null
|
125
|
+
Storage::Null.new
|
126
|
+
else
|
127
|
+
raise ArgumentError, "Unknown storage type: #{symbol}"
|
128
|
+
end
|
129
|
+
end
|
8
130
|
end
|
9
|
-
|
131
|
+
|
132
|
+
# Auto-configure with defaults
|
133
|
+
configure
|
134
|
+
|
135
|
+
# Backward compatibility aliases for error classes (defined after module setup)
|
136
|
+
CircuitOpenError = BreakerMachines::CircuitOpenError if defined?(BreakerMachines::CircuitOpenError)
|
137
|
+
RetryExhaustedError = ChronoMachines::MaxRetriesExceededError if defined?(ChronoMachines::MaxRetriesExceededError)
|
138
|
+
end
|