throttle_machines 0.0.0 → 0.1.1
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/LICENSE +20 -0
- data/README.md +187 -13
- data/Rakefile +12 -0
- data/lib/throttle_machines/async_limiter.rb +134 -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 +23 -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 +90 -0
- data/lib/throttle_machines/rack_middleware/allow2_ban.rb +62 -0
- data/lib/throttle_machines/rack_middleware/blocklist.rb +27 -0
- data/lib/throttle_machines/rack_middleware/configuration.rb +103 -0
- data/lib/throttle_machines/rack_middleware/fail2_ban.rb +87 -0
- data/lib/throttle_machines/rack_middleware/request.rb +12 -0
- data/lib/throttle_machines/rack_middleware/safelist.rb +27 -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 +89 -0
- data/lib/throttle_machines/storage/base.rb +93 -0
- data/lib/throttle_machines/storage/memory.rb +373 -0
- data/lib/throttle_machines/storage/null.rb +88 -0
- data/lib/throttle_machines/storage/redis/gcra.lua +22 -0
- data/lib/throttle_machines/storage/redis/get_breaker_state.lua +23 -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/record_breaker_failure.lua +24 -0
- data/lib/throttle_machines/storage/redis/record_breaker_success.lua +16 -0
- data/lib/throttle_machines/storage/redis/token_bucket.lua +23 -0
- data/lib/throttle_machines/storage/redis.rb +294 -0
- data/lib/throttle_machines/throttled_error.rb +14 -0
- data/lib/throttle_machines/version.rb +5 -0
- data/lib/throttle_machines.rb +130 -5
- metadata +113 -9
- data/LICENSE.txt +0 -21
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ipaddr'
|
4
|
+
|
5
|
+
module ThrottleMachines
|
6
|
+
class RackMiddleware
|
7
|
+
class Configuration
|
8
|
+
DEFAULT_BLOCKLISTED_RESPONDER = ->(_req) { [403, { 'content-type' => 'text/plain' }, ["Forbidden\n"]] }
|
9
|
+
|
10
|
+
DEFAULT_THROTTLED_RESPONDER = lambda do |req|
|
11
|
+
match_data = req.env['rack.attack.match_data']
|
12
|
+
retry_after = match_data[:retry_after] || 60
|
13
|
+
|
14
|
+
[429, { 'content-type' => 'text/plain', 'retry-after' => retry_after.to_s }, ["Retry later\n"]]
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :throttles, :tracks, :safelists, :blocklists
|
18
|
+
attr_accessor :throttled_responder, :blocklisted_responder
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@throttles = {}
|
22
|
+
@tracks = {}
|
23
|
+
@safelists = {}
|
24
|
+
@blocklists = {}
|
25
|
+
@anonymous_safelists = []
|
26
|
+
@anonymous_blocklists = []
|
27
|
+
@fail2bans = {}
|
28
|
+
@allow2bans = {}
|
29
|
+
|
30
|
+
@throttled_responder = DEFAULT_THROTTLED_RESPONDER
|
31
|
+
@blocklisted_responder = DEFAULT_BLOCKLISTED_RESPONDER
|
32
|
+
end
|
33
|
+
|
34
|
+
# DSL Methods
|
35
|
+
def throttle(name, options = {}, &)
|
36
|
+
@throttles[name] = Throttle.new(name, options, &)
|
37
|
+
end
|
38
|
+
|
39
|
+
def track(name, options = {}, &)
|
40
|
+
@tracks[name] = Track.new(name, options, &)
|
41
|
+
end
|
42
|
+
|
43
|
+
def safelist(name = nil, &)
|
44
|
+
if name
|
45
|
+
@safelists[name] = Safelist.new(name, &)
|
46
|
+
else
|
47
|
+
@anonymous_safelists << Safelist.new(nil, &)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def blocklist(name = nil, &)
|
52
|
+
if name
|
53
|
+
@blocklists[name] = Blocklist.new(name, &)
|
54
|
+
else
|
55
|
+
@anonymous_blocklists << Blocklist.new(nil, &)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def safelist_ip(ip_address)
|
60
|
+
@anonymous_safelists << Safelist.new(nil) { |req| req.ip == ip_address }
|
61
|
+
end
|
62
|
+
|
63
|
+
def blocklist_ip(ip_address)
|
64
|
+
@anonymous_blocklists << Blocklist.new(nil) { |req| req.ip == ip_address }
|
65
|
+
end
|
66
|
+
|
67
|
+
def fail2ban(name, options = {}, &)
|
68
|
+
@fail2bans[name] = Fail2Ban.new(name, options, &)
|
69
|
+
end
|
70
|
+
|
71
|
+
def allow2ban(name, options = {}, &)
|
72
|
+
@allow2bans[name] = Allow2Ban.new(name, options, &)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Check methods
|
76
|
+
def safelisted?(request)
|
77
|
+
@anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
78
|
+
@safelists.values.any? { |safelist| safelist.matched_by?(request) }
|
79
|
+
end
|
80
|
+
|
81
|
+
def blocklisted?(request)
|
82
|
+
# Check explicit blocklists
|
83
|
+
return true if @anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) }
|
84
|
+
return true if @blocklists.values.any? { |blocklist| blocklist.matched_by?(request) }
|
85
|
+
|
86
|
+
# Check fail2bans
|
87
|
+
@fail2bans.values.any? { |fail2ban| fail2ban.banned?(request) }
|
88
|
+
end
|
89
|
+
|
90
|
+
def throttled?(request)
|
91
|
+
# Process allow2bans first (they can reset fail2ban counters)
|
92
|
+
@allow2bans.each_value { |allow2ban| allow2ban.matched_by?(request) }
|
93
|
+
|
94
|
+
# Check throttles
|
95
|
+
@throttles.values.any? { |throttle| throttle.matched_by?(request) }
|
96
|
+
end
|
97
|
+
|
98
|
+
def tracked?(request)
|
99
|
+
@tracks.each_value { |track| track.matched_by?(request) }
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThrottleMachines
|
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
|
+
|
17
|
+
def banned?(request)
|
18
|
+
discriminator = discriminator_for(request)
|
19
|
+
return false unless discriminator
|
20
|
+
|
21
|
+
key = "fail2ban:#{@name}:#{discriminator}"
|
22
|
+
|
23
|
+
# Use circuit breaker to track failures
|
24
|
+
breaker = ThrottleMachines::Breaker.new(
|
25
|
+
key,
|
26
|
+
failure_threshold: @maxretry,
|
27
|
+
timeout: @bantime,
|
28
|
+
storage: ThrottleMachines.storage
|
29
|
+
)
|
30
|
+
|
31
|
+
# Check if circuit is open (banned)
|
32
|
+
if breaker.open?
|
33
|
+
# Get breaker state for instrumentation
|
34
|
+
state = breaker.to_h
|
35
|
+
|
36
|
+
request.env['rack.attack.matched'] = @name
|
37
|
+
request.env['rack.attack.match_type'] = :fail2ban
|
38
|
+
request.env['rack.attack.match_discriminator'] = discriminator
|
39
|
+
request.env['rack.attack.match_data'] = {
|
40
|
+
discriminator: discriminator,
|
41
|
+
maxretry: @maxretry,
|
42
|
+
findtime: @findtime,
|
43
|
+
bantime: @bantime,
|
44
|
+
failures: state[:failure_count],
|
45
|
+
time_until_unban: state[:time_until_retry]
|
46
|
+
}
|
47
|
+
|
48
|
+
ThrottleMachines::RackMiddleware.instrument(request)
|
49
|
+
true
|
50
|
+
else
|
51
|
+
false
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def count(request)
|
56
|
+
discriminator = discriminator_for(request)
|
57
|
+
return unless discriminator
|
58
|
+
|
59
|
+
key = "fail2ban:#{@name}:#{discriminator}"
|
60
|
+
|
61
|
+
# Use the breaker to record a failure if block returns true
|
62
|
+
return unless yield
|
63
|
+
|
64
|
+
breaker = ThrottleMachines::Breaker.new(
|
65
|
+
key,
|
66
|
+
failure_threshold: @maxretry,
|
67
|
+
timeout: @bantime,
|
68
|
+
storage: ThrottleMachines.storage
|
69
|
+
)
|
70
|
+
|
71
|
+
# Record failure by trying to call through the breaker
|
72
|
+
# and letting it fail
|
73
|
+
begin
|
74
|
+
breaker.call { raise 'Fail2Ban failure' }
|
75
|
+
rescue StandardError
|
76
|
+
# Expected - this records the failure
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def discriminator_for(request)
|
83
|
+
@block.call(request)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThrottleMachines
|
4
|
+
class RackMiddleware
|
5
|
+
class Safelist
|
6
|
+
attr_reader :name, :block
|
7
|
+
|
8
|
+
def initialize(name, &block)
|
9
|
+
@name = name
|
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
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThrottleMachines
|
4
|
+
class RackMiddleware
|
5
|
+
class Throttle
|
6
|
+
attr_reader :name, :limit, :period, :block, :algorithm
|
7
|
+
|
8
|
+
def initialize(name, options, &block)
|
9
|
+
@name = name
|
10
|
+
@block = block
|
11
|
+
|
12
|
+
raise ArgumentError, 'Must pass :limit option' unless options[:limit]
|
13
|
+
raise ArgumentError, 'Must pass :period option' unless options[:period]
|
14
|
+
|
15
|
+
@limit = options[:limit]
|
16
|
+
@period = options[:period].to_i
|
17
|
+
@algorithm = options[:algorithm] || :fixed_window
|
18
|
+
end
|
19
|
+
|
20
|
+
def matched_by?(request)
|
21
|
+
discriminator = discriminator_for(request)
|
22
|
+
|
23
|
+
return false unless discriminator
|
24
|
+
|
25
|
+
key = "#{@name}:#{discriminator}"
|
26
|
+
current_limit = limit_for(request)
|
27
|
+
current_period = period_for(request)
|
28
|
+
|
29
|
+
# Use ThrottleMachines limiter
|
30
|
+
limiter = ThrottleMachines.limiter(
|
31
|
+
key,
|
32
|
+
limit: current_limit,
|
33
|
+
period: current_period,
|
34
|
+
algorithm: @algorithm
|
35
|
+
)
|
36
|
+
|
37
|
+
# Try to consume the request atomically
|
38
|
+
throttled = false
|
39
|
+
|
40
|
+
begin
|
41
|
+
# This will either succeed and consume a request, or raise ThrottledError
|
42
|
+
limiter.throttle!
|
43
|
+
|
44
|
+
# If we get here, request was allowed
|
45
|
+
rescue ThrottledError
|
46
|
+
# Request was throttled
|
47
|
+
throttled = true
|
48
|
+
end
|
49
|
+
|
50
|
+
# Get current state for instrumentation
|
51
|
+
data = {
|
52
|
+
discriminator: discriminator,
|
53
|
+
count: current_limit - limiter.remaining,
|
54
|
+
period: current_period,
|
55
|
+
limit: current_limit,
|
56
|
+
retry_after: limiter.retry_after
|
57
|
+
}
|
58
|
+
|
59
|
+
annotate_request_with_throttle_data(request, data)
|
60
|
+
|
61
|
+
if throttled
|
62
|
+
annotate_request_with_matched_data(request, data)
|
63
|
+
ThrottleMachines::RackMiddleware.instrument(request)
|
64
|
+
end
|
65
|
+
|
66
|
+
throttled
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def discriminator_for(request)
|
72
|
+
@block.call(request)
|
73
|
+
end
|
74
|
+
|
75
|
+
def limit_for(request)
|
76
|
+
@limit.respond_to?(:call) ? @limit.call(request) : @limit
|
77
|
+
end
|
78
|
+
|
79
|
+
def period_for(request)
|
80
|
+
@period.respond_to?(:call) ? @period.call(request) : @period
|
81
|
+
end
|
82
|
+
|
83
|
+
def annotate_request_with_throttle_data(request, data)
|
84
|
+
(request.env['rack.attack.throttle_data'] ||= {})[@name] = data
|
85
|
+
end
|
86
|
+
|
87
|
+
def annotate_request_with_matched_data(request, data)
|
88
|
+
request.env['rack.attack.matched'] = @name
|
89
|
+
request.env['rack.attack.match_discriminator'] = data[:discriminator]
|
90
|
+
request.env['rack.attack.match_type'] = :throttle
|
91
|
+
request.env['rack.attack.match_data'] = data
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThrottleMachines
|
4
|
+
class RackMiddleware
|
5
|
+
class Track
|
6
|
+
attr_reader :name, :block, :limit, :period
|
7
|
+
|
8
|
+
def initialize(name, options = {}, &block)
|
9
|
+
@name = name
|
10
|
+
@block = block
|
11
|
+
@limit = options[:limit]
|
12
|
+
@period = options[:period]
|
13
|
+
end
|
14
|
+
|
15
|
+
def matched_by?(request)
|
16
|
+
discriminator = @block.call(request)
|
17
|
+
return false unless discriminator
|
18
|
+
|
19
|
+
# Track is just instrumentation without blocking
|
20
|
+
data = {
|
21
|
+
discriminator: discriminator
|
22
|
+
}
|
23
|
+
|
24
|
+
# If limit and period are provided, track the count
|
25
|
+
if @limit && @period
|
26
|
+
key = "track:#{@name}:#{discriminator}"
|
27
|
+
limiter = ThrottleMachines.limiter(
|
28
|
+
key,
|
29
|
+
limit: @limit,
|
30
|
+
period: @period,
|
31
|
+
algorithm: :fixed_window
|
32
|
+
)
|
33
|
+
|
34
|
+
# Just check, don't consume
|
35
|
+
data[:count] = @limit - limiter.remaining
|
36
|
+
data[:limit] = @limit
|
37
|
+
data[:period] = @period
|
38
|
+
end
|
39
|
+
|
40
|
+
request.env['rack.attack.matched'] = @name
|
41
|
+
request.env['rack.attack.match_type'] = :track
|
42
|
+
request.env['rack.attack.match_discriminator'] = discriminator
|
43
|
+
request.env['rack.attack.match_data'] = data
|
44
|
+
|
45
|
+
ThrottleMachines::RackMiddleware.instrument(request)
|
46
|
+
|
47
|
+
false # Track never blocks
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack'
|
4
|
+
require 'forwardable'
|
5
|
+
|
6
|
+
module ThrottleMachines
|
7
|
+
# Advanced Rack middleware for rate limiting and request filtering
|
8
|
+
class RackMiddleware
|
9
|
+
class << self
|
10
|
+
extend Forwardable
|
11
|
+
|
12
|
+
attr_accessor :enabled, :notifier
|
13
|
+
attr_reader :configuration
|
14
|
+
|
15
|
+
def_delegators :@configuration,
|
16
|
+
:throttle,
|
17
|
+
:track,
|
18
|
+
:safelist,
|
19
|
+
:blocklist,
|
20
|
+
:blocklist_ip,
|
21
|
+
:safelist_ip,
|
22
|
+
:fail2ban,
|
23
|
+
:allow2ban,
|
24
|
+
:throttled_responder,
|
25
|
+
:throttled_responder=,
|
26
|
+
:blocklisted_responder,
|
27
|
+
:blocklisted_responder=,
|
28
|
+
:throttles,
|
29
|
+
:tracks,
|
30
|
+
:safelists,
|
31
|
+
:blocklists
|
32
|
+
|
33
|
+
def configure(&)
|
34
|
+
@configuration ||= Configuration.new
|
35
|
+
@configuration.instance_eval(&) if block
|
36
|
+
end
|
37
|
+
|
38
|
+
# rubocop:disable Rails/Delegate -- Ruby 3.4 compatibility issue with delegate
|
39
|
+
def reset!
|
40
|
+
ThrottleMachines.reset!
|
41
|
+
end
|
42
|
+
# rubocop:enable Rails/Delegate
|
43
|
+
|
44
|
+
def clear!
|
45
|
+
@configuration = Configuration.new
|
46
|
+
end
|
47
|
+
|
48
|
+
# Instrument for ActiveSupport::Notifications compatibility
|
49
|
+
def instrument(request)
|
50
|
+
return unless notifier
|
51
|
+
|
52
|
+
event_type = request.env['rack.attack.match_type']
|
53
|
+
notifier.instrument("#{event_type}.throttle_machines", request: request)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Set defaults
|
58
|
+
@enabled = true
|
59
|
+
@notifier = ActiveSupport::Notifications
|
60
|
+
@configuration = Configuration.new
|
61
|
+
|
62
|
+
def initialize(app)
|
63
|
+
@app = app
|
64
|
+
end
|
65
|
+
|
66
|
+
def call(env)
|
67
|
+
return @app.call(env) if !self.class.enabled || env['rack.attack.called']
|
68
|
+
|
69
|
+
env['rack.attack.called'] = true
|
70
|
+
request = Request.new(env)
|
71
|
+
|
72
|
+
# Always use the current class-level configuration
|
73
|
+
configuration = self.class.configuration
|
74
|
+
|
75
|
+
if configuration.safelisted?(request)
|
76
|
+
@app.call(env)
|
77
|
+
elsif configuration.blocklisted?(request)
|
78
|
+
configuration.blocklisted_responder.call(request)
|
79
|
+
elsif configuration.throttled?(request)
|
80
|
+
configuration.throttled_responder.call(request)
|
81
|
+
else
|
82
|
+
configuration.tracked?(request)
|
83
|
+
@app.call(env)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# No alias - users should use the explicit name to avoid confusion
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThrottleMachines
|
4
|
+
module Storage
|
5
|
+
class Base
|
6
|
+
def initialize(options = {})
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
# Rate limiting operations
|
11
|
+
def increment_counter(key, window, amount = 1)
|
12
|
+
raise NotImplementedError
|
13
|
+
end
|
14
|
+
|
15
|
+
def get_counter(key, window)
|
16
|
+
raise NotImplementedError
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_counter_ttl(key, window)
|
20
|
+
raise NotImplementedError
|
21
|
+
end
|
22
|
+
|
23
|
+
def reset_counter(key, window)
|
24
|
+
raise NotImplementedError
|
25
|
+
end
|
26
|
+
|
27
|
+
# GCRA operations (atomic)
|
28
|
+
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
29
|
+
raise NotImplementedError
|
30
|
+
end
|
31
|
+
|
32
|
+
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
33
|
+
raise NotImplementedError
|
34
|
+
end
|
35
|
+
|
36
|
+
# Token bucket operations (atomic)
|
37
|
+
def check_token_bucket(key, capacity, refill_rate, ttl)
|
38
|
+
raise NotImplementedError
|
39
|
+
end
|
40
|
+
|
41
|
+
def peek_token_bucket(key, capacity, refill_rate)
|
42
|
+
raise NotImplementedError
|
43
|
+
end
|
44
|
+
|
45
|
+
# Circuit breaker operations
|
46
|
+
def get_breaker_state(key)
|
47
|
+
raise NotImplementedError
|
48
|
+
end
|
49
|
+
|
50
|
+
def record_breaker_success(key, timeout, half_open_requests = 1)
|
51
|
+
raise NotImplementedError
|
52
|
+
end
|
53
|
+
|
54
|
+
def record_breaker_failure(key, threshold, timeout)
|
55
|
+
raise NotImplementedError
|
56
|
+
end
|
57
|
+
|
58
|
+
def trip_breaker(key, timeout)
|
59
|
+
raise NotImplementedError
|
60
|
+
end
|
61
|
+
|
62
|
+
def reset_breaker(key)
|
63
|
+
raise NotImplementedError
|
64
|
+
end
|
65
|
+
|
66
|
+
# Utility operations
|
67
|
+
def clear(pattern = nil)
|
68
|
+
raise NotImplementedError
|
69
|
+
end
|
70
|
+
|
71
|
+
def healthy?
|
72
|
+
raise NotImplementedError
|
73
|
+
end
|
74
|
+
|
75
|
+
def with_timeout(timeout, &)
|
76
|
+
Timeout.timeout(timeout, &)
|
77
|
+
rescue Timeout::Error
|
78
|
+
nil
|
79
|
+
end
|
80
|
+
|
81
|
+
protected
|
82
|
+
|
83
|
+
def current_time
|
84
|
+
# Use monotonic time for consistency with BreakerMachines
|
85
|
+
ThrottleMachines.monotonic_time
|
86
|
+
end
|
87
|
+
|
88
|
+
def monotonic_time
|
89
|
+
ThrottleMachines.monotonic_time
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|