throttle_machines 0.3.3 → 0.4.0
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 +28 -0
- data/lib/throttle_machines/async_support.rb +16 -17
- data/lib/throttle_machines/control.rb +3 -2
- data/lib/throttle_machines/hedged_request.rb +2 -5
- data/lib/throttle_machines/limiter.rb +23 -2
- data/lib/throttle_machines/storage/base.rb +61 -13
- data/lib/throttle_machines/storage/cache.rb +91 -0
- data/lib/throttle_machines/storage/memory.rb +164 -84
- data/lib/throttle_machines/storage/null.rb +12 -0
- data/lib/throttle_machines/storage/redis.rb +35 -10
- data/lib/throttle_machines/unsupported_operation_error.rb +5 -0
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines.rb +8 -8
- metadata +21 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96de7185407f842a40836226124ee468c5a0ad1095bac184203709c6266ec095
|
|
4
|
+
data.tar.gz: 6118f412afbd4bb20929ae3d441eb807cd63154cb9a4ac8b35a7ceb5e4421d0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f64ad8aa766a9e77186c9384807c6dece25d4c22779467bbcafc5b5b317e56cdca5cfdad5b9dd9f4c36ea9d3921a0f6d47c2baa82ec6565d579a6b417dec679
|
|
7
|
+
data.tar.gz: 83e7acf78d640b8bf3f6849f7c25c1446c49446885bd11ef334dbda4e7ba8dcc2efed5ac4e26cb9dc4def031fa1d25b1d62ae26860b8482d25c6397ba555feda
|
data/README.md
CHANGED
|
@@ -126,6 +126,34 @@ memory limits separately. Evicting active Redis keys can reset their limits.
|
|
|
126
126
|
|
|
127
127
|
---
|
|
128
128
|
|
|
129
|
+
## Rails cache storage
|
|
130
|
+
|
|
131
|
+
`Storage::Cache` keeps fixed-window counters in any `ActiveSupport::Cache`
|
|
132
|
+
store, so limits share Solid Cache, Redis, or Memcached with the rest of the app
|
|
133
|
+
(and with `BreakerMachines::Storage::Cache`).
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
ThrottleMachines.storage = ThrottleMachines::Storage::Cache.new(cache_store: Rails.cache)
|
|
137
|
+
# or, inside Rails: config.default_storage = :cache
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Windows align to the Unix epoch and each one is its own cache entry, admitted
|
|
141
|
+
with a single atomic `increment`. On Solid Cache with PostgreSQL, the first
|
|
142
|
+
increments of a window can race and undercount by the number of concurrent
|
|
143
|
+
first requests.
|
|
144
|
+
|
|
145
|
+
It supports `:fixed_window` only; the cache API has no atomic read-modify-write
|
|
146
|
+
for GCRA or token buckets. A limiter built with an algorithm its storage lacks
|
|
147
|
+
raises `ArgumentError` on construction, for custom backends too.
|
|
148
|
+
|
|
149
|
+
When the store fails to count (an outage, `NullStore`), requests are admitted.
|
|
150
|
+
Pass `fail_closed: true` to throttle them instead. `clear` deletes only this
|
|
151
|
+
backend's keys and needs `delete_matched`; on Solid Cache and Memcached it
|
|
152
|
+
raises `ThrottleMachines::UnsupportedOperationError`, so reset single limiters
|
|
153
|
+
with `Limiter#reset!`.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
129
157
|
## 🌠 The Ultra-Thin Philosophy
|
|
130
158
|
|
|
131
159
|
Like the best spacecraft, ThrottleMachines follows the principle of **ultra-thin design**:
|
|
@@ -22,26 +22,25 @@ module ThrottleMachines
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def throttle_async(max_wait: nil, &block)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
caller_budget = DeadlineMachines.current
|
|
26
|
+
|
|
27
|
+
DeadlineMachines.with_deadline(max_wait) do |budget|
|
|
28
|
+
loop do
|
|
29
|
+
begin
|
|
30
|
+
throttle!
|
|
31
|
+
rescue ThrottledError => e
|
|
32
|
+
caller_budget&.check!
|
|
33
|
+
wait_time = [e.retry_after.to_f, 0.0].max
|
|
34
|
+
raise if budget.clamp_sleep(wait_time) < wait_time
|
|
35
|
+
|
|
36
|
+
sleep_for(wait_time)
|
|
37
|
+
next
|
|
36
38
|
end
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
next
|
|
40
|
-
end
|
|
40
|
+
return block.call if block
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return true
|
|
42
|
+
return true
|
|
43
|
+
end
|
|
45
44
|
end
|
|
46
45
|
end
|
|
47
46
|
|
|
@@ -27,7 +27,7 @@ module ThrottleMachines
|
|
|
27
27
|
def retry_on_failure(times:, backoff: :exponential, base_delay: 1, max_delay: 60)
|
|
28
28
|
@rules[:retry] = {
|
|
29
29
|
max_attempts: times,
|
|
30
|
-
|
|
30
|
+
backoff: backoff,
|
|
31
31
|
base_delay: base_delay,
|
|
32
32
|
max_delay: max_delay
|
|
33
33
|
}
|
|
@@ -87,7 +87,8 @@ module ThrottleMachines
|
|
|
87
87
|
max_attempts: @rules[:retry][:max_attempts],
|
|
88
88
|
base_delay: @rules[:retry][:base_delay],
|
|
89
89
|
max_delay: @rules[:retry][:max_delay],
|
|
90
|
-
|
|
90
|
+
backoff_strategy: @rules[:retry][:backoff],
|
|
91
|
+
jitter_factor: @rules[:retry][:backoff] == :exponential ? 1.0 : 0.0
|
|
91
92
|
}
|
|
92
93
|
@retrier = ChronoMachines::Executor.new(policy_options)
|
|
93
94
|
end
|
|
@@ -56,9 +56,9 @@ module ThrottleMachines
|
|
|
56
56
|
@max_attempts.times do |attempt|
|
|
57
57
|
# Schedule with delay
|
|
58
58
|
future = if attempt.zero?
|
|
59
|
-
Concurrent::Promises.
|
|
59
|
+
Concurrent::Promises.future_on(@executor) { yield(attempt) }
|
|
60
60
|
else
|
|
61
|
-
Concurrent::Promises.
|
|
61
|
+
Concurrent::Promises.schedule_on(@executor, @delay * attempt) { yield(attempt) }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Race to resolve first_result
|
|
@@ -88,9 +88,6 @@ module ThrottleMachines
|
|
|
88
88
|
else
|
|
89
89
|
first_result.value!
|
|
90
90
|
end
|
|
91
|
-
ensure
|
|
92
|
-
# Cancel pending futures
|
|
93
|
-
futures.each { |f| f.cancel if f.pending? }
|
|
94
91
|
end
|
|
95
92
|
|
|
96
93
|
# Run async version
|
|
@@ -10,6 +10,8 @@ module ThrottleMachines
|
|
|
10
10
|
@period = period
|
|
11
11
|
@algorithm = algorithm
|
|
12
12
|
@storage = storage || ThrottleMachines.storage
|
|
13
|
+
validate_algorithm!
|
|
14
|
+
validate_rate!
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def allow?
|
|
@@ -93,8 +95,10 @@ module ThrottleMachines
|
|
|
93
95
|
case @algorithm
|
|
94
96
|
when :fixed_window
|
|
95
97
|
@storage.reset_counter(@key, @period)
|
|
96
|
-
|
|
97
|
-
@storage.
|
|
98
|
+
when :gcra
|
|
99
|
+
@storage.reset_gcra(@key)
|
|
100
|
+
when :token_bucket
|
|
101
|
+
@storage.reset_token_bucket(@key)
|
|
98
102
|
end
|
|
99
103
|
end
|
|
100
104
|
|
|
@@ -163,5 +167,22 @@ module ThrottleMachines
|
|
|
163
167
|
retry_after: retry_after
|
|
164
168
|
}
|
|
165
169
|
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def validate_rate!
|
|
174
|
+
raise ArgumentError, "limit must be positive, got #{@limit.inspect}" unless @limit.is_a?(Numeric) && @limit.positive?
|
|
175
|
+
raise ArgumentError, "period must be positive, got #{@period.inspect}" unless @period.is_a?(Numeric) && @period.positive?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_algorithm!
|
|
179
|
+
unless Storage::Base::ALGORITHM_OPERATIONS.key?(@algorithm)
|
|
180
|
+
raise ArgumentError, "Unknown algorithm: #{@algorithm.inspect}"
|
|
181
|
+
end
|
|
182
|
+
return unless @storage.respond_to?(:supports?)
|
|
183
|
+
return if @storage.supports?(@algorithm)
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "#{@storage.class} does not support the #{@algorithm.inspect} algorithm"
|
|
186
|
+
end
|
|
166
187
|
end
|
|
167
188
|
end
|
|
@@ -3,58 +3,94 @@
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
module Storage
|
|
5
5
|
class Base
|
|
6
|
+
ALGORITHM_OPERATIONS = {
|
|
7
|
+
fixed_window: %i[increment_counter get_counter get_counter_ttl reset_counter],
|
|
8
|
+
gcra: %i[check_gcra_limit peek_gcra_limit reset_gcra],
|
|
9
|
+
token_bucket: %i[check_token_bucket peek_token_bucket reset_token_bucket]
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
HEALTH_KEY = 'throttle_machines:health'
|
|
13
|
+
|
|
14
|
+
# What a backend puts between a limiter's key and the state it appends.
|
|
15
|
+
KEY_SEPARATOR = ':'
|
|
16
|
+
|
|
17
|
+
GLOB_WILDCARD = '*'
|
|
18
|
+
GLOB_SPECIALS = /[*?\[\]\\]/
|
|
19
|
+
|
|
6
20
|
def initialize(options = {})
|
|
7
21
|
@options = options
|
|
8
22
|
end
|
|
9
23
|
|
|
24
|
+
def supports?(algorithm)
|
|
25
|
+
operations = ALGORITHM_OPERATIONS[algorithm]
|
|
26
|
+
return false unless operations
|
|
27
|
+
|
|
28
|
+
operations.all? { |operation| self.class.instance_method(operation).owner != Base }
|
|
29
|
+
end
|
|
30
|
+
|
|
10
31
|
# Rate limiting operations
|
|
11
32
|
def increment_counter(key, window, amount = 1)
|
|
12
|
-
raise NotImplementedError
|
|
33
|
+
raise NotImplementedError, "#{self.class} does not implement #increment_counter"
|
|
13
34
|
end
|
|
14
35
|
|
|
15
36
|
def get_counter(key, window)
|
|
16
|
-
raise NotImplementedError
|
|
37
|
+
raise NotImplementedError, "#{self.class} does not implement #get_counter"
|
|
17
38
|
end
|
|
18
39
|
|
|
19
40
|
def get_counter_ttl(key, window)
|
|
20
|
-
raise NotImplementedError
|
|
41
|
+
raise NotImplementedError, "#{self.class} does not implement #get_counter_ttl"
|
|
21
42
|
end
|
|
22
43
|
|
|
23
44
|
def reset_counter(key, window)
|
|
24
|
-
raise NotImplementedError
|
|
45
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_counter"
|
|
25
46
|
end
|
|
26
47
|
|
|
27
48
|
# GCRA operations (atomic)
|
|
28
49
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
29
|
-
raise NotImplementedError
|
|
50
|
+
raise NotImplementedError, "#{self.class} does not implement #check_gcra_limit"
|
|
30
51
|
end
|
|
31
52
|
|
|
32
53
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
33
|
-
raise NotImplementedError
|
|
54
|
+
raise NotImplementedError, "#{self.class} does not implement #peek_gcra_limit"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_gcra(key)
|
|
58
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_gcra"
|
|
34
59
|
end
|
|
35
60
|
|
|
36
61
|
# Token bucket operations (atomic)
|
|
37
62
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
38
|
-
raise NotImplementedError
|
|
63
|
+
raise NotImplementedError, "#{self.class} does not implement #check_token_bucket"
|
|
39
64
|
end
|
|
40
65
|
|
|
41
66
|
def peek_token_bucket(key, capacity, refill_rate)
|
|
42
|
-
raise NotImplementedError
|
|
67
|
+
raise NotImplementedError, "#{self.class} does not implement #peek_token_bucket"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reset_token_bucket(key)
|
|
71
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_token_bucket"
|
|
43
72
|
end
|
|
44
73
|
|
|
45
74
|
# Utility operations
|
|
46
75
|
def clear(pattern = nil)
|
|
47
|
-
raise NotImplementedError
|
|
76
|
+
raise NotImplementedError, "#{self.class} does not implement #clear"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Deletes every entry this key owns, whatever algorithm wrote it.
|
|
80
|
+
def reset_key(key)
|
|
81
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_key"
|
|
48
82
|
end
|
|
49
83
|
|
|
50
84
|
def healthy?
|
|
51
|
-
|
|
85
|
+
get_counter(HEALTH_KEY, 1)
|
|
86
|
+
true
|
|
87
|
+
rescue StandardError
|
|
88
|
+
false
|
|
52
89
|
end
|
|
53
90
|
|
|
54
91
|
def with_timeout(timeout, &block)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
nil
|
|
92
|
+
DeadlineMachines.check!
|
|
93
|
+
Timeout.timeout(DeadlineMachines.clamp_sleep(timeout), &block)
|
|
58
94
|
end
|
|
59
95
|
|
|
60
96
|
# Rounding slack for time comparisons, in units of the clock's precision.
|
|
@@ -63,6 +99,18 @@ module ThrottleMachines
|
|
|
63
99
|
|
|
64
100
|
protected
|
|
65
101
|
|
|
102
|
+
def owned_by?(stored_key, key)
|
|
103
|
+
stored_key == key || stored_key.start_with?("#{key}#{KEY_SEPARATOR}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def escape_glob(literal)
|
|
107
|
+
literal.gsub(GLOB_SPECIALS) { |char| "\\#{char}" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def glob_to_regexp(glob)
|
|
111
|
+
Regexp.new("\\A#{Regexp.escape(glob).gsub(Regexp.escape(GLOB_WILDCARD), '.*')}\\z")
|
|
112
|
+
end
|
|
113
|
+
|
|
66
114
|
# Times are running sums and differences of float seconds, so an
|
|
67
115
|
# on-schedule arrival can miss an exact boundary by a few ulps. The slack
|
|
68
116
|
# scales with the clock's magnitude.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThrottleMachines
|
|
4
|
+
module Storage
|
|
5
|
+
class Cache < Base
|
|
6
|
+
# The wildcard a caller's pattern keeps; everything else is a literal.
|
|
7
|
+
PATTERN_SPECIALS = /[?\[\]\\]/
|
|
8
|
+
|
|
9
|
+
def initialize(options = {})
|
|
10
|
+
super
|
|
11
|
+
@cache = options[:cache_store] || (defined?(Rails) && Rails.cache)
|
|
12
|
+
raise ArgumentError, 'Cache storage requires a cache_store outside Rails' unless @cache
|
|
13
|
+
|
|
14
|
+
@prefix = options[:prefix] || 'throttle_machines'
|
|
15
|
+
@fail_closed = options.fetch(:fail_closed, false)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def increment_counter(key, window, amount = 1)
|
|
19
|
+
count = @cache.increment(window_key(key, window), amount, expires_in: window.to_f.ceil + 1)
|
|
20
|
+
count || (@fail_closed ? Float::INFINITY : 0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get_counter(key, window)
|
|
24
|
+
@cache.read(window_key(key, window), raw: true).to_i
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_counter_ttl(_key, window)
|
|
28
|
+
window = positive_window(window)
|
|
29
|
+
window - (wall_time % window)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset_counter(key, window)
|
|
33
|
+
@cache.delete(window_key(key, window))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear(pattern = nil)
|
|
37
|
+
delete_matched(matcher_for("#{@prefix}#{KEY_SEPARATOR}#{pattern || GLOB_WILDCARD}"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The window and the bucket index a counter was written under are unknown
|
|
41
|
+
# here, so the counters go by a prefix of the key's own namespace.
|
|
42
|
+
def reset_key(key)
|
|
43
|
+
delete_matched(prefix_matcher("#{@prefix}#{KEY_SEPARATOR}#{key}#{KEY_SEPARATOR}"))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def healthy?
|
|
47
|
+
!@cache.increment("#{@prefix}:health", 1, expires_in: 60).nil?
|
|
48
|
+
rescue StandardError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def window_key(key, window)
|
|
55
|
+
window = positive_window(window)
|
|
56
|
+
"#{@prefix}:#{key}:#{window}:#{(wall_time / window).floor}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def positive_window(window)
|
|
60
|
+
window = window.to_f
|
|
61
|
+
raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
|
|
62
|
+
|
|
63
|
+
window
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete_matched(matcher)
|
|
67
|
+
@cache.delete_matched(matcher)
|
|
68
|
+
rescue NotImplementedError
|
|
69
|
+
raise UnsupportedOperationError,
|
|
70
|
+
"#{@cache.class} cannot delete by pattern; reset limiters individually with Limiter#reset!"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def matcher_for(glob)
|
|
74
|
+
return glob.gsub(PATTERN_SPECIALS) { |char| "\\#{char}" } if @cache.respond_to?(:redis)
|
|
75
|
+
|
|
76
|
+
Regexp.new("^#{Regexp.escape(glob).gsub(Regexp.escape(GLOB_WILDCARD), '.*')}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def prefix_matcher(literal)
|
|
80
|
+
return "#{escape_glob(literal)}#{GLOB_WILDCARD}" if @cache.respond_to?(:redis)
|
|
81
|
+
|
|
82
|
+
# ActiveSupport only prepends a store's namespace to a `^` source.
|
|
83
|
+
Regexp.new("^#{Regexp.escape(literal)}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def wall_time
|
|
87
|
+
Time.now.to_f
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -7,37 +7,36 @@ module ThrottleMachines
|
|
|
7
7
|
class Memory < Base
|
|
8
8
|
DEFAULT_MAX_KEYS = 65_536
|
|
9
9
|
CAPACITY_RETRY_AFTER = 1.0
|
|
10
|
+
DEFAULT_CLEANUP_INTERVAL = 60
|
|
11
|
+
DEFAULT_LOCK_POOL_SIZE = 32
|
|
12
|
+
SHUTDOWN_GRACE = 1.0
|
|
10
13
|
|
|
11
14
|
def initialize(options = {})
|
|
12
15
|
super
|
|
13
|
-
|
|
14
|
-
unless
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
max_keys = options.fetch(:max_keys, DEFAULT_MAX_KEYS)
|
|
17
|
+
raise ArgumentError, 'max_keys must be a non-negative integer' unless max_keys.is_a?(Integer) && max_keys >= 0
|
|
18
|
+
|
|
19
|
+
@state = State.new(
|
|
20
|
+
max_keys: max_keys,
|
|
21
|
+
lock_pool_size: options[:lock_pool_size] || DEFAULT_LOCK_POOL_SIZE,
|
|
22
|
+
cleanup_interval: options[:cleanup_interval] || DEFAULT_CLEANUP_INTERVAL
|
|
23
|
+
)
|
|
24
|
+
@counters = @state.counters
|
|
25
|
+
@gcra_states = @state.gcra_states
|
|
26
|
+
@token_buckets = @state.token_buckets
|
|
17
27
|
|
|
18
|
-
@stored_keys = 0
|
|
19
|
-
@capacity_lock = Mutex.new
|
|
20
28
|
@capacity_cleanup_lock = Mutex.new
|
|
21
29
|
@next_capacity_cleanup_at = -Float::INFINITY
|
|
22
|
-
@
|
|
23
|
-
@gcra_states = Concurrent::Hash.new
|
|
24
|
-
@token_buckets = Concurrent::Hash.new
|
|
25
|
-
|
|
26
|
-
# Use a striped lock pattern - pool of locks for fine-grained concurrency
|
|
27
|
-
@lock_pool_size = options[:lock_pool_size] || 32
|
|
28
|
-
@locks = Array.new(@lock_pool_size) { Concurrent::ReadWriteLock.new }
|
|
30
|
+
@cleanup_thread = nil
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
@cleanup_interval = options[:cleanup_interval] || 60
|
|
32
|
-
@shutdown = false
|
|
33
|
-
@cleanup_thread = start_cleanup_thread if options[:auto_cleanup] != false
|
|
32
|
+
return if options[:auto_cleanup] == false
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
@cleanup_thread = @state.start_cleanup_thread
|
|
36
35
|
ObjectSpace.define_finalizer(self, self.class.finalizer(@cleanup_thread))
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
def self.finalizer(cleanup_thread)
|
|
40
|
-
proc { cleanup_thread
|
|
39
|
+
proc { cleanup_thread.kill }
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
# Rate limiting operations
|
|
@@ -113,7 +112,7 @@ module ThrottleMachines
|
|
|
113
112
|
|
|
114
113
|
{
|
|
115
114
|
allowed: allow,
|
|
116
|
-
retry_after: allow ? 0 : (tat - now - delay_tolerance),
|
|
115
|
+
retry_after: allow ? 0.0 : (tat - now - delay_tolerance),
|
|
117
116
|
tat: tat
|
|
118
117
|
}
|
|
119
118
|
end
|
|
@@ -132,12 +131,16 @@ module ThrottleMachines
|
|
|
132
131
|
|
|
133
132
|
{
|
|
134
133
|
allowed: allow,
|
|
135
|
-
retry_after: allow ? 0 : (tat - now - delay_tolerance),
|
|
134
|
+
retry_after: allow ? 0.0 : (tat - now - delay_tolerance),
|
|
136
135
|
tat: tat
|
|
137
136
|
}
|
|
138
137
|
end
|
|
139
138
|
end
|
|
140
139
|
|
|
140
|
+
def reset_gcra(key)
|
|
141
|
+
with_write_lock(key) { delete_entry(@gcra_states, key) }
|
|
142
|
+
end
|
|
143
|
+
|
|
141
144
|
# Token bucket operations (atomic simulation)
|
|
142
145
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
143
146
|
reclaim_capacity
|
|
@@ -160,7 +163,7 @@ module ThrottleMachines
|
|
|
160
163
|
|
|
161
164
|
{
|
|
162
165
|
allowed: true,
|
|
163
|
-
retry_after: 0,
|
|
166
|
+
retry_after: 0.0,
|
|
164
167
|
tokens_remaining: bucket[:tokens].floor
|
|
165
168
|
}
|
|
166
169
|
else
|
|
@@ -191,7 +194,7 @@ module ThrottleMachines
|
|
|
191
194
|
if token_available?(current_tokens, now, refill_rate)
|
|
192
195
|
{
|
|
193
196
|
allowed: true,
|
|
194
|
-
retry_after: 0,
|
|
197
|
+
retry_after: 0.0,
|
|
195
198
|
tokens_remaining: [current_tokens - 1, 0].max.floor
|
|
196
199
|
}
|
|
197
200
|
else
|
|
@@ -206,65 +209,58 @@ module ThrottleMachines
|
|
|
206
209
|
end
|
|
207
210
|
end
|
|
208
211
|
|
|
212
|
+
def reset_token_bucket(key)
|
|
213
|
+
with_write_lock(key) { delete_entry(@token_buckets, key) }
|
|
214
|
+
end
|
|
215
|
+
|
|
209
216
|
# No circuit breaker operations here: breaker state is owned by BreakerMachines
|
|
210
217
|
|
|
211
218
|
# Utility operations
|
|
212
219
|
def clear(pattern = nil)
|
|
213
220
|
if pattern
|
|
214
|
-
regex =
|
|
215
|
-
|
|
216
|
-
# Clear matching keys from all stores
|
|
217
|
-
[@counters, @gcra_states, @token_buckets].each do |store|
|
|
218
|
-
store.keys.each do |k|
|
|
219
|
-
with_write_lock(k) { delete_entry(store, k) } if k&.match?(regex)
|
|
220
|
-
end
|
|
221
|
-
end
|
|
221
|
+
regex = glob_to_regexp(pattern)
|
|
222
|
+
delete_where { |stored_key| stored_key.match?(regex) }
|
|
222
223
|
else
|
|
223
|
-
@
|
|
224
|
-
@counters.clear
|
|
225
|
-
@gcra_states.clear
|
|
226
|
-
@token_buckets.clear
|
|
227
|
-
@stored_keys = 0
|
|
228
|
-
end
|
|
224
|
+
@state.clear_all
|
|
229
225
|
end
|
|
230
226
|
end
|
|
231
227
|
|
|
228
|
+
def reset_key(key)
|
|
229
|
+
delete_where { |stored_key| owned_by?(stored_key, key) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
232
|
def healthy?
|
|
233
233
|
true
|
|
234
234
|
end
|
|
235
235
|
|
|
236
236
|
def shutdown
|
|
237
|
-
@shutdown
|
|
238
|
-
@cleanup_thread&.join(
|
|
239
|
-
@cleanup_thread&.kill
|
|
237
|
+
@state.shutdown!
|
|
238
|
+
@cleanup_thread&.join(SHUTDOWN_GRACE)
|
|
239
|
+
@cleanup_thread&.kill
|
|
240
240
|
@cleanup_thread = nil
|
|
241
|
+
ObjectSpace.undefine_finalizer(self)
|
|
241
242
|
end
|
|
242
243
|
|
|
243
244
|
private
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return false if new_key && @stored_keys >= @max_keys
|
|
251
|
-
|
|
252
|
-
store[key] = value
|
|
253
|
-
@stored_keys += 1 if new_key
|
|
254
|
-
true
|
|
246
|
+
def delete_where
|
|
247
|
+
@state.stores.each do |store|
|
|
248
|
+
store.keys.each do |stored_key|
|
|
249
|
+
with_write_lock(stored_key) { delete_entry(store, stored_key) } if yield(stored_key)
|
|
250
|
+
end
|
|
255
251
|
end
|
|
256
252
|
end
|
|
257
253
|
|
|
254
|
+
def store_entry(store, key, value)
|
|
255
|
+
@state.store_entry(store, key, value)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
258
|
def delete_entry(store, key)
|
|
259
|
-
@
|
|
260
|
-
value = store.delete(key)
|
|
261
|
-
@stored_keys -= 1 if value
|
|
262
|
-
value
|
|
263
|
-
end
|
|
259
|
+
@state.delete_entry(store, key)
|
|
264
260
|
end
|
|
265
261
|
|
|
266
262
|
def capacity_available?(store, key)
|
|
267
|
-
@
|
|
263
|
+
@state.capacity_available?(store, key)
|
|
268
264
|
end
|
|
269
265
|
|
|
270
266
|
def capacity_gcra_refusal(now, delay_tolerance)
|
|
@@ -279,7 +275,7 @@ module ThrottleMachines
|
|
|
279
275
|
# cleanup at most once per second, even with auto_cleanup disabled.
|
|
280
276
|
# Rejected identity sprays must not trigger a full scan on every call.
|
|
281
277
|
def reclaim_capacity
|
|
282
|
-
return unless @
|
|
278
|
+
return unless @state.saturated?
|
|
283
279
|
return unless @capacity_cleanup_lock.try_lock
|
|
284
280
|
|
|
285
281
|
begin
|
|
@@ -294,49 +290,133 @@ module ThrottleMachines
|
|
|
294
290
|
end
|
|
295
291
|
|
|
296
292
|
def with_read_lock(key, &block)
|
|
297
|
-
|
|
293
|
+
@state.with_read_lock(key, &block)
|
|
298
294
|
end
|
|
299
295
|
|
|
300
296
|
def with_write_lock(key, &block)
|
|
301
|
-
|
|
297
|
+
@state.with_write_lock(key, &block)
|
|
302
298
|
end
|
|
303
299
|
|
|
304
|
-
def
|
|
305
|
-
|
|
306
|
-
index = key.hash.abs % @lock_pool_size
|
|
307
|
-
@locks[index]
|
|
300
|
+
def clean_expired_entries
|
|
301
|
+
@state.clean_expired_entries
|
|
308
302
|
end
|
|
309
303
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
304
|
+
# Holds every map, lock and counter the cleanup worker sweeps.
|
|
305
|
+
class State
|
|
306
|
+
attr_reader :counters, :gcra_states, :token_buckets
|
|
307
|
+
|
|
308
|
+
def initialize(max_keys:, lock_pool_size:, cleanup_interval:)
|
|
309
|
+
@max_keys = max_keys
|
|
310
|
+
@cleanup_interval = cleanup_interval
|
|
311
|
+
@stored_keys = 0
|
|
312
|
+
@capacity_lock = Mutex.new
|
|
313
|
+
@counters = Concurrent::Hash.new
|
|
314
|
+
@gcra_states = Concurrent::Hash.new
|
|
315
|
+
@token_buckets = Concurrent::Hash.new
|
|
316
|
+
|
|
317
|
+
# Use a striped lock pattern - pool of locks for fine-grained concurrency
|
|
318
|
+
@lock_pool_size = lock_pool_size
|
|
319
|
+
@locks = Array.new(lock_pool_size) { Concurrent::ReadWriteLock.new }
|
|
320
|
+
@shutdown = false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def start_cleanup_thread
|
|
324
|
+
Thread.new do
|
|
325
|
+
loop do
|
|
326
|
+
break if @shutdown
|
|
314
327
|
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
sleep @cleanup_interval
|
|
329
|
+
break if @shutdown
|
|
317
330
|
|
|
318
|
-
|
|
331
|
+
clean_expired_entries
|
|
332
|
+
end
|
|
319
333
|
end
|
|
320
334
|
end
|
|
321
|
-
end
|
|
322
335
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
336
|
+
def shutdown!
|
|
337
|
+
@shutdown = true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def stores
|
|
341
|
+
[@counters, @gcra_states, @token_buckets]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def stored_keys
|
|
345
|
+
@capacity_lock.synchronize { @stored_keys }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def saturated?
|
|
349
|
+
@capacity_lock.synchronize { @stored_keys >= @max_keys }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def with_read_lock(key, &block)
|
|
353
|
+
lock_for(key).with_read_lock(&block)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def with_write_lock(key, &block)
|
|
357
|
+
lock_for(key).with_write_lock(&block)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Called with the key's write lock held. The short admission lock makes
|
|
361
|
+
# the cap atomic across stripes and all three stores.
|
|
362
|
+
def store_entry(store, key, value)
|
|
363
|
+
@capacity_lock.synchronize do
|
|
364
|
+
new_key = !store.key?(key)
|
|
365
|
+
return false if new_key && @stored_keys >= @max_keys
|
|
366
|
+
|
|
367
|
+
store[key] = value
|
|
368
|
+
@stored_keys += 1 if new_key
|
|
369
|
+
true
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def delete_entry(store, key)
|
|
374
|
+
@capacity_lock.synchronize do
|
|
375
|
+
value = store.delete(key)
|
|
376
|
+
@stored_keys -= 1 if value
|
|
377
|
+
value
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def capacity_available?(store, key)
|
|
382
|
+
@capacity_lock.synchronize { store.key?(key) || @stored_keys < @max_keys }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def clear_all
|
|
386
|
+
@capacity_lock.synchronize do
|
|
387
|
+
@counters.clear
|
|
388
|
+
@gcra_states.clear
|
|
389
|
+
@token_buckets.clear
|
|
390
|
+
@stored_keys = 0
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def clean_expired_entries
|
|
395
|
+
now = ThrottleMachines.monotonic_time
|
|
396
|
+
|
|
397
|
+
stores.each do |store|
|
|
398
|
+
store.keys.each do |key|
|
|
399
|
+
with_write_lock(key) do
|
|
400
|
+
# Re-read under the lock: a writer may have refreshed the TTL.
|
|
401
|
+
data = store[key]
|
|
402
|
+
delete_entry(store, key) if data && data[:expires_at] && data[:expires_at] <= now
|
|
403
|
+
end
|
|
332
404
|
end
|
|
333
405
|
end
|
|
406
|
+
|
|
407
|
+
# No breaker state cleanup: breaker state is managed by BreakerMachines
|
|
408
|
+
rescue StandardError => e
|
|
409
|
+
# Log error but don't crash cleanup thread
|
|
410
|
+
warn "ThrottleMachines: Cleanup error: #{e.message}"
|
|
334
411
|
end
|
|
335
412
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
413
|
+
private
|
|
414
|
+
|
|
415
|
+
def lock_for(key)
|
|
416
|
+
# Hash key to determine which lock to use
|
|
417
|
+
index = key.hash.abs % @lock_pool_size
|
|
418
|
+
@locks[index]
|
|
419
|
+
end
|
|
340
420
|
end
|
|
341
421
|
end
|
|
342
422
|
end
|
|
@@ -37,6 +37,10 @@ module ThrottleMachines
|
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def reset_gcra(_key)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
# Token bucket operations
|
|
41
45
|
def check_token_bucket(_key, capacity, _refill_rate, _ttl)
|
|
42
46
|
{
|
|
@@ -54,11 +58,19 @@ module ThrottleMachines
|
|
|
54
58
|
}
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def reset_token_bucket(_key)
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
# Utility operations
|
|
58
66
|
def clear(_pattern = nil)
|
|
59
67
|
true
|
|
60
68
|
end
|
|
61
69
|
|
|
70
|
+
def reset_key(_key)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
62
74
|
def healthy?
|
|
63
75
|
true
|
|
64
76
|
end
|
|
@@ -22,6 +22,9 @@ module ThrottleMachines
|
|
|
22
22
|
PEEK_GCRA_SHA = Digest::SHA1.hexdigest(PEEK_GCRA_SCRIPT)
|
|
23
23
|
PEEK_TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(PEEK_TOKEN_BUCKET_SCRIPT)
|
|
24
24
|
|
|
25
|
+
# Keys a single SCAN round trip asks for.
|
|
26
|
+
SCAN_BATCH = 100
|
|
27
|
+
|
|
25
28
|
def initialize(options = {})
|
|
26
29
|
super
|
|
27
30
|
@redis = options[:redis] || options[:client] || options[:pool]
|
|
@@ -74,6 +77,10 @@ module ThrottleMachines
|
|
|
74
77
|
}
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
def reset_gcra(key)
|
|
81
|
+
delete_state(key)
|
|
82
|
+
end
|
|
83
|
+
|
|
77
84
|
# Token bucket operations (atomic via Lua)
|
|
78
85
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
79
86
|
result = run_script(TOKEN_BUCKET_SCRIPT, TOKEN_BUCKET_SHA,
|
|
@@ -83,6 +90,10 @@ module ThrottleMachines
|
|
|
83
90
|
token_bucket_result(result, refill_rate)
|
|
84
91
|
end
|
|
85
92
|
|
|
93
|
+
def reset_token_bucket(key)
|
|
94
|
+
delete_state(key)
|
|
95
|
+
end
|
|
96
|
+
|
|
86
97
|
# Peek methods for non-consuming checks
|
|
87
98
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
88
99
|
now = current_time
|
|
@@ -112,17 +123,14 @@ module ThrottleMachines
|
|
|
112
123
|
|
|
113
124
|
# Utility operations
|
|
114
125
|
def clear(pattern = nil)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
scan_pattern = pattern ? prefixed(pattern) : "#{@prefix}*"
|
|
126
|
+
delete_matching(pattern ? prefixed(pattern) : "#{@prefix}#{GLOB_WILDCARD}")
|
|
127
|
+
end
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
end
|
|
125
|
-
end
|
|
129
|
+
# The window a counter was written under is unknown here, so the counters
|
|
130
|
+
# go by a glob over the key's own namespace; the key itself is a literal.
|
|
131
|
+
def reset_key(key)
|
|
132
|
+
delete_state(key)
|
|
133
|
+
delete_matching("#{escape_glob(prefixed(key))}#{KEY_SEPARATOR}#{GLOB_WILDCARD}")
|
|
126
134
|
end
|
|
127
135
|
|
|
128
136
|
def healthy?
|
|
@@ -133,6 +141,23 @@ module ThrottleMachines
|
|
|
133
141
|
|
|
134
142
|
private
|
|
135
143
|
|
|
144
|
+
def delete_state(key)
|
|
145
|
+
with_redis { |r| r.del(prefixed(key)) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# SCAN instead of KEYS, to avoid blocking in production.
|
|
149
|
+
def delete_matching(scan_pattern)
|
|
150
|
+
cursor = '0'
|
|
151
|
+
|
|
152
|
+
with_redis do |redis|
|
|
153
|
+
loop do
|
|
154
|
+
cursor, keys = redis.scan(cursor, match: scan_pattern, count: SCAN_BATCH)
|
|
155
|
+
redis.del(*keys) unless keys.empty?
|
|
156
|
+
break if cursor == '0'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
136
161
|
# `result` is [allowed, tokens]: the balance after an admission, or the
|
|
137
162
|
# fractional balance a refusal waits on. Matches Storage::Memory.
|
|
138
163
|
def token_bucket_result(result, refill_rate)
|
data/lib/throttle_machines.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'active_support/core_ext/class/attribute'
|
|
|
7
7
|
|
|
8
8
|
# Ecosystem dependencies
|
|
9
9
|
require 'chrono_machines'
|
|
10
|
+
require 'deadline_machines'
|
|
10
11
|
require 'breaker_machines'
|
|
11
12
|
|
|
12
13
|
# Set up Zeitwerk loader
|
|
@@ -75,7 +76,7 @@ module ThrottleMachines
|
|
|
75
76
|
|
|
76
77
|
def reset!(key = nil)
|
|
77
78
|
if key
|
|
78
|
-
storage.
|
|
79
|
+
storage.reset_key(key)
|
|
79
80
|
else
|
|
80
81
|
storage.clear
|
|
81
82
|
# Reset storage instance to force recreation with defaults
|
|
@@ -98,19 +99,16 @@ module ThrottleMachines
|
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
def break_circuit(key, failures:, timeout:, &block)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
failure_threshold: failures,
|
|
105
|
-
reset_timeout: timeout
|
|
106
|
-
)
|
|
107
|
-
breaker.call(&block)
|
|
102
|
+
BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
|
|
103
|
+
key, self, { failure_threshold: failures, reset_timeout: timeout }
|
|
104
|
+
).call(&block)
|
|
108
105
|
end
|
|
109
106
|
|
|
110
107
|
def retry_with(max_attempts: 3, backoff: :exponential, &block)
|
|
111
108
|
# Delegate to chrono_machines
|
|
112
109
|
policy_options = {
|
|
113
110
|
max_attempts: max_attempts,
|
|
111
|
+
backoff_strategy: backoff,
|
|
114
112
|
jitter_factor: backoff == :exponential ? 1.0 : 0.0
|
|
115
113
|
}
|
|
116
114
|
ChronoMachines.retry(policy_options, &block)
|
|
@@ -159,6 +157,8 @@ module ThrottleMachines
|
|
|
159
157
|
case symbol
|
|
160
158
|
when :memory
|
|
161
159
|
Storage::Memory.new
|
|
160
|
+
when :cache
|
|
161
|
+
Storage::Cache.new
|
|
162
162
|
when :redis
|
|
163
163
|
raise ArgumentError, 'Redis storage requires redis gem' unless defined?(Redis)
|
|
164
164
|
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -69,16 +69,16 @@ dependencies:
|
|
|
69
69
|
name: breaker_machines
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
|
-
- - "
|
|
72
|
+
- - ">="
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version:
|
|
74
|
+
version: 0.22.1
|
|
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:
|
|
81
|
+
version: 0.22.1
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: chrono_machines
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -93,6 +93,20 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '0.10'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: deadline_machines
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0.1'
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0.1'
|
|
96
110
|
- !ruby/object:Gem::Dependency
|
|
97
111
|
name: minitest
|
|
98
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -185,6 +199,7 @@ files:
|
|
|
185
199
|
- lib/throttle_machines/rack_middleware/throttle.rb
|
|
186
200
|
- lib/throttle_machines/rack_middleware/track.rb
|
|
187
201
|
- lib/throttle_machines/storage/base.rb
|
|
202
|
+
- lib/throttle_machines/storage/cache.rb
|
|
188
203
|
- lib/throttle_machines/storage/memory.rb
|
|
189
204
|
- lib/throttle_machines/storage/null.rb
|
|
190
205
|
- lib/throttle_machines/storage/redis.rb
|
|
@@ -194,6 +209,7 @@ files:
|
|
|
194
209
|
- lib/throttle_machines/storage/redis/peek_token_bucket.lua
|
|
195
210
|
- lib/throttle_machines/storage/redis/token_bucket.lua
|
|
196
211
|
- lib/throttle_machines/throttled_error.rb
|
|
212
|
+
- lib/throttle_machines/unsupported_operation_error.rb
|
|
197
213
|
- lib/throttle_machines/version.rb
|
|
198
214
|
homepage: https://github.com/seuros/throttle_machines
|
|
199
215
|
licenses:
|