throttle_machines 0.3.3-x86_64-linux → 0.3.4-x86_64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +28 -0
- data/lib/throttle_machines/limiter.rb +13 -0
- data/lib/throttle_machines/storage/base.rb +28 -10
- data/lib/throttle_machines/storage/cache.rb +73 -0
- data/lib/throttle_machines/unsupported_operation_error.rb +5 -0
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines.rb +2 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c66ed002f19fead80fd931c2fef895b7ac3778437d72d9ff01642b1876ae7e4
|
|
4
|
+
data.tar.gz: 364c295404364d6206048e7a7f350907400450cfd47e73cbcd832294f00aa592
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e73aacb0fadd1522578b92a81dc86e1a5ec3af3b15389d01558bc3030c90e84ced71f0a82404bd5fb07ac93e6b8e999912659263b70f367c9d83de22c2c3ef7f
|
|
7
|
+
data.tar.gz: 368c382b90c4621b237dac12fed6e8ba86198023e6b351ab3db591b76b33672942e0b3faf68124bc725ebf491ae81a6a3ece8731c41c601ffef80fb4ba9c17a8
|
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**:
|
|
@@ -10,6 +10,7 @@ module ThrottleMachines
|
|
|
10
10
|
@period = period
|
|
11
11
|
@algorithm = algorithm
|
|
12
12
|
@storage = storage || ThrottleMachines.storage
|
|
13
|
+
validate_algorithm!
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def allow?
|
|
@@ -163,5 +164,17 @@ module ThrottleMachines
|
|
|
163
164
|
retry_after: retry_after
|
|
164
165
|
}
|
|
165
166
|
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def validate_algorithm!
|
|
171
|
+
unless Storage::Base::ALGORITHM_OPERATIONS.key?(@algorithm)
|
|
172
|
+
raise ArgumentError, "Unknown algorithm: #{@algorithm.inspect}"
|
|
173
|
+
end
|
|
174
|
+
return unless @storage.respond_to?(:supports?)
|
|
175
|
+
return if @storage.supports?(@algorithm)
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "#{@storage.class} does not support the #{@algorithm.inspect} algorithm"
|
|
178
|
+
end
|
|
166
179
|
end
|
|
167
180
|
end
|
|
@@ -3,52 +3,70 @@
|
|
|
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],
|
|
9
|
+
token_bucket: %i[check_token_bucket peek_token_bucket]
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
HEALTH_KEY = 'throttle_machines:health'
|
|
13
|
+
|
|
6
14
|
def initialize(options = {})
|
|
7
15
|
@options = options
|
|
8
16
|
end
|
|
9
17
|
|
|
18
|
+
def supports?(algorithm)
|
|
19
|
+
operations = ALGORITHM_OPERATIONS[algorithm]
|
|
20
|
+
return false unless operations
|
|
21
|
+
|
|
22
|
+
operations.all? { |operation| self.class.instance_method(operation).owner != Base }
|
|
23
|
+
end
|
|
24
|
+
|
|
10
25
|
# Rate limiting operations
|
|
11
26
|
def increment_counter(key, window, amount = 1)
|
|
12
|
-
raise NotImplementedError
|
|
27
|
+
raise NotImplementedError, "#{self.class} does not implement #increment_counter"
|
|
13
28
|
end
|
|
14
29
|
|
|
15
30
|
def get_counter(key, window)
|
|
16
|
-
raise NotImplementedError
|
|
31
|
+
raise NotImplementedError, "#{self.class} does not implement #get_counter"
|
|
17
32
|
end
|
|
18
33
|
|
|
19
34
|
def get_counter_ttl(key, window)
|
|
20
|
-
raise NotImplementedError
|
|
35
|
+
raise NotImplementedError, "#{self.class} does not implement #get_counter_ttl"
|
|
21
36
|
end
|
|
22
37
|
|
|
23
38
|
def reset_counter(key, window)
|
|
24
|
-
raise NotImplementedError
|
|
39
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_counter"
|
|
25
40
|
end
|
|
26
41
|
|
|
27
42
|
# GCRA operations (atomic)
|
|
28
43
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
29
|
-
raise NotImplementedError
|
|
44
|
+
raise NotImplementedError, "#{self.class} does not implement #check_gcra_limit"
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
33
|
-
raise NotImplementedError
|
|
48
|
+
raise NotImplementedError, "#{self.class} does not implement #peek_gcra_limit"
|
|
34
49
|
end
|
|
35
50
|
|
|
36
51
|
# Token bucket operations (atomic)
|
|
37
52
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
38
|
-
raise NotImplementedError
|
|
53
|
+
raise NotImplementedError, "#{self.class} does not implement #check_token_bucket"
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
def peek_token_bucket(key, capacity, refill_rate)
|
|
42
|
-
raise NotImplementedError
|
|
57
|
+
raise NotImplementedError, "#{self.class} does not implement #peek_token_bucket"
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# Utility operations
|
|
46
61
|
def clear(pattern = nil)
|
|
47
|
-
raise NotImplementedError
|
|
62
|
+
raise NotImplementedError, "#{self.class} does not implement #clear"
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
def healthy?
|
|
51
|
-
|
|
66
|
+
get_counter(HEALTH_KEY, 1)
|
|
67
|
+
true
|
|
68
|
+
rescue StandardError
|
|
69
|
+
false
|
|
52
70
|
end
|
|
53
71
|
|
|
54
72
|
def with_timeout(timeout, &block)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThrottleMachines
|
|
4
|
+
module Storage
|
|
5
|
+
class Cache < Base
|
|
6
|
+
GLOB_SPECIALS = /[?\[\]\\]/
|
|
7
|
+
|
|
8
|
+
def initialize(options = {})
|
|
9
|
+
super
|
|
10
|
+
@cache = options[:cache_store] || (defined?(Rails) && Rails.cache)
|
|
11
|
+
raise ArgumentError, 'Cache storage requires a cache_store outside Rails' unless @cache
|
|
12
|
+
|
|
13
|
+
@prefix = options[:prefix] || 'throttle_machines'
|
|
14
|
+
@fail_closed = options.fetch(:fail_closed, false)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def increment_counter(key, window, amount = 1)
|
|
18
|
+
count = @cache.increment(window_key(key, window), amount, expires_in: window.to_f.ceil + 1)
|
|
19
|
+
count || (@fail_closed ? Float::INFINITY : 0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get_counter(key, window)
|
|
23
|
+
@cache.read(window_key(key, window), raw: true).to_i
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_counter_ttl(_key, window)
|
|
27
|
+
window = positive_window(window)
|
|
28
|
+
window - (wall_time % window)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_counter(key, window)
|
|
32
|
+
@cache.delete(window_key(key, window))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear(pattern = nil)
|
|
36
|
+
@cache.delete_matched(matcher_for("#{@prefix}:#{pattern || '*'}"))
|
|
37
|
+
rescue NotImplementedError
|
|
38
|
+
raise UnsupportedOperationError,
|
|
39
|
+
"#{@cache.class} cannot delete by pattern; reset limiters individually with Limiter#reset!"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def healthy?
|
|
43
|
+
!@cache.increment("#{@prefix}:health", 1, expires_in: 60).nil?
|
|
44
|
+
rescue StandardError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def window_key(key, window)
|
|
51
|
+
window = positive_window(window)
|
|
52
|
+
"#{@prefix}:#{key}:#{window}:#{(wall_time / window).floor}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def positive_window(window)
|
|
56
|
+
window = window.to_f
|
|
57
|
+
raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
|
|
58
|
+
|
|
59
|
+
window
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def matcher_for(glob)
|
|
63
|
+
return glob.gsub(GLOB_SPECIALS) { |char| "\\#{char}" } if @cache.respond_to?(:redis)
|
|
64
|
+
|
|
65
|
+
Regexp.new("^#{Regexp.escape(glob).gsub('\*', '.*')}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def wall_time
|
|
69
|
+
Time.now.to_f
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/throttle_machines.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: throttle_machines
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.4
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -70,16 +70,16 @@ dependencies:
|
|
|
70
70
|
name: breaker_machines
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
72
72
|
requirements:
|
|
73
|
-
- - "
|
|
73
|
+
- - ">="
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
|
-
version:
|
|
75
|
+
version: 0.22.1
|
|
76
76
|
type: :runtime
|
|
77
77
|
prerelease: false
|
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
79
|
requirements:
|
|
80
|
-
- - "
|
|
80
|
+
- - ">="
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
|
-
version:
|
|
82
|
+
version: 0.22.1
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
84
|
name: chrono_machines
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -172,6 +172,7 @@ files:
|
|
|
172
172
|
- lib/throttle_machines/rack_middleware/throttle.rb
|
|
173
173
|
- lib/throttle_machines/rack_middleware/track.rb
|
|
174
174
|
- lib/throttle_machines/storage/base.rb
|
|
175
|
+
- lib/throttle_machines/storage/cache.rb
|
|
175
176
|
- lib/throttle_machines/storage/memory.rb
|
|
176
177
|
- lib/throttle_machines/storage/null.rb
|
|
177
178
|
- lib/throttle_machines/storage/redis.rb
|
|
@@ -181,6 +182,7 @@ files:
|
|
|
181
182
|
- lib/throttle_machines/storage/redis/peek_token_bucket.lua
|
|
182
183
|
- lib/throttle_machines/storage/redis/token_bucket.lua
|
|
183
184
|
- lib/throttle_machines/throttled_error.rb
|
|
185
|
+
- lib/throttle_machines/unsupported_operation_error.rb
|
|
184
186
|
- lib/throttle_machines/version.rb
|
|
185
187
|
- lib/throttle_machines_native/throttle_machines_native.so
|
|
186
188
|
homepage: https://github.com/seuros/throttle_machines
|