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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +187 -13
  4. data/Rakefile +12 -0
  5. data/lib/throttle_machines/async_limiter.rb +134 -0
  6. data/lib/throttle_machines/clock.rb +41 -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 +25 -0
  11. data/lib/throttle_machines/hedged_request.rb +137 -0
  12. data/lib/throttle_machines/instrumentation.rb +162 -0
  13. data/lib/throttle_machines/limiter.rb +167 -0
  14. data/lib/throttle_machines/middleware.rb +90 -0
  15. data/lib/throttle_machines/rack_middleware/allow2_ban.rb +62 -0
  16. data/lib/throttle_machines/rack_middleware/blocklist.rb +27 -0
  17. data/lib/throttle_machines/rack_middleware/configuration.rb +103 -0
  18. data/lib/throttle_machines/rack_middleware/fail2_ban.rb +87 -0
  19. data/lib/throttle_machines/rack_middleware/request.rb +12 -0
  20. data/lib/throttle_machines/rack_middleware/safelist.rb +27 -0
  21. data/lib/throttle_machines/rack_middleware/throttle.rb +95 -0
  22. data/lib/throttle_machines/rack_middleware/track.rb +51 -0
  23. data/lib/throttle_machines/rack_middleware.rb +87 -0
  24. data/lib/throttle_machines/storage/base.rb +93 -0
  25. data/lib/throttle_machines/storage/memory.rb +374 -0
  26. data/lib/throttle_machines/storage/null.rb +90 -0
  27. data/lib/throttle_machines/storage/redis.rb +451 -0
  28. data/lib/throttle_machines/throttled_error.rb +14 -0
  29. data/lib/throttle_machines/version.rb +5 -0
  30. data/lib/throttle_machines.rb +134 -5
  31. metadata +105 -9
  32. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ VERSION = '0.1.0'
5
+ end
@@ -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
- VERSION = "0.0.0"
5
-
6
- def self.version
7
- VERSION
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
- end
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