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.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +204 -0
- data/lib/throttle_machines/async_breaker.rb +12 -0
- data/lib/throttle_machines/async_limiter.rb +24 -0
- data/lib/throttle_machines/async_support.rb +75 -0
- data/lib/throttle_machines/control.rb +95 -0
- data/lib/throttle_machines/controller_helpers.rb +79 -0
- data/lib/throttle_machines/dependency_error.rb +6 -0
- data/lib/throttle_machines/engine.rb +18 -0
- data/lib/throttle_machines/hedged_breaker.rb +23 -0
- data/lib/throttle_machines/hedged_request.rb +117 -0
- data/lib/throttle_machines/instrumentation.rb +158 -0
- data/lib/throttle_machines/limiter.rb +167 -0
- data/lib/throttle_machines/middleware.rb +89 -0
- data/lib/throttle_machines/native_speedup.rb +98 -0
- data/lib/throttle_machines/rack_middleware/allow2_ban.rb +51 -0
- data/lib/throttle_machines/rack_middleware/ban_filter.rb +29 -0
- data/lib/throttle_machines/rack_middleware/blocklist.rb +14 -0
- data/lib/throttle_machines/rack_middleware/configuration.rb +103 -0
- data/lib/throttle_machines/rack_middleware/fail2_ban.rb +78 -0
- data/lib/throttle_machines/rack_middleware/list_filter.rb +39 -0
- data/lib/throttle_machines/rack_middleware/request.rb +12 -0
- data/lib/throttle_machines/rack_middleware/safelist.rb +14 -0
- data/lib/throttle_machines/rack_middleware/throttle.rb +95 -0
- data/lib/throttle_machines/rack_middleware/track.rb +51 -0
- data/lib/throttle_machines/rack_middleware.rb +87 -0
- data/lib/throttle_machines/storage/base.rb +72 -0
- data/lib/throttle_machines/storage/memory.rb +267 -0
- data/lib/throttle_machines/storage/null.rb +67 -0
- data/lib/throttle_machines/storage/redis/gcra.lua +22 -0
- data/lib/throttle_machines/storage/redis/increment_counter.lua +9 -0
- data/lib/throttle_machines/storage/redis/peek_gcra.lua +16 -0
- data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +18 -0
- data/lib/throttle_machines/storage/redis/token_bucket.lua +23 -0
- data/lib/throttle_machines/storage/redis.rb +231 -0
- data/lib/throttle_machines/throttled_error.rb +14 -0
- data/lib/throttle_machines/version.rb +5 -0
- data/lib/throttle_machines.rb +200 -0
- data/lib/throttle_machines_native/throttle_machines_native.so +0 -0
- 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
|