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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +204 -0
  4. data/lib/throttle_machines/async_breaker.rb +12 -0
  5. data/lib/throttle_machines/async_limiter.rb +24 -0
  6. data/lib/throttle_machines/async_support.rb +75 -0
  7. data/lib/throttle_machines/control.rb +95 -0
  8. data/lib/throttle_machines/controller_helpers.rb +79 -0
  9. data/lib/throttle_machines/dependency_error.rb +6 -0
  10. data/lib/throttle_machines/engine.rb +18 -0
  11. data/lib/throttle_machines/hedged_breaker.rb +23 -0
  12. data/lib/throttle_machines/hedged_request.rb +117 -0
  13. data/lib/throttle_machines/instrumentation.rb +158 -0
  14. data/lib/throttle_machines/limiter.rb +167 -0
  15. data/lib/throttle_machines/middleware.rb +89 -0
  16. data/lib/throttle_machines/native_speedup.rb +98 -0
  17. data/lib/throttle_machines/rack_middleware/allow2_ban.rb +51 -0
  18. data/lib/throttle_machines/rack_middleware/ban_filter.rb +29 -0
  19. data/lib/throttle_machines/rack_middleware/blocklist.rb +14 -0
  20. data/lib/throttle_machines/rack_middleware/configuration.rb +103 -0
  21. data/lib/throttle_machines/rack_middleware/fail2_ban.rb +78 -0
  22. data/lib/throttle_machines/rack_middleware/list_filter.rb +39 -0
  23. data/lib/throttle_machines/rack_middleware/request.rb +12 -0
  24. data/lib/throttle_machines/rack_middleware/safelist.rb +14 -0
  25. data/lib/throttle_machines/rack_middleware/throttle.rb +95 -0
  26. data/lib/throttle_machines/rack_middleware/track.rb +51 -0
  27. data/lib/throttle_machines/rack_middleware.rb +87 -0
  28. data/lib/throttle_machines/storage/base.rb +72 -0
  29. data/lib/throttle_machines/storage/memory.rb +267 -0
  30. data/lib/throttle_machines/storage/null.rb +67 -0
  31. data/lib/throttle_machines/storage/redis/gcra.lua +22 -0
  32. data/lib/throttle_machines/storage/redis/increment_counter.lua +9 -0
  33. data/lib/throttle_machines/storage/redis/peek_gcra.lua +16 -0
  34. data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +18 -0
  35. data/lib/throttle_machines/storage/redis/token_bucket.lua +23 -0
  36. data/lib/throttle_machines/storage/redis.rb +231 -0
  37. data/lib/throttle_machines/throttled_error.rb +14 -0
  38. data/lib/throttle_machines/version.rb +5 -0
  39. data/lib/throttle_machines.rb +200 -0
  40. data/lib/throttle_machines_native/throttle_machines_native.so +0 -0
  41. 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,9 @@
1
+ local count = redis.call('INCRBY', KEYS[1], ARGV[1])
2
+ local ttl = redis.call('TTL', KEYS[1])
3
+
4
+ -- Set expiry if key is new (ttl == -2) or has no TTL (ttl == -1)
5
+ if ttl <= 0 then
6
+ redis.call('EXPIRE', KEYS[1], ARGV[2])
7
+ end
8
+
9
+ return count
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ VERSION = '0.2.4'
5
+ end