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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ # Instrumentation module for emitting events via ActiveSupport::Notifications
5
+ module Instrumentation
6
+ class << self
7
+ attr_writer :enabled, :backend
8
+
9
+ def enabled
10
+ @enabled = true if @enabled.nil?
11
+ @enabled
12
+ end
13
+
14
+ def backend
15
+ @backend ||= ActiveSupport::Notifications
16
+ end
17
+
18
+ def instrument(event_name, payload = {}, &block)
19
+ if !enabled || backend.nil?
20
+ return yield if block_given?
21
+
22
+ return
23
+ end
24
+
25
+ full_event_name = "#{event_name}.throttle_machines"
26
+ backend.instrument(full_event_name, payload, &block)
27
+ end
28
+
29
+ # Convenience methods for common events
30
+
31
+ # Rate limiter events
32
+ def rate_limit_checked(limiter, allowed:, remaining: nil)
33
+ payload = {
34
+ key: limiter.key,
35
+ limit: limiter.limit,
36
+ period: limiter.period,
37
+ algorithm: limiter.algorithm,
38
+ allowed: allowed,
39
+ remaining: remaining
40
+ }
41
+ instrument('rate_limit.checked', payload)
42
+ end
43
+
44
+ def rate_limit_allowed(limiter, remaining: nil)
45
+ payload = {
46
+ key: limiter.key,
47
+ limit: limiter.limit,
48
+ period: limiter.period,
49
+ algorithm: limiter.algorithm,
50
+ remaining: remaining
51
+ }
52
+ instrument('rate_limit.allowed', payload)
53
+ end
54
+
55
+ def rate_limit_throttled(limiter, retry_after: nil)
56
+ payload = {
57
+ key: limiter.key,
58
+ limit: limiter.limit,
59
+ period: limiter.period,
60
+ algorithm: limiter.algorithm,
61
+ retry_after: retry_after
62
+ }
63
+ instrument('rate_limit.throttled', payload)
64
+ end
65
+
66
+ # Circuit breaker events
67
+ def circuit_opened(breaker, failure_count:)
68
+ payload = {
69
+ key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
70
+ failure_threshold: (breaker.respond_to?(:configuration) ? breaker.configuration[:failure_threshold] : nil),
71
+ timeout: (breaker.respond_to?(:configuration) ? breaker.configuration[:reset_timeout] : nil),
72
+ failure_count: failure_count
73
+ }
74
+ instrument('circuit_breaker.opened', payload)
75
+ end
76
+
77
+ def circuit_closed(breaker)
78
+ payload = {
79
+ key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
80
+ failure_threshold: (breaker.respond_to?(:configuration) ? breaker.configuration[:failure_threshold] : nil),
81
+ timeout: (breaker.respond_to?(:configuration) ? breaker.configuration[:reset_timeout] : nil)
82
+ }
83
+ instrument('circuit_breaker.closed', payload)
84
+ end
85
+
86
+ def circuit_half_opened(breaker)
87
+ payload = {
88
+ key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
89
+ failure_threshold: (breaker.respond_to?(:configuration) ? breaker.configuration[:failure_threshold] : nil),
90
+ timeout: (breaker.respond_to?(:configuration) ? breaker.configuration[:reset_timeout] : nil),
91
+ half_open_requests: (breaker.respond_to?(:configuration) ? breaker.configuration[:half_open_calls] : nil)
92
+ }
93
+ instrument('circuit_breaker.half_opened', payload)
94
+ end
95
+
96
+ def circuit_success(breaker)
97
+ payload = {
98
+ key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
99
+ state: (breaker.respond_to?(:to_h) ? breaker.to_h[:state] : nil)
100
+ }
101
+ instrument('circuit_breaker.success', payload)
102
+ end
103
+
104
+ def circuit_failure(breaker, error: nil)
105
+ payload = {
106
+ key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
107
+ state: (breaker.respond_to?(:to_h) ? breaker.to_h[:state] : nil),
108
+ error_class: error&.class&.name,
109
+ error_message: error&.message
110
+ }
111
+ instrument('circuit_breaker.failure', payload)
112
+ end
113
+
114
+ def circuit_rejected(breaker)
115
+ payload = {
116
+ key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
117
+ failure_threshold: (breaker.respond_to?(:configuration) ? breaker.configuration[:failure_threshold] : nil),
118
+ timeout: (breaker.respond_to?(:configuration) ? breaker.configuration[:reset_timeout] : nil)
119
+ }
120
+ instrument('circuit_breaker.rejected', payload)
121
+ end
122
+
123
+ # Cascade events
124
+ def cascade_triggered(primary_key, cascaded_key)
125
+ payload = {
126
+ primary_key: primary_key,
127
+ cascaded_key: cascaded_key
128
+ }
129
+ instrument('cascade.triggered', payload)
130
+ end
131
+
132
+ # Hedged request events
133
+ def hedged_request_started(request_id, attempts:)
134
+ payload = {
135
+ request_id: request_id,
136
+ max_attempts: attempts
137
+ }
138
+ instrument('hedged_request.started', payload)
139
+ end
140
+
141
+ def hedged_request_winner(request_id, attempt:, duration:)
142
+ payload = {
143
+ request_id: request_id,
144
+ winning_attempt: attempt,
145
+ duration: duration
146
+ }
147
+ instrument('hedged_request.winner', payload)
148
+ end
149
+ end
150
+
151
+ # Null backend for when ActiveSupport::Notifications is not available
152
+ class NullBackend
153
+ def instrument(_name, _payload = {})
154
+ yield if block_given?
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class Limiter
5
+ attr_reader :key, :limit, :period, :algorithm, :storage
6
+
7
+ def initialize(key, limit:, period:, algorithm: :fixed_window, storage: nil)
8
+ @key = key
9
+ @limit = limit
10
+ @period = period
11
+ @algorithm = algorithm
12
+ @storage = storage || ThrottleMachines.storage
13
+ end
14
+
15
+ def allow?
16
+ allowed = case @algorithm
17
+ when :fixed_window
18
+ # Don't increment here, just check
19
+ count = @storage.get_counter(@key, @period)
20
+ count < @limit
21
+ when :gcra
22
+ result = @storage.peek_gcra_limit(
23
+ @key,
24
+ @period.to_f / @limit, # emission_interval
25
+ 0 # delay_tolerance (no burst)
26
+ )
27
+ result[:allowed]
28
+ when :token_bucket
29
+ result = @storage.peek_token_bucket(
30
+ @key,
31
+ @limit, # capacity
32
+ @limit.to_f / @period # refill_rate
33
+ )
34
+ result[:allowed]
35
+ else
36
+ raise ArgumentError, "Unknown algorithm: #{@algorithm}"
37
+ end
38
+
39
+ # Instrument the check
40
+ Instrumentation.rate_limit_checked(self, allowed: allowed, remaining: nil)
41
+
42
+ allowed
43
+ end
44
+
45
+ def throttle!
46
+ case @algorithm
47
+ when :fixed_window
48
+ # Increment and check atomically
49
+ count = @storage.increment_counter(@key, @period)
50
+ if count > @limit
51
+ Instrumentation.rate_limit_throttled(self, retry_after: retry_after)
52
+ raise ThrottledError, self
53
+ end
54
+ when :gcra
55
+ result = @storage.check_gcra_limit(
56
+ @key,
57
+ @period.to_f / @limit, # emission_interval
58
+ 0, # delay_tolerance (no burst)
59
+ (@period * 2).to_i # ttl
60
+ )
61
+ unless result[:allowed]
62
+ Instrumentation.rate_limit_throttled(self, retry_after: retry_after)
63
+ raise ThrottledError, self
64
+ end
65
+ when :token_bucket
66
+ result = @storage.check_token_bucket(
67
+ @key,
68
+ @limit, # capacity
69
+ @limit.to_f / @period, # refill_rate
70
+ (@period * 2).to_i # ttl
71
+ )
72
+ unless result[:allowed]
73
+ Instrumentation.rate_limit_throttled(self, retry_after: retry_after)
74
+ raise ThrottledError, self
75
+ end
76
+ else
77
+ raise ArgumentError, "Unknown algorithm: #{@algorithm}"
78
+ end
79
+
80
+ # If we get here, the request was allowed
81
+ # Calculate remaining after the operation
82
+ remaining_count = begin
83
+ remaining
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ Instrumentation.rate_limit_allowed(self, remaining: remaining_count)
88
+
89
+ yield if block_given?
90
+ end
91
+
92
+ def reset!
93
+ case @algorithm
94
+ when :fixed_window
95
+ @storage.reset_counter(@key, @period)
96
+ else
97
+ @storage.clear("#{@key}*")
98
+ end
99
+ end
100
+
101
+ def remaining
102
+ case @algorithm
103
+ when :fixed_window
104
+ count = @storage.get_counter(@key, @period)
105
+ [@limit - count, 0].max
106
+ when :gcra
107
+ # GCRA doesn't have a simple "remaining" count
108
+ # Just return 1 or 0 based on current state
109
+ result = @storage.peek_gcra_limit(
110
+ @key,
111
+ @period.to_f / @limit,
112
+ 0
113
+ )
114
+ result[:allowed] ? 1 : 0
115
+ when :token_bucket
116
+ result = @storage.peek_token_bucket(
117
+ @key,
118
+ @limit,
119
+ @limit.to_f / @period
120
+ )
121
+ result[:tokens_remaining].to_i
122
+ else
123
+ 0
124
+ end
125
+ end
126
+
127
+ def retry_after
128
+ case @algorithm
129
+ when :fixed_window
130
+ count = @storage.get_counter(@key, @period)
131
+ if count >= @limit
132
+ # Return the actual time remaining in the current window
133
+ @storage.get_counter_ttl(@key, @period)
134
+ else
135
+ 0
136
+ end
137
+ when :gcra
138
+ result = @storage.peek_gcra_limit(
139
+ @key,
140
+ @period.to_f / @limit,
141
+ 0
142
+ )
143
+ result[:retry_after]
144
+ when :token_bucket
145
+ result = @storage.peek_token_bucket(
146
+ @key,
147
+ @limit,
148
+ @limit.to_f / @period
149
+ )
150
+ result[:retry_after]
151
+ else
152
+ 0
153
+ end
154
+ end
155
+
156
+ def to_h
157
+ {
158
+ key: @key,
159
+ limit: @limit,
160
+ period: @period,
161
+ algorithm: @algorithm,
162
+ remaining: remaining,
163
+ retry_after: retry_after
164
+ }
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class Middleware
5
+ def initialize(app, &config_block)
6
+ @app = app
7
+ @rules = []
8
+
9
+ instance_eval(&config_block) if config_block
10
+ end
11
+
12
+ def call(env)
13
+ request = Rack::Request.new(env)
14
+
15
+ @rules.each do |rule|
16
+ next unless rule[:matcher].call(request)
17
+
18
+ key = rule[:key_generator].call(request)
19
+ limiter = ThrottleMachines.limiter(
20
+ key,
21
+ limit: rule[:limit],
22
+ period: rule[:period],
23
+ algorithm: rule[:algorithm]
24
+ )
25
+
26
+ return rate_limit_response(limiter) unless limiter.allow?
27
+ end
28
+
29
+ @app.call(env)
30
+ rescue ThrottledError => e
31
+ rate_limit_response(e.limiter)
32
+ end
33
+
34
+ def throttle(path, limit:, period:, by: :ip, algorithm: :gcra)
35
+ @rules << {
36
+ matcher: build_matcher(path),
37
+ key_generator: build_key_generator(by),
38
+ limit: limit,
39
+ period: period,
40
+ algorithm: algorithm
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ def build_matcher(path)
47
+ case path
48
+ when String
49
+ ->(request) { request.path == path }
50
+ when Regexp
51
+ ->(request) { request.path =~ path }
52
+ when Proc
53
+ path
54
+ else
55
+ ->(_request) { true }
56
+ end
57
+ end
58
+
59
+ def build_key_generator(by)
60
+ case by
61
+ when :ip
62
+ ->(request) { "ip:#{request.ip}" }
63
+ when Symbol
64
+ ->(request) { "#{by}:#{request.env['rack.session']&.dig(by)}" }
65
+ when Proc
66
+ by
67
+ else
68
+ ->(_request) { by.to_s }
69
+ end
70
+ end
71
+
72
+ def rate_limit_response(limiter)
73
+ headers = {
74
+ 'Content-Type' => 'application/json',
75
+ 'X-RateLimit-Limit' => limiter.limit.to_s,
76
+ 'X-RateLimit-Remaining' => limiter.remaining.to_s,
77
+ 'X-RateLimit-Reset' => (Time.now.to_i + limiter.retry_after).to_s,
78
+ 'Retry-After' => limiter.retry_after.ceil.to_s
79
+ }
80
+
81
+ body = JSON.generate({
82
+ error: 'Rate limit exceeded',
83
+ retry_after: limiter.retry_after
84
+ })
85
+
86
+ [429, headers, [body]]
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Native speedup for ThrottleMachines using Rust FFI.
4
+ #
5
+ # This module is automatically loaded when the native extension is available.
6
+ # It prepends optimized implementations onto Storage::Memory.
7
+ #
8
+ # Environment controls:
9
+ # DISABLE_MATRYOSHKA_NATIVE=1 - Disable all matryoshka native extensions
10
+ # DISABLE_THROTTLE_MACHINES_NATIVE=1 - Disable only throttle_machines native
11
+ # DEBUG_MATRYOSHKA=1 - Log which backend is loaded
12
+
13
+ return if ENV["DISABLE_MATRYOSHKA_NATIVE"]
14
+ return if ENV["DISABLE_THROTTLE_MACHINES_NATIVE"]
15
+
16
+ begin
17
+ require "throttle_machines_native/throttle_machines_native"
18
+
19
+ module ThrottleMachines
20
+ module Storage
21
+ # Native GCRA implementation using Rust.
22
+ module NativeGCRA
23
+ def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
24
+ with_write_lock(key) do
25
+ now = current_time
26
+ state = @gcra_states[key] || { tat: 0.0 }
27
+
28
+ # Rust does the math
29
+ ThrottleMachinesNative.gcra_check(
30
+ state[:tat], now, emission_interval, delay_tolerance
31
+ ) => { allowed:, state: new_tat, retry_after: }
32
+
33
+ @gcra_states[key] = { tat: new_tat, expires_at: now + ttl } if allowed
34
+
35
+ { allowed: allowed, retry_after: retry_after, tat: new_tat }
36
+ end
37
+ end
38
+
39
+ def peek_gcra_limit(key, _emission_interval, delay_tolerance)
40
+ with_read_lock(key) do
41
+ now = current_time
42
+ state = @gcra_states[key] || { tat: 0.0 }
43
+
44
+ # Rust does the math
45
+ ThrottleMachinesNative.gcra_peek(
46
+ state[:tat], now, delay_tolerance
47
+ ) => { allowed:, state: tat, retry_after: }
48
+
49
+ { allowed: allowed, retry_after: retry_after, tat: tat }
50
+ end
51
+ end
52
+ end
53
+
54
+ # Native Token Bucket implementation using Rust.
55
+ module NativeTokenBucket
56
+ def check_token_bucket(key, capacity, refill_rate, ttl)
57
+ with_write_lock(key) do
58
+ now = current_time
59
+ bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
60
+
61
+ # Rust does the math
62
+ ThrottleMachinesNative.token_bucket_check(
63
+ bucket[:tokens], bucket[:last_refill], now, capacity, refill_rate
64
+ ) => { allowed:, state: new_tokens, retry_after: }
65
+
66
+ if allowed
67
+ @token_buckets[key] = { tokens: new_tokens, last_refill: now, expires_at: now + ttl }
68
+ end
69
+
70
+ { allowed: allowed, retry_after: retry_after, tokens_remaining: new_tokens.floor }
71
+ end
72
+ end
73
+
74
+ def peek_token_bucket(key, capacity, refill_rate)
75
+ with_read_lock(key) do
76
+ now = current_time
77
+ bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
78
+
79
+ # Rust does the math
80
+ ThrottleMachinesNative.token_bucket_peek(
81
+ bucket[:tokens], bucket[:last_refill], now, capacity, refill_rate
82
+ ) => { allowed:, state: tokens, retry_after: }
83
+
84
+ { allowed: allowed, retry_after: retry_after, tokens_remaining: tokens.floor }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ # Prepend native implementations
92
+ ThrottleMachines::Storage::Memory.prepend(ThrottleMachines::Storage::NativeGCRA)
93
+ ThrottleMachines::Storage::Memory.prepend(ThrottleMachines::Storage::NativeTokenBucket)
94
+
95
+ warn "[ThrottleMachines] Native speedup loaded" if ENV["DEBUG_MATRYOSHKA"]
96
+ rescue LoadError => e
97
+ warn "[ThrottleMachines] Native speedup unavailable: #{e.message}" if ENV["DEBUG_MATRYOSHKA"]
98
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ class Allow2Ban < BanFilter
6
+ def matched_by?(request)
7
+ discriminator = discriminator_for(request)
8
+ return false unless discriminator
9
+
10
+ # Allow2Ban resets fail2ban counters on successful requests
11
+ # We'll track successful requests and reset the breaker when threshold is met
12
+
13
+ success_key = "allow2ban:#{@name}:#{discriminator}"
14
+ fail_key = "fail2ban:#{@name}:#{discriminator}"
15
+
16
+ # Count successful requests
17
+ success_limiter = ThrottleMachines.limiter(
18
+ success_key,
19
+ limit: @maxretry,
20
+ period: @findtime,
21
+ algorithm: :fixed_window
22
+ )
23
+
24
+ # Check if we've had enough successful requests
25
+ if success_limiter.remaining.zero?
26
+ # Reset the fail2ban breaker (use BreakerMachines circuit)
27
+ breaker = BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
28
+ fail_key,
29
+ self,
30
+ failure_threshold: @maxretry,
31
+ failure_window: @findtime,
32
+ reset_timeout: @bantime
33
+ )
34
+ breaker.hard_reset
35
+
36
+ # Reset our own counter
37
+ ThrottleMachines.storage.reset_counter(success_key, @findtime)
38
+ else
39
+ # Increment success counter
40
+ begin
41
+ success_limiter.throttle!
42
+ rescue ThrottledError
43
+ # We've hit the limit, which triggers the reset above
44
+ end
45
+ end
46
+
47
+ false # Allow2Ban never blocks directly
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ # Base class for Fail2Ban and Allow2Ban filters.
6
+ #
7
+ # Both are configured with the same maxretry/findtime/bantime thresholds and
8
+ # derive a discriminator from the request via the supplied block. The ban
9
+ # logic itself lives in each subclass.
10
+ class BanFilter
11
+ attr_reader :name, :maxretry, :findtime, :bantime, :block
12
+
13
+ def initialize(name, options, &block)
14
+ @name = name
15
+ @block = block
16
+
17
+ @maxretry = options[:maxretry] || 5
18
+ @findtime = options[:findtime] || 60
19
+ @bantime = options[:bantime] || 300
20
+ end
21
+
22
+ private
23
+
24
+ def discriminator_for(request)
25
+ @block.call(request)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ # Blocks requests matching the configured block (returns true => blocked).
6
+ class Blocklist < ListFilter
7
+ private
8
+
9
+ def match_type
10
+ :blocklist
11
+ end
12
+ end
13
+ end
14
+ end