throttle_machines 0.1.1 → 0.2.4
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 +4 -4
- data/README.md +3 -0
- data/lib/throttle_machines/async_breaker.rb +12 -0
- data/lib/throttle_machines/async_limiter.rb +7 -117
- data/lib/throttle_machines/async_support.rb +75 -0
- data/lib/throttle_machines/controller_helpers.rb +2 -2
- data/lib/throttle_machines/engine.rb +1 -6
- data/lib/throttle_machines/hedged_breaker.rb +2 -2
- data/lib/throttle_machines/hedged_request.rb +2 -2
- data/lib/throttle_machines/instrumentation.rb +19 -19
- data/lib/throttle_machines/middleware.rb +1 -2
- data/lib/throttle_machines/native_speedup.rb +98 -0
- data/lib/throttle_machines/rack_middleware/allow2_ban.rb +11 -22
- data/lib/throttle_machines/rack_middleware/ban_filter.rb +29 -0
- data/lib/throttle_machines/rack_middleware/blocklist.rb +5 -18
- data/lib/throttle_machines/rack_middleware/configuration.rb +14 -14
- data/lib/throttle_machines/rack_middleware/fail2_ban.rb +20 -29
- data/lib/throttle_machines/rack_middleware/list_filter.rb +39 -0
- data/lib/throttle_machines/rack_middleware/safelist.rb +5 -18
- data/lib/throttle_machines/rack_middleware.rb +2 -4
- data/lib/throttle_machines/storage/base.rb +2 -23
- data/lib/throttle_machines/storage/memory.rb +7 -113
- data/lib/throttle_machines/storage/null.rb +0 -21
- data/lib/throttle_machines/storage/redis/gcra.lua +1 -1
- data/lib/throttle_machines/storage/redis/increment_counter.lua +1 -1
- data/lib/throttle_machines/storage/redis/peek_gcra.lua +1 -1
- data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +1 -1
- data/lib/throttle_machines/storage/redis/token_bucket.lua +1 -1
- data/lib/throttle_machines/storage/redis.rb +5 -68
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines.rb +87 -21
- metadata +74 -15
- data/Rakefile +0 -12
- data/lib/throttle_machines/storage/redis/get_breaker_state.lua +0 -23
- data/lib/throttle_machines/storage/redis/record_breaker_failure.lua +0 -24
- data/lib/throttle_machines/storage/redis/record_breaker_success.lua +0 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6450b4499ed28a6520bdc5d51a2c5bc7fcc55ba8e1f1e327def0ca9ab5c80d62
|
|
4
|
+
data.tar.gz: af1addd1f524c9f5663f3f241ce15bfb1aa9a6b5365463f3c9d453a9e03f0b05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 564076859ef7219e48060400a26295cffc830a74fbf82d757766753e19d2020fce390ce8b91b9daf996147689dd47fec0911037a34a0208cd160ec3d6009ac70
|
|
7
|
+
data.tar.gz: 39590d460f59b66a700487146ea3309aa8df4d9f3b31e0248acb1b0748bc04b3a26f9a55f52f18b4beb3fca7618a8142092b4d434f45a4ce833a265d8e2c5fdb
|
data/README.md
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'concurrent-ruby'
|
|
4
|
-
|
|
5
3
|
module ThrottleMachines
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# Like quantum entanglement communications - allows multiple spacecraft
|
|
9
|
-
# to communicate simultaneously without interference, each in their own
|
|
10
|
-
# quantum state (fiber).
|
|
4
|
+
# Backward-compatible async-aware limiter class.
|
|
11
5
|
#
|
|
12
6
|
# Example:
|
|
13
|
-
# limiter = ThrottleMachines::AsyncLimiter.new("
|
|
7
|
+
# limiter = ThrottleMachines::AsyncLimiter.new("api",
|
|
14
8
|
# limit: 100,
|
|
15
9
|
# period: 60,
|
|
16
10
|
# algorithm: :gcra
|
|
@@ -18,117 +12,13 @@ module ThrottleMachines
|
|
|
18
12
|
#
|
|
19
13
|
# Async do
|
|
20
14
|
# if limiter.allowed_async?
|
|
21
|
-
# #
|
|
15
|
+
# # Currently allowed without consuming capacity
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# limiter.throttle_async(max_wait: 5) do
|
|
19
|
+
# # Consumes capacity through the same atomic path as throttle!
|
|
22
20
|
# end
|
|
23
21
|
# end
|
|
24
22
|
class AsyncLimiter < Limiter
|
|
25
|
-
def initialize(key, limit:, period:, algorithm: :fixed_window, storage: nil)
|
|
26
|
-
super
|
|
27
|
-
@fiber_storage = Concurrent::Map.new # Thread-safe fiber storage
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Async version of allowed? that's fiber-safe
|
|
31
|
-
def allowed_async?
|
|
32
|
-
allowed = if defined?(Async::Task) && Async::Task.current?
|
|
33
|
-
# In async context, use fiber-local checking
|
|
34
|
-
fiber_allowed?
|
|
35
|
-
else
|
|
36
|
-
# Fall back to regular synchronous check
|
|
37
|
-
allowed?
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Don't double-instrument when calling parent allowed?
|
|
41
|
-
return allowed unless defined?(Async::Task) && Async::Task.current?
|
|
42
|
-
|
|
43
|
-
# Instrument the async check
|
|
44
|
-
Instrumentation.rate_limit_checked(self, allowed: allowed, remaining: nil)
|
|
45
|
-
allowed
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Non-blocking check with async support
|
|
49
|
-
def check_async
|
|
50
|
-
if allowed_async?
|
|
51
|
-
yield if block_given?
|
|
52
|
-
true
|
|
53
|
-
else
|
|
54
|
-
false
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Async throttle with automatic retry
|
|
59
|
-
def throttle_async(max_wait: nil)
|
|
60
|
-
start_time = current_time
|
|
61
|
-
|
|
62
|
-
loop do
|
|
63
|
-
if allowed_async?
|
|
64
|
-
return yield if block_given?
|
|
65
|
-
|
|
66
|
-
return true
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
wait_time = retry_after
|
|
70
|
-
|
|
71
|
-
# Check if we've exceeded max wait time
|
|
72
|
-
if max_wait && (current_time - start_time + wait_time) > max_wait
|
|
73
|
-
raise ThrottleError, 'Maximum wait time exceeded'
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Non-blocking sleep in async context
|
|
77
|
-
if defined?(Async::Task) && Async::Task.current?
|
|
78
|
-
Async::Task.current.sleep(wait_time)
|
|
79
|
-
else
|
|
80
|
-
sleep(wait_time)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Get current fiber's state
|
|
86
|
-
def fiber_state
|
|
87
|
-
fiber_id = Fiber.current.object_id
|
|
88
|
-
@fiber_storage.compute_if_absent(fiber_id) do
|
|
89
|
-
{
|
|
90
|
-
last_check: 0,
|
|
91
|
-
tokens: @limit.to_f
|
|
92
|
-
}
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Clean up fiber storage periodically
|
|
97
|
-
def cleanup_fiber_storage
|
|
98
|
-
current_fibers = ObjectSpace.each_object(Fiber).map(&:object_id)
|
|
99
|
-
@fiber_storage.each_key do |fiber_id|
|
|
100
|
-
@fiber_storage.delete(fiber_id) unless current_fibers.include?(fiber_id)
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private
|
|
105
|
-
|
|
106
|
-
def fiber_allowed?
|
|
107
|
-
state = fiber_state
|
|
108
|
-
now = current_time
|
|
109
|
-
|
|
110
|
-
case @algorithm
|
|
111
|
-
when :token_bucket
|
|
112
|
-
# Refill tokens based on time passed
|
|
113
|
-
time_passed = now - state[:last_check]
|
|
114
|
-
tokens_to_add = time_passed * (@limit.to_f / @period)
|
|
115
|
-
state[:tokens] = [state[:tokens] + tokens_to_add, @limit.to_f].min
|
|
116
|
-
state[:last_check] = now
|
|
117
|
-
|
|
118
|
-
if state[:tokens] >= 1
|
|
119
|
-
state[:tokens] -= 1
|
|
120
|
-
true
|
|
121
|
-
else
|
|
122
|
-
false
|
|
123
|
-
end
|
|
124
|
-
else
|
|
125
|
-
# Delegate to parent for other algorithms
|
|
126
|
-
allowed?
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def current_time
|
|
131
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
132
|
-
end
|
|
133
23
|
end
|
|
134
24
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Async/Fiber support for ThrottleMachines.
|
|
4
|
+
#
|
|
5
|
+
# The async gem is optional. When an Async task is active, waits yield back to
|
|
6
|
+
# the scheduler; otherwise the limiter falls back to normal sleep.
|
|
7
|
+
|
|
8
|
+
module ThrottleMachines
|
|
9
|
+
# Fiber-aware rate limiter helpers.
|
|
10
|
+
module AsyncSupport
|
|
11
|
+
def allowed_async?
|
|
12
|
+
allow?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
alias allow_async? allowed_async?
|
|
16
|
+
|
|
17
|
+
def check_async
|
|
18
|
+
return false unless allowed_async?
|
|
19
|
+
|
|
20
|
+
yield if block_given?
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def throttle_async(max_wait: nil, &block)
|
|
25
|
+
deadline = max_wait.nil? ? nil : ThrottleMachines.monotonic_time + max_wait.to_f
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
begin
|
|
29
|
+
throttle!
|
|
30
|
+
rescue ThrottledError => e
|
|
31
|
+
wait_time = [e.retry_after.to_f, 0.0].max
|
|
32
|
+
|
|
33
|
+
if deadline
|
|
34
|
+
remaining_wait = deadline - ThrottleMachines.monotonic_time
|
|
35
|
+
raise if remaining_wait <= 0 || wait_time > remaining_wait
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sleep_for(wait_time)
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
return block.call if block
|
|
43
|
+
|
|
44
|
+
return true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Use Async's fiber-aware sleep when in an async context.
|
|
51
|
+
def sleep_for(duration)
|
|
52
|
+
if (task = current_async_task)
|
|
53
|
+
task.sleep(duration)
|
|
54
|
+
else
|
|
55
|
+
sleep(duration)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def current_async_task
|
|
60
|
+
return nil unless defined?(::Async::Task)
|
|
61
|
+
|
|
62
|
+
if ::Async::Task.respond_to?(:current?)
|
|
63
|
+
::Async::Task.current?
|
|
64
|
+
else
|
|
65
|
+
::Async::Task.current
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
ThrottleMachines::Limiter.include(ThrottleMachines::AsyncSupport)
|
|
74
|
+
|
|
75
|
+
warn "[ThrottleMachines] Async support loaded" if ENV['DEBUG_MATRYOSHKA']
|
|
@@ -21,10 +21,10 @@ module ThrottleMachines
|
|
|
21
21
|
set_rate_limit_headers(limiter)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def with_throttle(key = nil, limit:, period:, &)
|
|
24
|
+
def with_throttle(key = nil, limit:, period:, &block)
|
|
25
25
|
key ||= default_throttle_key
|
|
26
26
|
|
|
27
|
-
ThrottleMachines.limit(key, limit: limit, period: period, &)
|
|
27
|
+
ThrottleMachines.limit(key, limit: limit, period: period, &block)
|
|
28
28
|
rescue ThrottledError => e
|
|
29
29
|
render_rate_limited(e.limiter)
|
|
30
30
|
end
|
|
@@ -13,11 +13,6 @@ module ThrottleMachines
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
ThrottleMachines.configure do |config|
|
|
18
|
-
# Use Rails.cache , user can override
|
|
19
|
-
config.store = Rails.cache
|
|
20
|
-
end
|
|
21
|
-
end
|
|
16
|
+
# No default Rails.cache binding; storage is managed by ThrottleMachines
|
|
22
17
|
end
|
|
23
18
|
end
|
|
@@ -15,7 +15,7 @@ module ThrottleMachines
|
|
|
15
15
|
@backend ||= ActiveSupport::Notifications
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def instrument(event_name, payload = {}, &)
|
|
18
|
+
def instrument(event_name, payload = {}, &block)
|
|
19
19
|
if !enabled || backend.nil?
|
|
20
20
|
return yield if block_given?
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ module ThrottleMachines
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
full_event_name = "#{event_name}.throttle_machines"
|
|
26
|
-
backend.instrument(full_event_name, payload, &)
|
|
26
|
+
backend.instrument(full_event_name, payload, &block)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Convenience methods for common events
|
|
@@ -66,9 +66,9 @@ module ThrottleMachines
|
|
|
66
66
|
# Circuit breaker events
|
|
67
67
|
def circuit_opened(breaker, failure_count:)
|
|
68
68
|
payload = {
|
|
69
|
-
key: breaker.
|
|
70
|
-
failure_threshold: breaker.failure_threshold,
|
|
71
|
-
timeout: breaker.
|
|
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
72
|
failure_count: failure_count
|
|
73
73
|
}
|
|
74
74
|
instrument('circuit_breaker.opened', payload)
|
|
@@ -76,35 +76,35 @@ module ThrottleMachines
|
|
|
76
76
|
|
|
77
77
|
def circuit_closed(breaker)
|
|
78
78
|
payload = {
|
|
79
|
-
key: breaker.
|
|
80
|
-
failure_threshold: breaker.failure_threshold,
|
|
81
|
-
timeout: breaker.
|
|
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
82
|
}
|
|
83
83
|
instrument('circuit_breaker.closed', payload)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def circuit_half_opened(breaker)
|
|
87
87
|
payload = {
|
|
88
|
-
key: breaker.
|
|
89
|
-
failure_threshold: breaker.failure_threshold,
|
|
90
|
-
timeout: breaker.
|
|
91
|
-
half_open_requests: breaker.
|
|
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
92
|
}
|
|
93
93
|
instrument('circuit_breaker.half_opened', payload)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def circuit_success(breaker)
|
|
97
97
|
payload = {
|
|
98
|
-
key: breaker.
|
|
99
|
-
state: breaker.state
|
|
98
|
+
key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
|
|
99
|
+
state: (breaker.respond_to?(:to_h) ? breaker.to_h[:state] : nil)
|
|
100
100
|
}
|
|
101
101
|
instrument('circuit_breaker.success', payload)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def circuit_failure(breaker, error: nil)
|
|
105
105
|
payload = {
|
|
106
|
-
key: breaker.
|
|
107
|
-
state: breaker.state,
|
|
106
|
+
key: (breaker.respond_to?(:name) ? breaker.name : breaker.to_s),
|
|
107
|
+
state: (breaker.respond_to?(:to_h) ? breaker.to_h[:state] : nil),
|
|
108
108
|
error_class: error&.class&.name,
|
|
109
109
|
error_message: error&.message
|
|
110
110
|
}
|
|
@@ -113,9 +113,9 @@ module ThrottleMachines
|
|
|
113
113
|
|
|
114
114
|
def circuit_rejected(breaker)
|
|
115
115
|
payload = {
|
|
116
|
-
key: breaker.
|
|
117
|
-
failure_threshold: breaker.failure_threshold,
|
|
118
|
-
timeout: breaker.
|
|
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
119
|
}
|
|
120
120
|
instrument('circuit_breaker.rejected', payload)
|
|
121
121
|
end
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
class Middleware
|
|
5
|
-
def initialize(app,
|
|
5
|
+
def initialize(app, &config_block)
|
|
6
6
|
@app = app
|
|
7
|
-
@store = store || ThrottleMachines.configuration.store
|
|
8
7
|
@rules = []
|
|
9
8
|
|
|
10
9
|
instance_eval(&config_block) if config_block
|
|
@@ -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
|
|
@@ -2,18 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
class RackMiddleware
|
|
5
|
-
class Allow2Ban
|
|
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
|
-
|
|
5
|
+
class Allow2Ban < BanFilter
|
|
17
6
|
def matched_by?(request)
|
|
18
7
|
discriminator = discriminator_for(request)
|
|
19
8
|
return false unless discriminator
|
|
@@ -34,12 +23,18 @@ module ThrottleMachines
|
|
|
34
23
|
|
|
35
24
|
# Check if we've had enough successful requests
|
|
36
25
|
if success_limiter.remaining.zero?
|
|
37
|
-
# Reset the fail2ban breaker
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
40
35
|
|
|
41
36
|
# Reset our own counter
|
|
42
|
-
storage.reset_counter(success_key, @findtime)
|
|
37
|
+
ThrottleMachines.storage.reset_counter(success_key, @findtime)
|
|
43
38
|
else
|
|
44
39
|
# Increment success counter
|
|
45
40
|
begin
|
|
@@ -51,12 +46,6 @@ module ThrottleMachines
|
|
|
51
46
|
|
|
52
47
|
false # Allow2Ban never blocks directly
|
|
53
48
|
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def discriminator_for(request)
|
|
58
|
-
@block.call(request)
|
|
59
|
-
end
|
|
60
49
|
end
|
|
61
50
|
end
|
|
62
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
|
|
@@ -2,25 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
class RackMiddleware
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# Blocks requests matching the configured block (returns true => blocked).
|
|
6
|
+
class Blocklist < ListFilter
|
|
7
|
+
private
|
|
7
8
|
|
|
8
|
-
def
|
|
9
|
-
|
|
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'] = :blocklist
|
|
19
|
-
ThrottleMachines::RackMiddleware.instrument(request)
|
|
20
|
-
true
|
|
21
|
-
else
|
|
22
|
-
false
|
|
23
|
-
end
|
|
9
|
+
def match_type
|
|
10
|
+
:blocklist
|
|
24
11
|
end
|
|
25
12
|
end
|
|
26
13
|
end
|
|
@@ -32,27 +32,27 @@ module ThrottleMachines
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# DSL Methods
|
|
35
|
-
def throttle(name, options = {}, &)
|
|
36
|
-
@throttles[name] = Throttle.new(name, options, &)
|
|
35
|
+
def throttle(name, options = {}, &block)
|
|
36
|
+
@throttles[name] = Throttle.new(name, options, &block)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def track(name, options = {}, &)
|
|
40
|
-
@tracks[name] = Track.new(name, options, &)
|
|
39
|
+
def track(name, options = {}, &block)
|
|
40
|
+
@tracks[name] = Track.new(name, options, &block)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def safelist(name = nil, &)
|
|
43
|
+
def safelist(name = nil, &block)
|
|
44
44
|
if name
|
|
45
|
-
@safelists[name] = Safelist.new(name, &)
|
|
45
|
+
@safelists[name] = Safelist.new(name, &block)
|
|
46
46
|
else
|
|
47
|
-
@anonymous_safelists << Safelist.new(nil, &)
|
|
47
|
+
@anonymous_safelists << Safelist.new(nil, &block)
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def blocklist(name = nil, &)
|
|
51
|
+
def blocklist(name = nil, &block)
|
|
52
52
|
if name
|
|
53
|
-
@blocklists[name] = Blocklist.new(name, &)
|
|
53
|
+
@blocklists[name] = Blocklist.new(name, &block)
|
|
54
54
|
else
|
|
55
|
-
@anonymous_blocklists << Blocklist.new(nil, &)
|
|
55
|
+
@anonymous_blocklists << Blocklist.new(nil, &block)
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -64,12 +64,12 @@ module ThrottleMachines
|
|
|
64
64
|
@anonymous_blocklists << Blocklist.new(nil) { |req| req.ip == ip_address }
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
def fail2ban(name, options = {}, &)
|
|
68
|
-
@fail2bans[name] = Fail2Ban.new(name, options, &)
|
|
67
|
+
def fail2ban(name, options = {}, &block)
|
|
68
|
+
@fail2bans[name] = Fail2Ban.new(name, options, &block)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
def allow2ban(name, options = {}, &)
|
|
72
|
-
@allow2bans[name] = Allow2Ban.new(name, options, &)
|
|
71
|
+
def allow2ban(name, options = {}, &block)
|
|
72
|
+
@allow2bans[name] = Allow2Ban.new(name, options, &block)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# Check methods
|