throttle_machines 0.0.0 → 0.1.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/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/control.rb +95 -0
  7. data/lib/throttle_machines/controller_helpers.rb +79 -0
  8. data/lib/throttle_machines/dependency_error.rb +6 -0
  9. data/lib/throttle_machines/engine.rb +23 -0
  10. data/lib/throttle_machines/hedged_breaker.rb +23 -0
  11. data/lib/throttle_machines/hedged_request.rb +117 -0
  12. data/lib/throttle_machines/instrumentation.rb +158 -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 +89 -0
  24. data/lib/throttle_machines/storage/base.rb +93 -0
  25. data/lib/throttle_machines/storage/memory.rb +373 -0
  26. data/lib/throttle_machines/storage/null.rb +88 -0
  27. data/lib/throttle_machines/storage/redis/gcra.lua +22 -0
  28. data/lib/throttle_machines/storage/redis/get_breaker_state.lua +23 -0
  29. data/lib/throttle_machines/storage/redis/increment_counter.lua +9 -0
  30. data/lib/throttle_machines/storage/redis/peek_gcra.lua +16 -0
  31. data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +18 -0
  32. data/lib/throttle_machines/storage/redis/record_breaker_failure.lua +24 -0
  33. data/lib/throttle_machines/storage/redis/record_breaker_success.lua +16 -0
  34. data/lib/throttle_machines/storage/redis/token_bucket.lua +23 -0
  35. data/lib/throttle_machines/storage/redis.rb +294 -0
  36. data/lib/throttle_machines/throttled_error.rb +14 -0
  37. data/lib/throttle_machines/version.rb +5 -0
  38. data/lib/throttle_machines.rb +130 -5
  39. metadata +113 -9
  40. data/LICENSE.txt +0 -21
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module ThrottleMachines
6
+ class RackMiddleware
7
+ class Configuration
8
+ DEFAULT_BLOCKLISTED_RESPONDER = ->(_req) { [403, { 'content-type' => 'text/plain' }, ["Forbidden\n"]] }
9
+
10
+ DEFAULT_THROTTLED_RESPONDER = lambda do |req|
11
+ match_data = req.env['rack.attack.match_data']
12
+ retry_after = match_data[:retry_after] || 60
13
+
14
+ [429, { 'content-type' => 'text/plain', 'retry-after' => retry_after.to_s }, ["Retry later\n"]]
15
+ end
16
+
17
+ attr_reader :throttles, :tracks, :safelists, :blocklists
18
+ attr_accessor :throttled_responder, :blocklisted_responder
19
+
20
+ def initialize
21
+ @throttles = {}
22
+ @tracks = {}
23
+ @safelists = {}
24
+ @blocklists = {}
25
+ @anonymous_safelists = []
26
+ @anonymous_blocklists = []
27
+ @fail2bans = {}
28
+ @allow2bans = {}
29
+
30
+ @throttled_responder = DEFAULT_THROTTLED_RESPONDER
31
+ @blocklisted_responder = DEFAULT_BLOCKLISTED_RESPONDER
32
+ end
33
+
34
+ # DSL Methods
35
+ def throttle(name, options = {}, &)
36
+ @throttles[name] = Throttle.new(name, options, &)
37
+ end
38
+
39
+ def track(name, options = {}, &)
40
+ @tracks[name] = Track.new(name, options, &)
41
+ end
42
+
43
+ def safelist(name = nil, &)
44
+ if name
45
+ @safelists[name] = Safelist.new(name, &)
46
+ else
47
+ @anonymous_safelists << Safelist.new(nil, &)
48
+ end
49
+ end
50
+
51
+ def blocklist(name = nil, &)
52
+ if name
53
+ @blocklists[name] = Blocklist.new(name, &)
54
+ else
55
+ @anonymous_blocklists << Blocklist.new(nil, &)
56
+ end
57
+ end
58
+
59
+ def safelist_ip(ip_address)
60
+ @anonymous_safelists << Safelist.new(nil) { |req| req.ip == ip_address }
61
+ end
62
+
63
+ def blocklist_ip(ip_address)
64
+ @anonymous_blocklists << Blocklist.new(nil) { |req| req.ip == ip_address }
65
+ end
66
+
67
+ def fail2ban(name, options = {}, &)
68
+ @fail2bans[name] = Fail2Ban.new(name, options, &)
69
+ end
70
+
71
+ def allow2ban(name, options = {}, &)
72
+ @allow2bans[name] = Allow2Ban.new(name, options, &)
73
+ end
74
+
75
+ # Check methods
76
+ def safelisted?(request)
77
+ @anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
78
+ @safelists.values.any? { |safelist| safelist.matched_by?(request) }
79
+ end
80
+
81
+ def blocklisted?(request)
82
+ # Check explicit blocklists
83
+ return true if @anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) }
84
+ return true if @blocklists.values.any? { |blocklist| blocklist.matched_by?(request) }
85
+
86
+ # Check fail2bans
87
+ @fail2bans.values.any? { |fail2ban| fail2ban.banned?(request) }
88
+ end
89
+
90
+ def throttled?(request)
91
+ # Process allow2bans first (they can reset fail2ban counters)
92
+ @allow2bans.each_value { |allow2ban| allow2ban.matched_by?(request) }
93
+
94
+ # Check throttles
95
+ @throttles.values.any? { |throttle| throttle.matched_by?(request) }
96
+ end
97
+
98
+ def tracked?(request)
99
+ @tracks.each_value { |track| track.matched_by?(request) }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ class Fail2Ban
6
+ attr_reader :name, :maxretry, :findtime, :bantime, :block
7
+
8
+ def initialize(name, options, &block)
9
+ @name = name
10
+ @block = block
11
+
12
+ @maxretry = options[:maxretry] || 5
13
+ @findtime = options[:findtime] || 60
14
+ @bantime = options[:bantime] || 300
15
+ end
16
+
17
+ def banned?(request)
18
+ discriminator = discriminator_for(request)
19
+ return false unless discriminator
20
+
21
+ key = "fail2ban:#{@name}:#{discriminator}"
22
+
23
+ # Use circuit breaker to track failures
24
+ breaker = ThrottleMachines::Breaker.new(
25
+ key,
26
+ failure_threshold: @maxretry,
27
+ timeout: @bantime,
28
+ storage: ThrottleMachines.storage
29
+ )
30
+
31
+ # Check if circuit is open (banned)
32
+ if breaker.open?
33
+ # Get breaker state for instrumentation
34
+ state = breaker.to_h
35
+
36
+ request.env['rack.attack.matched'] = @name
37
+ request.env['rack.attack.match_type'] = :fail2ban
38
+ request.env['rack.attack.match_discriminator'] = discriminator
39
+ request.env['rack.attack.match_data'] = {
40
+ discriminator: discriminator,
41
+ maxretry: @maxretry,
42
+ findtime: @findtime,
43
+ bantime: @bantime,
44
+ failures: state[:failure_count],
45
+ time_until_unban: state[:time_until_retry]
46
+ }
47
+
48
+ ThrottleMachines::RackMiddleware.instrument(request)
49
+ true
50
+ else
51
+ false
52
+ end
53
+ end
54
+
55
+ def count(request)
56
+ discriminator = discriminator_for(request)
57
+ return unless discriminator
58
+
59
+ key = "fail2ban:#{@name}:#{discriminator}"
60
+
61
+ # Use the breaker to record a failure if block returns true
62
+ return unless yield
63
+
64
+ breaker = ThrottleMachines::Breaker.new(
65
+ key,
66
+ failure_threshold: @maxretry,
67
+ timeout: @bantime,
68
+ storage: ThrottleMachines.storage
69
+ )
70
+
71
+ # Record failure by trying to call through the breaker
72
+ # and letting it fail
73
+ begin
74
+ breaker.call { raise 'Fail2Ban failure' }
75
+ rescue StandardError
76
+ # Expected - this records the failure
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def discriminator_for(request)
83
+ @block.call(request)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ # Rack 3 Request wrapper
6
+ class Request < ::Rack::Request
7
+ def user_agent
8
+ @env['HTTP_USER_AGENT']
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ class Safelist
6
+ attr_reader :name, :block
7
+
8
+ def initialize(name, &block)
9
+ @name = name
10
+ @block = block
11
+ end
12
+
13
+ def matched_by?(request)
14
+ return false unless @block
15
+
16
+ if @block.call(request)
17
+ request.env['rack.attack.matched'] = @name
18
+ request.env['rack.attack.match_type'] = :safelist
19
+ ThrottleMachines::RackMiddleware.instrument(request)
20
+ true
21
+ else
22
+ false
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ class Throttle
6
+ attr_reader :name, :limit, :period, :block, :algorithm
7
+
8
+ def initialize(name, options, &block)
9
+ @name = name
10
+ @block = block
11
+
12
+ raise ArgumentError, 'Must pass :limit option' unless options[:limit]
13
+ raise ArgumentError, 'Must pass :period option' unless options[:period]
14
+
15
+ @limit = options[:limit]
16
+ @period = options[:period].to_i
17
+ @algorithm = options[:algorithm] || :fixed_window
18
+ end
19
+
20
+ def matched_by?(request)
21
+ discriminator = discriminator_for(request)
22
+
23
+ return false unless discriminator
24
+
25
+ key = "#{@name}:#{discriminator}"
26
+ current_limit = limit_for(request)
27
+ current_period = period_for(request)
28
+
29
+ # Use ThrottleMachines limiter
30
+ limiter = ThrottleMachines.limiter(
31
+ key,
32
+ limit: current_limit,
33
+ period: current_period,
34
+ algorithm: @algorithm
35
+ )
36
+
37
+ # Try to consume the request atomically
38
+ throttled = false
39
+
40
+ begin
41
+ # This will either succeed and consume a request, or raise ThrottledError
42
+ limiter.throttle!
43
+
44
+ # If we get here, request was allowed
45
+ rescue ThrottledError
46
+ # Request was throttled
47
+ throttled = true
48
+ end
49
+
50
+ # Get current state for instrumentation
51
+ data = {
52
+ discriminator: discriminator,
53
+ count: current_limit - limiter.remaining,
54
+ period: current_period,
55
+ limit: current_limit,
56
+ retry_after: limiter.retry_after
57
+ }
58
+
59
+ annotate_request_with_throttle_data(request, data)
60
+
61
+ if throttled
62
+ annotate_request_with_matched_data(request, data)
63
+ ThrottleMachines::RackMiddleware.instrument(request)
64
+ end
65
+
66
+ throttled
67
+ end
68
+
69
+ private
70
+
71
+ def discriminator_for(request)
72
+ @block.call(request)
73
+ end
74
+
75
+ def limit_for(request)
76
+ @limit.respond_to?(:call) ? @limit.call(request) : @limit
77
+ end
78
+
79
+ def period_for(request)
80
+ @period.respond_to?(:call) ? @period.call(request) : @period
81
+ end
82
+
83
+ def annotate_request_with_throttle_data(request, data)
84
+ (request.env['rack.attack.throttle_data'] ||= {})[@name] = data
85
+ end
86
+
87
+ def annotate_request_with_matched_data(request, data)
88
+ request.env['rack.attack.matched'] = @name
89
+ request.env['rack.attack.match_discriminator'] = data[:discriminator]
90
+ request.env['rack.attack.match_type'] = :throttle
91
+ request.env['rack.attack.match_data'] = data
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ class Track
6
+ attr_reader :name, :block, :limit, :period
7
+
8
+ def initialize(name, options = {}, &block)
9
+ @name = name
10
+ @block = block
11
+ @limit = options[:limit]
12
+ @period = options[:period]
13
+ end
14
+
15
+ def matched_by?(request)
16
+ discriminator = @block.call(request)
17
+ return false unless discriminator
18
+
19
+ # Track is just instrumentation without blocking
20
+ data = {
21
+ discriminator: discriminator
22
+ }
23
+
24
+ # If limit and period are provided, track the count
25
+ if @limit && @period
26
+ key = "track:#{@name}:#{discriminator}"
27
+ limiter = ThrottleMachines.limiter(
28
+ key,
29
+ limit: @limit,
30
+ period: @period,
31
+ algorithm: :fixed_window
32
+ )
33
+
34
+ # Just check, don't consume
35
+ data[:count] = @limit - limiter.remaining
36
+ data[:limit] = @limit
37
+ data[:period] = @period
38
+ end
39
+
40
+ request.env['rack.attack.matched'] = @name
41
+ request.env['rack.attack.match_type'] = :track
42
+ request.env['rack.attack.match_discriminator'] = discriminator
43
+ request.env['rack.attack.match_data'] = data
44
+
45
+ ThrottleMachines::RackMiddleware.instrument(request)
46
+
47
+ false # Track never blocks
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'forwardable'
5
+
6
+ module ThrottleMachines
7
+ # Advanced Rack middleware for rate limiting and request filtering
8
+ class RackMiddleware
9
+ class << self
10
+ extend Forwardable
11
+
12
+ attr_accessor :enabled, :notifier
13
+ attr_reader :configuration
14
+
15
+ def_delegators :@configuration,
16
+ :throttle,
17
+ :track,
18
+ :safelist,
19
+ :blocklist,
20
+ :blocklist_ip,
21
+ :safelist_ip,
22
+ :fail2ban,
23
+ :allow2ban,
24
+ :throttled_responder,
25
+ :throttled_responder=,
26
+ :blocklisted_responder,
27
+ :blocklisted_responder=,
28
+ :throttles,
29
+ :tracks,
30
+ :safelists,
31
+ :blocklists
32
+
33
+ def configure(&)
34
+ @configuration ||= Configuration.new
35
+ @configuration.instance_eval(&) if block
36
+ end
37
+
38
+ # rubocop:disable Rails/Delegate -- Ruby 3.4 compatibility issue with delegate
39
+ def reset!
40
+ ThrottleMachines.reset!
41
+ end
42
+ # rubocop:enable Rails/Delegate
43
+
44
+ def clear!
45
+ @configuration = Configuration.new
46
+ end
47
+
48
+ # Instrument for ActiveSupport::Notifications compatibility
49
+ def instrument(request)
50
+ return unless notifier
51
+
52
+ event_type = request.env['rack.attack.match_type']
53
+ notifier.instrument("#{event_type}.throttle_machines", request: request)
54
+ end
55
+ end
56
+
57
+ # Set defaults
58
+ @enabled = true
59
+ @notifier = ActiveSupport::Notifications
60
+ @configuration = Configuration.new
61
+
62
+ def initialize(app)
63
+ @app = app
64
+ end
65
+
66
+ def call(env)
67
+ return @app.call(env) if !self.class.enabled || env['rack.attack.called']
68
+
69
+ env['rack.attack.called'] = true
70
+ request = Request.new(env)
71
+
72
+ # Always use the current class-level configuration
73
+ configuration = self.class.configuration
74
+
75
+ if configuration.safelisted?(request)
76
+ @app.call(env)
77
+ elsif configuration.blocklisted?(request)
78
+ configuration.blocklisted_responder.call(request)
79
+ elsif configuration.throttled?(request)
80
+ configuration.throttled_responder.call(request)
81
+ else
82
+ configuration.tracked?(request)
83
+ @app.call(env)
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # No alias - users should use the explicit name to avoid confusion
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ module Storage
5
+ class Base
6
+ def initialize(options = {})
7
+ @options = options
8
+ end
9
+
10
+ # Rate limiting operations
11
+ def increment_counter(key, window, amount = 1)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def get_counter(key, window)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def get_counter_ttl(key, window)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def reset_counter(key, window)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ # GCRA operations (atomic)
28
+ def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def peek_gcra_limit(key, emission_interval, delay_tolerance)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ # Token bucket operations (atomic)
37
+ def check_token_bucket(key, capacity, refill_rate, ttl)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def peek_token_bucket(key, capacity, refill_rate)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ # Circuit breaker operations
46
+ def get_breaker_state(key)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def record_breaker_success(key, timeout, half_open_requests = 1)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def record_breaker_failure(key, threshold, timeout)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def trip_breaker(key, timeout)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ def reset_breaker(key)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ # Utility operations
67
+ def clear(pattern = nil)
68
+ raise NotImplementedError
69
+ end
70
+
71
+ def healthy?
72
+ raise NotImplementedError
73
+ end
74
+
75
+ def with_timeout(timeout, &)
76
+ Timeout.timeout(timeout, &)
77
+ rescue Timeout::Error
78
+ nil
79
+ end
80
+
81
+ protected
82
+
83
+ def current_time
84
+ # Use monotonic time for consistency with BreakerMachines
85
+ ThrottleMachines.monotonic_time
86
+ end
87
+
88
+ def monotonic_time
89
+ ThrottleMachines.monotonic_time
90
+ end
91
+ end
92
+ end
93
+ end