throttle_machines 0.2.4-aarch64-linux
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/LICENSE +20 -0
- data/README.md +204 -0
- data/lib/throttle_machines/async_breaker.rb +12 -0
- data/lib/throttle_machines/async_limiter.rb +24 -0
- data/lib/throttle_machines/async_support.rb +75 -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 +18 -0
- data/lib/throttle_machines/hedged_breaker.rb +23 -0
- data/lib/throttle_machines/hedged_request.rb +117 -0
- data/lib/throttle_machines/instrumentation.rb +158 -0
- data/lib/throttle_machines/limiter.rb +167 -0
- data/lib/throttle_machines/middleware.rb +89 -0
- data/lib/throttle_machines/native_speedup.rb +98 -0
- data/lib/throttle_machines/rack_middleware/allow2_ban.rb +51 -0
- data/lib/throttle_machines/rack_middleware/ban_filter.rb +29 -0
- data/lib/throttle_machines/rack_middleware/blocklist.rb +14 -0
- data/lib/throttle_machines/rack_middleware/configuration.rb +103 -0
- data/lib/throttle_machines/rack_middleware/fail2_ban.rb +78 -0
- data/lib/throttle_machines/rack_middleware/list_filter.rb +39 -0
- data/lib/throttle_machines/rack_middleware/request.rb +12 -0
- data/lib/throttle_machines/rack_middleware/safelist.rb +14 -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 +72 -0
- data/lib/throttle_machines/storage/memory.rb +267 -0
- data/lib/throttle_machines/storage/null.rb +67 -0
- data/lib/throttle_machines/storage/redis/gcra.lua +22 -0
- data/lib/throttle_machines/storage/redis/increment_counter.lua +9 -0
- data/lib/throttle_machines/storage/redis/peek_gcra.lua +16 -0
- data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +18 -0
- data/lib/throttle_machines/storage/redis/token_bucket.lua +23 -0
- data/lib/throttle_machines/storage/redis.rb +231 -0
- data/lib/throttle_machines/throttled_error.rb +14 -0
- data/lib/throttle_machines/version.rb +5 -0
- data/lib/throttle_machines.rb +200 -0
- data/lib/throttle_machines_native/throttle_machines_native.so +0 -0
- metadata +218 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module ThrottleMachines
|
|
6
|
+
module Storage
|
|
7
|
+
class Memory < Base
|
|
8
|
+
def initialize(options = {})
|
|
9
|
+
super
|
|
10
|
+
@counters = Concurrent::Hash.new
|
|
11
|
+
@gcra_states = Concurrent::Hash.new
|
|
12
|
+
@token_buckets = Concurrent::Hash.new
|
|
13
|
+
|
|
14
|
+
# Use a striped lock pattern - pool of locks for fine-grained concurrency
|
|
15
|
+
@lock_pool_size = options[:lock_pool_size] || 32
|
|
16
|
+
@locks = Array.new(@lock_pool_size) { Concurrent::ReadWriteLock.new }
|
|
17
|
+
|
|
18
|
+
# Background cleanup thread
|
|
19
|
+
@cleanup_interval = options[:cleanup_interval] || 60
|
|
20
|
+
@shutdown = false
|
|
21
|
+
@cleanup_thread = start_cleanup_thread if options[:auto_cleanup] != false
|
|
22
|
+
|
|
23
|
+
# Ensure cleanup on garbage collection
|
|
24
|
+
ObjectSpace.define_finalizer(self, self.class.finalizer(@cleanup_thread))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.finalizer(cleanup_thread)
|
|
28
|
+
proc { cleanup_thread&.kill }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Rate limiting operations
|
|
32
|
+
def increment_counter(key, window, amount = 1)
|
|
33
|
+
window_key = "#{key}:#{window}"
|
|
34
|
+
|
|
35
|
+
with_write_lock(window_key) do
|
|
36
|
+
now = current_time
|
|
37
|
+
# Fetch fresh value inside the lock to ensure consistency
|
|
38
|
+
counter = @counters[window_key]
|
|
39
|
+
|
|
40
|
+
if counter.nil? || counter[:expires_at] <= now
|
|
41
|
+
# Create or reset counter atomically
|
|
42
|
+
new_count = amount
|
|
43
|
+
@counters[window_key] = { count: new_count, expires_at: now + window }
|
|
44
|
+
else
|
|
45
|
+
# Increment existing counter atomically
|
|
46
|
+
new_count = counter[:count] + amount
|
|
47
|
+
@counters[window_key] = { count: new_count, expires_at: counter[:expires_at] }
|
|
48
|
+
end
|
|
49
|
+
new_count
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_counter(key, window)
|
|
54
|
+
window_key = "#{key}:#{window}"
|
|
55
|
+
|
|
56
|
+
with_read_lock(window_key) do
|
|
57
|
+
counter = @counters[window_key]
|
|
58
|
+
return 0 unless counter
|
|
59
|
+
return 0 if counter[:expires_at] <= current_time
|
|
60
|
+
|
|
61
|
+
counter[:count]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_counter_ttl(key, window)
|
|
66
|
+
window_key = "#{key}:#{window}"
|
|
67
|
+
|
|
68
|
+
with_read_lock(window_key) do
|
|
69
|
+
counter = @counters[window_key]
|
|
70
|
+
return 0 unless counter
|
|
71
|
+
|
|
72
|
+
ttl = counter[:expires_at] - current_time
|
|
73
|
+
[ttl, 0].max
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reset_counter(key, window)
|
|
78
|
+
window_key = "#{key}:#{window}"
|
|
79
|
+
with_write_lock(window_key) { @counters.delete(window_key) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# GCRA operations (atomic simulation)
|
|
83
|
+
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
84
|
+
with_write_lock(key) do
|
|
85
|
+
now = current_time
|
|
86
|
+
state = @gcra_states[key] || { tat: 0.0 }
|
|
87
|
+
|
|
88
|
+
tat = [state[:tat], now].max
|
|
89
|
+
allow = tat - now <= delay_tolerance
|
|
90
|
+
|
|
91
|
+
if allow
|
|
92
|
+
new_tat = tat + emission_interval
|
|
93
|
+
@gcra_states[key] = { tat: new_tat, expires_at: now + ttl }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
allowed: allow,
|
|
98
|
+
retry_after: allow ? 0 : (tat - now - delay_tolerance),
|
|
99
|
+
tat: tat
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def peek_gcra_limit(key, _emission_interval, delay_tolerance)
|
|
105
|
+
with_read_lock(key) do
|
|
106
|
+
now = current_time
|
|
107
|
+
state = @gcra_states[key] || { tat: 0.0 }
|
|
108
|
+
|
|
109
|
+
tat = [state[:tat], now].max
|
|
110
|
+
allow = tat - now <= delay_tolerance
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
allowed: allow,
|
|
114
|
+
retry_after: allow ? 0 : (tat - now - delay_tolerance),
|
|
115
|
+
tat: tat
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Token bucket operations (atomic simulation)
|
|
121
|
+
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
122
|
+
with_write_lock(key) do
|
|
123
|
+
now = current_time
|
|
124
|
+
bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
|
|
125
|
+
|
|
126
|
+
# Refill tokens
|
|
127
|
+
elapsed = now - bucket[:last_refill]
|
|
128
|
+
tokens_to_add = elapsed * refill_rate
|
|
129
|
+
bucket[:tokens] = [bucket[:tokens] + tokens_to_add, capacity].min
|
|
130
|
+
bucket[:last_refill] = now
|
|
131
|
+
|
|
132
|
+
# Check if we can consume a token
|
|
133
|
+
if bucket[:tokens] >= 1
|
|
134
|
+
bucket[:tokens] -= 1
|
|
135
|
+
@token_buckets[key] = bucket.merge(expires_at: now + ttl)
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
allowed: true,
|
|
139
|
+
retry_after: 0,
|
|
140
|
+
tokens_remaining: bucket[:tokens].floor
|
|
141
|
+
}
|
|
142
|
+
else
|
|
143
|
+
retry_after = (1 - bucket[:tokens]) / refill_rate
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
allowed: false,
|
|
147
|
+
retry_after: retry_after,
|
|
148
|
+
tokens_remaining: 0
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def peek_token_bucket(key, capacity, refill_rate)
|
|
155
|
+
with_read_lock(key) do
|
|
156
|
+
now = current_time
|
|
157
|
+
bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
|
|
158
|
+
|
|
159
|
+
# Calculate tokens without modifying state
|
|
160
|
+
elapsed = now - bucket[:last_refill]
|
|
161
|
+
tokens_to_add = elapsed * refill_rate
|
|
162
|
+
current_tokens = [bucket[:tokens] + tokens_to_add, capacity].min
|
|
163
|
+
|
|
164
|
+
if current_tokens >= 1
|
|
165
|
+
{
|
|
166
|
+
allowed: true,
|
|
167
|
+
retry_after: 0,
|
|
168
|
+
tokens_remaining: (current_tokens - 1).floor
|
|
169
|
+
}
|
|
170
|
+
else
|
|
171
|
+
retry_after = (1 - current_tokens) / refill_rate
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
allowed: false,
|
|
175
|
+
retry_after: retry_after,
|
|
176
|
+
tokens_remaining: 0
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# No circuit breaker operations here: breaker state is owned by BreakerMachines
|
|
183
|
+
|
|
184
|
+
# Utility operations
|
|
185
|
+
def clear(pattern = nil)
|
|
186
|
+
if pattern
|
|
187
|
+
regex = Regexp.new(pattern.gsub('*', '.*'))
|
|
188
|
+
|
|
189
|
+
# Clear matching keys from all stores
|
|
190
|
+
[@counters, @gcra_states, @token_buckets].each do |store|
|
|
191
|
+
store.each_key do |k|
|
|
192
|
+
store.delete(k) if k&.match?(regex)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
else
|
|
196
|
+
@counters.clear
|
|
197
|
+
@gcra_states.clear
|
|
198
|
+
@token_buckets.clear
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def healthy?
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def shutdown
|
|
207
|
+
@shutdown = true
|
|
208
|
+
@cleanup_thread&.join(1) # Wait up to 1 second for graceful shutdown
|
|
209
|
+
@cleanup_thread&.kill if @cleanup_thread&.alive?
|
|
210
|
+
@cleanup_thread = nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def with_read_lock(key, &block)
|
|
216
|
+
lock_for(key).with_read_lock(&block)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def with_write_lock(key, &block)
|
|
220
|
+
lock_for(key).with_write_lock(&block)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def lock_for(key)
|
|
224
|
+
# Hash key to determine which lock to use
|
|
225
|
+
index = key.hash.abs % @lock_pool_size
|
|
226
|
+
@locks[index]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def start_cleanup_thread
|
|
230
|
+
Thread.new do
|
|
231
|
+
loop do
|
|
232
|
+
break if @shutdown
|
|
233
|
+
|
|
234
|
+
sleep @cleanup_interval
|
|
235
|
+
break if @shutdown
|
|
236
|
+
|
|
237
|
+
clean_expired_entries
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def clean_expired_entries
|
|
243
|
+
now = current_time
|
|
244
|
+
|
|
245
|
+
# Clean expired counters
|
|
246
|
+
@counters.each_pair do |key, data|
|
|
247
|
+
with_write_lock(key) { @counters.delete(key) } if data[:expires_at] && data[:expires_at] <= now
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Clean expired GCRA states
|
|
251
|
+
@gcra_states.each_pair do |key, data|
|
|
252
|
+
with_write_lock(key) { @gcra_states.delete(key) } if data[:expires_at] && data[:expires_at] <= now
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Clean expired token buckets
|
|
256
|
+
@token_buckets.each_pair do |key, data|
|
|
257
|
+
with_write_lock(key) { @token_buckets.delete(key) } if data[:expires_at] && data[:expires_at] <= now
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# No breaker state cleanup: breaker state is managed by BreakerMachines
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
# Log error but don't crash cleanup thread
|
|
263
|
+
warn "ThrottleMachines: Cleanup error: #{e.message}"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThrottleMachines
|
|
4
|
+
module Storage
|
|
5
|
+
class Null < Base
|
|
6
|
+
# Rate limiting operations
|
|
7
|
+
def increment_counter(_key, _window, _amount = 1)
|
|
8
|
+
0
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def get_counter(_key, _window)
|
|
12
|
+
0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_counter_ttl(_key, _window)
|
|
16
|
+
0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset_counter(_key, _window)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# GCRA operations
|
|
24
|
+
def check_gcra_limit(_key, _emission_interval, _delay_tolerance, _ttl)
|
|
25
|
+
{
|
|
26
|
+
allowed: true,
|
|
27
|
+
retry_after: 0,
|
|
28
|
+
tat: 0
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def peek_gcra_limit(_key, _emission_interval, _delay_tolerance)
|
|
33
|
+
{
|
|
34
|
+
allowed: true,
|
|
35
|
+
retry_after: 0,
|
|
36
|
+
tat: 0
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Token bucket operations
|
|
41
|
+
def check_token_bucket(_key, capacity, _refill_rate, _ttl)
|
|
42
|
+
{
|
|
43
|
+
allowed: true,
|
|
44
|
+
retry_after: 0,
|
|
45
|
+
tokens_remaining: capacity
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def peek_token_bucket(_key, capacity, _refill_rate)
|
|
50
|
+
{
|
|
51
|
+
allowed: true,
|
|
52
|
+
retry_after: 0,
|
|
53
|
+
tokens_remaining: capacity
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Utility operations
|
|
58
|
+
def clear(_pattern = nil)
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def healthy?
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local emission_interval = tonumber(ARGV[1])
|
|
3
|
+
local delay_tolerance = tonumber(ARGV[2])
|
|
4
|
+
local ttl = tonumber(ARGV[3])
|
|
5
|
+
local now = tonumber(ARGV[4])
|
|
6
|
+
|
|
7
|
+
local tat = redis.call('GET', key)
|
|
8
|
+
if not tat then
|
|
9
|
+
tat = 0
|
|
10
|
+
else
|
|
11
|
+
tat = tonumber(tat)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
tat = math.max(tat, now)
|
|
15
|
+
local allow = (tat - now) <= delay_tolerance
|
|
16
|
+
|
|
17
|
+
if allow then
|
|
18
|
+
local new_tat = tat + emission_interval
|
|
19
|
+
redis.call('SET', key, new_tat, 'EX', ttl)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
return { allow and 1 or 0, tat }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local emission_interval = tonumber(ARGV[1])
|
|
3
|
+
local delay_tolerance = tonumber(ARGV[2])
|
|
4
|
+
local now = tonumber(ARGV[3])
|
|
5
|
+
|
|
6
|
+
local tat = redis.call('GET', key)
|
|
7
|
+
if not tat then
|
|
8
|
+
tat = 0
|
|
9
|
+
else
|
|
10
|
+
tat = tonumber(tat)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
tat = math.max(tat, now)
|
|
14
|
+
local allow = (tat - now) <= delay_tolerance
|
|
15
|
+
|
|
16
|
+
return { allow and 1 or 0, tat }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local capacity = tonumber(ARGV[1])
|
|
3
|
+
local refill_rate = tonumber(ARGV[2])
|
|
4
|
+
local now = tonumber(ARGV[3])
|
|
5
|
+
|
|
6
|
+
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
|
|
7
|
+
local tokens = tonumber(bucket[1]) or capacity
|
|
8
|
+
local last_refill = tonumber(bucket[2]) or now
|
|
9
|
+
|
|
10
|
+
-- Calculate tokens without modifying
|
|
11
|
+
local elapsed = now - last_refill
|
|
12
|
+
local tokens_to_add = elapsed * refill_rate
|
|
13
|
+
tokens = math.min(tokens + tokens_to_add, capacity)
|
|
14
|
+
|
|
15
|
+
local allow = tokens >= 1
|
|
16
|
+
local tokens_after = allow and (tokens - 1) or 0
|
|
17
|
+
|
|
18
|
+
return { allow and 1 or 0, tokens_after }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
local key = KEYS[1]
|
|
2
|
+
local capacity = tonumber(ARGV[1])
|
|
3
|
+
local refill_rate = tonumber(ARGV[2])
|
|
4
|
+
local ttl = tonumber(ARGV[3])
|
|
5
|
+
local now = tonumber(ARGV[4])
|
|
6
|
+
|
|
7
|
+
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
|
|
8
|
+
local tokens = tonumber(bucket[1]) or capacity
|
|
9
|
+
local last_refill = tonumber(bucket[2]) or now
|
|
10
|
+
|
|
11
|
+
-- Refill tokens
|
|
12
|
+
local elapsed = now - last_refill
|
|
13
|
+
local tokens_to_add = elapsed * refill_rate
|
|
14
|
+
tokens = math.min(tokens + tokens_to_add, capacity)
|
|
15
|
+
|
|
16
|
+
local allow = tokens >= 1
|
|
17
|
+
if allow then
|
|
18
|
+
tokens = tokens - 1
|
|
19
|
+
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
|
20
|
+
redis.call('EXPIRE', key, ttl)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
return { allow and 1 or 0, tokens }
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThrottleMachines
|
|
4
|
+
module Storage
|
|
5
|
+
class Redis < Base
|
|
6
|
+
# Load Lua scripts from files
|
|
7
|
+
LUA_SCRIPTS_DIR = File.expand_path('redis', __dir__)
|
|
8
|
+
|
|
9
|
+
GCRA_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'gcra.lua'))
|
|
10
|
+
TOKEN_BUCKET_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'token_bucket.lua'))
|
|
11
|
+
PEEK_GCRA_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'peek_gcra.lua'))
|
|
12
|
+
PEEK_TOKEN_BUCKET_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'peek_token_bucket.lua'))
|
|
13
|
+
INCREMENT_COUNTER_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'increment_counter.lua'))
|
|
14
|
+
# Breaker scripts removed: breaker state is owned by BreakerMachines
|
|
15
|
+
|
|
16
|
+
def initialize(options = {})
|
|
17
|
+
super
|
|
18
|
+
@redis = options[:redis] || options[:client] || options[:pool]
|
|
19
|
+
@prefix = options[:prefix] || 'throttle:'
|
|
20
|
+
|
|
21
|
+
# Cache scripts to avoid repeated script loads
|
|
22
|
+
@gcra_sha = nil
|
|
23
|
+
@token_bucket_sha = nil
|
|
24
|
+
@peek_gcra_sha = nil
|
|
25
|
+
@peek_token_bucket_sha = nil
|
|
26
|
+
|
|
27
|
+
# Validate Redis connection
|
|
28
|
+
validate_redis_connection!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Rate limiting operations
|
|
32
|
+
def increment_counter(key, window, amount = 1)
|
|
33
|
+
window_key = prefixed("#{key}:#{window}")
|
|
34
|
+
|
|
35
|
+
# Use Lua script for atomic increment with TTL
|
|
36
|
+
with_redis do |redis|
|
|
37
|
+
redis.eval(INCREMENT_COUNTER_SCRIPT, keys: [window_key], argv: [amount, window.to_i])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get_counter(key, window)
|
|
42
|
+
window_key = prefixed("#{key}:#{window}")
|
|
43
|
+
with_redis { |r| (r.get(window_key) || 0).to_i }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_counter_ttl(key, window)
|
|
47
|
+
window_key = prefixed("#{key}:#{window}")
|
|
48
|
+
ttl = with_redis { |r| r.ttl(window_key) }
|
|
49
|
+
[ttl, 0].max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset_counter(key, window)
|
|
53
|
+
window_key = prefixed("#{key}:#{window}")
|
|
54
|
+
with_redis { |r| r.del(window_key) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# GCRA operations (atomic via Lua)
|
|
58
|
+
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
59
|
+
ensure_gcra_script_loaded!
|
|
60
|
+
|
|
61
|
+
result = with_redis do |redis|
|
|
62
|
+
redis.evalsha(
|
|
63
|
+
@gcra_sha,
|
|
64
|
+
keys: [prefixed(key)],
|
|
65
|
+
argv: [emission_interval, delay_tolerance, ttl, current_time]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
allowed = result[0] == 1
|
|
70
|
+
tat = result[1]
|
|
71
|
+
now = current_time
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
allowed: allowed,
|
|
75
|
+
retry_after: allowed ? 0 : (tat - now - delay_tolerance),
|
|
76
|
+
tat: tat
|
|
77
|
+
}
|
|
78
|
+
rescue ::Redis::CommandError => e
|
|
79
|
+
raise unless e.message.include?('NOSCRIPT')
|
|
80
|
+
|
|
81
|
+
@gcra_sha = nil
|
|
82
|
+
retry
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Token bucket operations (atomic via Lua)
|
|
86
|
+
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
87
|
+
ensure_token_bucket_script_loaded!
|
|
88
|
+
|
|
89
|
+
result = with_redis do |redis|
|
|
90
|
+
redis.evalsha(
|
|
91
|
+
@token_bucket_sha,
|
|
92
|
+
keys: [prefixed(key)],
|
|
93
|
+
argv: [capacity, refill_rate, ttl, current_time]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
allowed = result[0] == 1
|
|
98
|
+
tokens = result[1]
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
allowed: allowed,
|
|
102
|
+
retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
|
|
103
|
+
tokens_remaining: tokens.floor
|
|
104
|
+
}
|
|
105
|
+
rescue ::Redis::CommandError => e
|
|
106
|
+
raise unless e.message.include?('NOSCRIPT')
|
|
107
|
+
|
|
108
|
+
@token_bucket_sha = nil
|
|
109
|
+
retry
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Peek methods for non-consuming checks
|
|
113
|
+
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
114
|
+
ensure_peek_gcra_script_loaded!
|
|
115
|
+
|
|
116
|
+
result = with_redis do |redis|
|
|
117
|
+
redis.evalsha(
|
|
118
|
+
@peek_gcra_sha,
|
|
119
|
+
keys: [prefixed(key)],
|
|
120
|
+
argv: [emission_interval, delay_tolerance, current_time]
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
allowed = result[0] == 1
|
|
125
|
+
tat = result[1]
|
|
126
|
+
now = current_time
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
allowed: allowed,
|
|
130
|
+
retry_after: allowed ? 0 : (tat - now - delay_tolerance),
|
|
131
|
+
tat: tat
|
|
132
|
+
}
|
|
133
|
+
rescue ::Redis::CommandError => e
|
|
134
|
+
raise unless e.message.include?('NOSCRIPT')
|
|
135
|
+
|
|
136
|
+
@peek_gcra_sha = nil
|
|
137
|
+
retry
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def peek_token_bucket(key, capacity, refill_rate)
|
|
141
|
+
ensure_peek_token_bucket_script_loaded!
|
|
142
|
+
|
|
143
|
+
result = with_redis do |redis|
|
|
144
|
+
redis.evalsha(
|
|
145
|
+
@peek_token_bucket_sha,
|
|
146
|
+
keys: [prefixed(key)],
|
|
147
|
+
argv: [capacity, refill_rate, current_time]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
allowed = result[0] == 1
|
|
152
|
+
tokens_remaining = result[1]
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
allowed: allowed,
|
|
156
|
+
retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
|
|
157
|
+
tokens_remaining: tokens_remaining.floor
|
|
158
|
+
}
|
|
159
|
+
rescue ::Redis::CommandError => e
|
|
160
|
+
raise unless e.message.include?('NOSCRIPT')
|
|
161
|
+
|
|
162
|
+
@peek_token_bucket_sha = nil
|
|
163
|
+
retry
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# No circuit breaker operations: breaker state is owned by BreakerMachines
|
|
167
|
+
|
|
168
|
+
# Utility operations
|
|
169
|
+
def clear(pattern = nil)
|
|
170
|
+
# Use SCAN instead of KEYS to avoid blocking in production
|
|
171
|
+
cursor = '0'
|
|
172
|
+
scan_pattern = pattern ? prefixed(pattern) : "#{@prefix}*"
|
|
173
|
+
|
|
174
|
+
with_redis do |redis|
|
|
175
|
+
loop do
|
|
176
|
+
cursor, keys = redis.scan(cursor, match: scan_pattern, count: 100)
|
|
177
|
+
redis.del(*keys) unless keys.empty?
|
|
178
|
+
break if cursor == '0'
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def healthy?
|
|
184
|
+
with_redis { |r| r.ping == 'PONG' }
|
|
185
|
+
rescue StandardError
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def prefixed(key)
|
|
192
|
+
"#{@prefix}#{key}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def ensure_gcra_script_loaded!
|
|
196
|
+
@ensure_gcra_script_loaded ||= with_redis { |r| r.script(:load, GCRA_SCRIPT) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def ensure_token_bucket_script_loaded!
|
|
200
|
+
@ensure_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, TOKEN_BUCKET_SCRIPT) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def ensure_peek_gcra_script_loaded!
|
|
204
|
+
@ensure_peek_gcra_script_loaded ||= with_redis { |r| r.script(:load, PEEK_GCRA_SCRIPT) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def ensure_peek_token_bucket_script_loaded!
|
|
208
|
+
@ensure_peek_token_bucket_script_loaded ||= with_redis { |r| r.script(:load, PEEK_TOKEN_BUCKET_SCRIPT) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate_redis_connection!
|
|
212
|
+
raise ArgumentError, 'Redis client not provided' unless @redis
|
|
213
|
+
|
|
214
|
+
# Test connection
|
|
215
|
+
with_redis(&:ping)
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
raise ArgumentError, "Invalid Redis connection: #{e.message}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def with_redis(&block)
|
|
221
|
+
if @redis.respond_to?(:with)
|
|
222
|
+
# Connection pool
|
|
223
|
+
@redis.with(&block)
|
|
224
|
+
else
|
|
225
|
+
# Regular Redis client
|
|
226
|
+
block.call(@redis)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
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
|