throttle_machines 0.1.2 → 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/native_speedup.rb +98 -0
- data/lib/throttle_machines/rack_middleware/allow2_ban.rb +1 -18
- 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/fail2_ban.rb +1 -18
- 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 +0 -2
- 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/version.rb +1 -1
- data/lib/throttle_machines.rb +57 -4
- metadata +72 -10
- data/Rakefile +0 -12
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']
|
|
@@ -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
|
|
@@ -57,12 +46,6 @@ module ThrottleMachines
|
|
|
57
46
|
|
|
58
47
|
false # Allow2Ban never blocks directly
|
|
59
48
|
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def discriminator_for(request)
|
|
64
|
-
@block.call(request)
|
|
65
|
-
end
|
|
66
49
|
end
|
|
67
50
|
end
|
|
68
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
|
|
@@ -2,18 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module ThrottleMachines
|
|
4
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
|
-
|
|
5
|
+
class Fail2Ban < BanFilter
|
|
17
6
|
def banned?(request)
|
|
18
7
|
discriminator = discriminator_for(request)
|
|
19
8
|
return false unless discriminator
|
|
@@ -84,12 +73,6 @@ module ThrottleMachines
|
|
|
84
73
|
# Expected - this records the failure
|
|
85
74
|
end
|
|
86
75
|
end
|
|
87
|
-
|
|
88
|
-
private
|
|
89
|
-
|
|
90
|
-
def discriminator_for(request)
|
|
91
|
-
@block.call(request)
|
|
92
|
-
end
|
|
93
76
|
end
|
|
94
77
|
end
|
|
95
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
|
|
@@ -2,25 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
class RackMiddleware
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# Allows (short-circuits) requests matching the configured block.
|
|
6
|
+
class Safelist < 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'] = :safelist
|
|
19
|
-
ThrottleMachines::RackMiddleware.instrument(request)
|
|
20
|
-
true
|
|
21
|
-
else
|
|
22
|
-
false
|
|
23
|
-
end
|
|
9
|
+
def match_type
|
|
10
|
+
:safelist
|
|
24
11
|
end
|
|
25
12
|
end
|
|
26
13
|
end
|
|
@@ -35,11 +35,9 @@ module ThrottleMachines
|
|
|
35
35
|
@configuration.instance_eval(&block) if block
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# rubocop:disable Rails/Delegate -- Ruby 3.4 compatibility issue with delegate
|
|
39
38
|
def reset!
|
|
40
39
|
ThrottleMachines.reset!
|
|
41
40
|
end
|
|
42
|
-
# rubocop:enable Rails/Delegate
|
|
43
41
|
|
|
44
42
|
def clear!
|
|
45
43
|
@configuration = Configuration.new
|
data/lib/throttle_machines.rb
CHANGED
|
@@ -17,17 +17,32 @@ loader.setup
|
|
|
17
17
|
module ThrottleMachines
|
|
18
18
|
class Configuration
|
|
19
19
|
attr_accessor :default_limit, :default_period, :default_storage, :clock,
|
|
20
|
-
:instrumentation_enabled, :instrumentation_backend,
|
|
20
|
+
:default_algorithm, :instrumentation_enabled, :instrumentation_backend,
|
|
21
|
+
:_storage_instance
|
|
21
22
|
|
|
22
23
|
def initialize
|
|
23
24
|
@default_limit = 100
|
|
24
25
|
@default_period = 60 # 1 minute
|
|
25
26
|
@default_storage = :memory
|
|
27
|
+
@default_algorithm = :fixed_window
|
|
26
28
|
@clock = nil
|
|
27
29
|
@instrumentation_enabled = true
|
|
28
30
|
@instrumentation_backend = nil
|
|
31
|
+
@fiber_safe = false
|
|
29
32
|
@_storage_instance = nil
|
|
30
33
|
end
|
|
34
|
+
|
|
35
|
+
def fiber_safe
|
|
36
|
+
@fiber_safe
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def fiber_safe=(value)
|
|
40
|
+
@fiber_safe = !!value
|
|
41
|
+
ThrottleMachines.load_async_runtime! if @fiber_safe && ThrottleMachines.respond_to?(:load_async_runtime!)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
alias async_enabled fiber_safe
|
|
45
|
+
alias async_enabled= fiber_safe=
|
|
31
46
|
end
|
|
32
47
|
|
|
33
48
|
@config = Configuration.new
|
|
@@ -46,6 +61,7 @@ module ThrottleMachines
|
|
|
46
61
|
# Apply instrumentation settings
|
|
47
62
|
Instrumentation.enabled = config.instrumentation_enabled
|
|
48
63
|
Instrumentation.backend = config.instrumentation_backend if config.instrumentation_backend
|
|
64
|
+
load_async_runtime! if config.fiber_safe
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
def storage
|
|
@@ -75,7 +91,7 @@ module ThrottleMachines
|
|
|
75
91
|
control
|
|
76
92
|
end
|
|
77
93
|
|
|
78
|
-
def limit(key, limit:, period:, algorithm:
|
|
94
|
+
def limit(key, limit:, period:, algorithm: nil, &block)
|
|
79
95
|
limiter = limiter(key, limit: limit, period: period, algorithm: algorithm)
|
|
80
96
|
limiter.throttle!(&block)
|
|
81
97
|
end
|
|
@@ -99,8 +115,28 @@ module ThrottleMachines
|
|
|
99
115
|
ChronoMachines.retry(policy_options, &block)
|
|
100
116
|
end
|
|
101
117
|
|
|
102
|
-
def limiter(key, limit:, period:, algorithm:
|
|
103
|
-
Limiter.new(
|
|
118
|
+
def limiter(key, limit:, period:, algorithm: nil)
|
|
119
|
+
Limiter.new(
|
|
120
|
+
key,
|
|
121
|
+
limit: limit,
|
|
122
|
+
period: period,
|
|
123
|
+
algorithm: algorithm || config.default_algorithm,
|
|
124
|
+
storage: storage
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def async_breaker(...)
|
|
129
|
+
ThrottleMachines::AsyncBreaker.new(...)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def load_async_runtime!
|
|
133
|
+
require 'async'
|
|
134
|
+
require 'async/task'
|
|
135
|
+
true
|
|
136
|
+
rescue LoadError => e
|
|
137
|
+
raise unless e.message.include?('async')
|
|
138
|
+
|
|
139
|
+
raise LoadError, "The 'async' gem is required for fiber_safe mode. Add `gem 'async'` to your Gemfile."
|
|
104
140
|
end
|
|
105
141
|
|
|
106
142
|
private
|
|
@@ -145,3 +181,20 @@ module ThrottleMachines
|
|
|
145
181
|
# Back-compat wrapper: use BreakerMachines as the circuit implementation
|
|
146
182
|
Breaker = BreakerMachines::Circuit
|
|
147
183
|
end
|
|
184
|
+
|
|
185
|
+
# Optional speedups (order matters: most specific first)
|
|
186
|
+
# These are loaded after Zeitwerk setup to allow graceful fallback.
|
|
187
|
+
|
|
188
|
+
# Native Rust extension for performance-critical algorithms
|
|
189
|
+
begin
|
|
190
|
+
require_relative "throttle_machines/native_speedup"
|
|
191
|
+
rescue LoadError
|
|
192
|
+
# Native extension not available, pure Ruby will be used
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Async/Fiber support when Async gem is loaded
|
|
196
|
+
begin
|
|
197
|
+
require_relative "throttle_machines/async_support"
|
|
198
|
+
rescue LoadError
|
|
199
|
+
# Async gem not available
|
|
200
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: throttle_machines
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -71,28 +71,84 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '0.
|
|
74
|
+
version: '0.19'
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: '0.
|
|
81
|
+
version: '0.19'
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: chrono_machines
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
85
85
|
requirements:
|
|
86
|
-
- - "
|
|
86
|
+
- - "~>"
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version: '0.
|
|
88
|
+
version: '0.9'
|
|
89
89
|
type: :runtime
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
|
-
- - "
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.9'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: minitest
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '5.16'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '5.16'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: rb_sys
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0.9'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0.9'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: rake
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
94
129
|
- !ruby/object:Gem::Version
|
|
95
|
-
version: '0
|
|
130
|
+
version: '13.0'
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '13.0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rake-compiler
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '1.3'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '1.3'
|
|
96
152
|
description: ThrottleMachines provides ultra-thin, elegant rate limiting with temporal
|
|
97
153
|
precision for distributed systems.
|
|
98
154
|
email:
|
|
@@ -103,9 +159,10 @@ extra_rdoc_files: []
|
|
|
103
159
|
files:
|
|
104
160
|
- LICENSE
|
|
105
161
|
- README.md
|
|
106
|
-
- Rakefile
|
|
107
162
|
- lib/throttle_machines.rb
|
|
163
|
+
- lib/throttle_machines/async_breaker.rb
|
|
108
164
|
- lib/throttle_machines/async_limiter.rb
|
|
165
|
+
- lib/throttle_machines/async_support.rb
|
|
109
166
|
- lib/throttle_machines/control.rb
|
|
110
167
|
- lib/throttle_machines/controller_helpers.rb
|
|
111
168
|
- lib/throttle_machines/dependency_error.rb
|
|
@@ -115,11 +172,14 @@ files:
|
|
|
115
172
|
- lib/throttle_machines/instrumentation.rb
|
|
116
173
|
- lib/throttle_machines/limiter.rb
|
|
117
174
|
- lib/throttle_machines/middleware.rb
|
|
175
|
+
- lib/throttle_machines/native_speedup.rb
|
|
118
176
|
- lib/throttle_machines/rack_middleware.rb
|
|
119
177
|
- lib/throttle_machines/rack_middleware/allow2_ban.rb
|
|
178
|
+
- lib/throttle_machines/rack_middleware/ban_filter.rb
|
|
120
179
|
- lib/throttle_machines/rack_middleware/blocklist.rb
|
|
121
180
|
- lib/throttle_machines/rack_middleware/configuration.rb
|
|
122
181
|
- lib/throttle_machines/rack_middleware/fail2_ban.rb
|
|
182
|
+
- lib/throttle_machines/rack_middleware/list_filter.rb
|
|
123
183
|
- lib/throttle_machines/rack_middleware/request.rb
|
|
124
184
|
- lib/throttle_machines/rack_middleware/safelist.rb
|
|
125
185
|
- lib/throttle_machines/rack_middleware/throttle.rb
|
|
@@ -143,6 +203,8 @@ metadata:
|
|
|
143
203
|
source_code_uri: https://github.com/seuros/throttle_machines
|
|
144
204
|
changelog_uri: https://github.com/seuros/throttle_machines/blob/main/CHANGELOG.md
|
|
145
205
|
rubygems_mfa_required: 'true'
|
|
206
|
+
cargo_crate_name: throttle_machines_native
|
|
207
|
+
cargo_manifest_path: ext/throttle_machines_native/ffi/Cargo.toml
|
|
146
208
|
rdoc_options: []
|
|
147
209
|
require_paths:
|
|
148
210
|
- lib
|
|
@@ -150,14 +212,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
150
212
|
requirements:
|
|
151
213
|
- - ">="
|
|
152
214
|
- !ruby/object:Gem::Version
|
|
153
|
-
version:
|
|
215
|
+
version: 4.0.0
|
|
154
216
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
155
217
|
requirements:
|
|
156
218
|
- - ">="
|
|
157
219
|
- !ruby/object:Gem::Version
|
|
158
220
|
version: '0'
|
|
159
221
|
requirements: []
|
|
160
|
-
rubygems_version:
|
|
222
|
+
rubygems_version: 4.0.20
|
|
161
223
|
specification_version: 4
|
|
162
224
|
summary: Advanced Rate limiting for Ruby applications
|
|
163
225
|
test_files: []
|
data/Rakefile
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'bundler/gem_tasks'
|
|
4
|
-
require 'rake/testtask'
|
|
5
|
-
|
|
6
|
-
Rake::TestTask.new(:test) do |t|
|
|
7
|
-
t.libs << 'test'
|
|
8
|
-
t.test_files = FileList['test/**/*_test.rb'].exclude('test/dummy/**/*')
|
|
9
|
-
t.verbose = false
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
task default: :test
|