throttle_machines 0.2.4-x86_64-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,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 = {}, &block)
36
+ @throttles[name] = Throttle.new(name, options, &block)
37
+ end
38
+
39
+ def track(name, options = {}, &block)
40
+ @tracks[name] = Track.new(name, options, &block)
41
+ end
42
+
43
+ def safelist(name = nil, &block)
44
+ if name
45
+ @safelists[name] = Safelist.new(name, &block)
46
+ else
47
+ @anonymous_safelists << Safelist.new(nil, &block)
48
+ end
49
+ end
50
+
51
+ def blocklist(name = nil, &block)
52
+ if name
53
+ @blocklists[name] = Blocklist.new(name, &block)
54
+ else
55
+ @anonymous_blocklists << Blocklist.new(nil, &block)
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 = {}, &block)
68
+ @fail2bans[name] = Fail2Ban.new(name, options, &block)
69
+ end
70
+
71
+ def allow2ban(name, options = {}, &block)
72
+ @allow2bans[name] = Allow2Ban.new(name, options, &block)
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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ class Fail2Ban < BanFilter
6
+ def banned?(request)
7
+ discriminator = discriminator_for(request)
8
+ return false unless discriminator
9
+
10
+ key = "fail2ban:#{@name}:#{discriminator}"
11
+
12
+ # Use a globally managed BreakerMachines circuit as the ban mechanism
13
+ breaker = BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
14
+ key,
15
+ self,
16
+ failure_threshold: @maxretry,
17
+ failure_window: @findtime,
18
+ reset_timeout: @bantime
19
+ )
20
+
21
+ # Check if circuit is open (banned)
22
+ if breaker.open?
23
+ stats = breaker.stats
24
+ now = BreakerMachines.monotonic_time
25
+ time_until_unban = if stats.opened_at
26
+ remaining = @bantime - (now - stats.opened_at)
27
+ remaining.positive? ? remaining : 0
28
+ else
29
+ @bantime
30
+ end
31
+
32
+ request.env['rack.attack.matched'] = @name
33
+ request.env['rack.attack.match_type'] = :fail2ban
34
+ request.env['rack.attack.match_discriminator'] = discriminator
35
+ request.env['rack.attack.match_data'] = {
36
+ discriminator: discriminator,
37
+ maxretry: @maxretry,
38
+ findtime: @findtime,
39
+ bantime: @bantime,
40
+ failures: stats.failure_count,
41
+ time_until_unban: time_until_unban
42
+ }
43
+
44
+ ThrottleMachines::RackMiddleware.instrument(request)
45
+ true
46
+ else
47
+ false
48
+ end
49
+ end
50
+
51
+ def count(request)
52
+ discriminator = discriminator_for(request)
53
+ return unless discriminator
54
+
55
+ key = "fail2ban:#{@name}:#{discriminator}"
56
+
57
+ # Use the breaker to record a failure if block returns true
58
+ return unless yield
59
+
60
+ breaker = BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
61
+ key,
62
+ self,
63
+ failure_threshold: @maxretry,
64
+ failure_window: @findtime,
65
+ reset_timeout: @bantime
66
+ )
67
+
68
+ # Record failure by trying to call through the breaker
69
+ # and letting it fail
70
+ begin
71
+ breaker.call { raise 'Fail2Ban failure' }
72
+ rescue StandardError
73
+ # Expected - this records the failure
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ # Base class for Safelist and Blocklist filters.
6
+ #
7
+ # Both match a request against a user-supplied block and, on a match, tag the
8
+ # rack env and emit instrumentation. They differ only in the +match_type+
9
+ # recorded on the env, which subclasses provide.
10
+ class ListFilter
11
+ attr_reader :name, :block
12
+
13
+ def initialize(name, &block)
14
+ @name = name
15
+ @block = block
16
+ end
17
+
18
+ def matched_by?(request)
19
+ return false unless @block
20
+
21
+ if @block.call(request)
22
+ request.env['rack.attack.matched'] = @name
23
+ request.env['rack.attack.match_type'] = match_type
24
+ ThrottleMachines::RackMiddleware.instrument(request)
25
+ true
26
+ else
27
+ false
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # @return [Symbol] the rack.attack match type recorded for this filter
34
+ def match_type
35
+ raise NotImplementedError, "#{self.class} must implement #match_type"
36
+ end
37
+ end
38
+ end
39
+ 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ # Allows (short-circuits) requests matching the configured block.
6
+ class Safelist < ListFilter
7
+ private
8
+
9
+ def match_type
10
+ :safelist
11
+ end
12
+ end
13
+ end
14
+ 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,87 @@
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(&block)
34
+ @configuration ||= Configuration.new
35
+ @configuration.instance_eval(&block) if block
36
+ end
37
+
38
+ def reset!
39
+ ThrottleMachines.reset!
40
+ end
41
+
42
+ def clear!
43
+ @configuration = Configuration.new
44
+ end
45
+
46
+ # Instrument for ActiveSupport::Notifications compatibility
47
+ def instrument(request)
48
+ return unless notifier
49
+
50
+ event_type = request.env['rack.attack.match_type']
51
+ notifier.instrument("#{event_type}.throttle_machines", request: request)
52
+ end
53
+ end
54
+
55
+ # Set defaults
56
+ @enabled = true
57
+ @notifier = ActiveSupport::Notifications
58
+ @configuration = Configuration.new
59
+
60
+ def initialize(app)
61
+ @app = app
62
+ end
63
+
64
+ def call(env)
65
+ return @app.call(env) if !self.class.enabled || env['rack.attack.called']
66
+
67
+ env['rack.attack.called'] = true
68
+ request = Request.new(env)
69
+
70
+ # Always use the current class-level configuration
71
+ configuration = self.class.configuration
72
+
73
+ if configuration.safelisted?(request)
74
+ @app.call(env)
75
+ elsif configuration.blocklisted?(request)
76
+ configuration.blocklisted_responder.call(request)
77
+ elsif configuration.throttled?(request)
78
+ configuration.throttled_responder.call(request)
79
+ else
80
+ configuration.tracked?(request)
81
+ @app.call(env)
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ # No alias - users should use the explicit name to avoid confusion
@@ -0,0 +1,72 @@
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
+ # Utility operations
46
+ def clear(pattern = nil)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def healthy?
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def with_timeout(timeout, &block)
55
+ Timeout.timeout(timeout, &block)
56
+ rescue Timeout::Error
57
+ nil
58
+ end
59
+
60
+ protected
61
+
62
+ def current_time
63
+ # Use monotonic time for consistency with BreakerMachines
64
+ ThrottleMachines.monotonic_time
65
+ end
66
+
67
+ def monotonic_time
68
+ ThrottleMachines.monotonic_time
69
+ end
70
+ end
71
+ end
72
+ end