throttle_machines 0.3.1-aarch64-linux → 0.3.4-aarch64-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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 509526a077fdf01abb15d9abe3f987b47860cfc2809c5ed6e013f14892419c88
4
- data.tar.gz: ee7ceb36ff48952212f86594d91fc31e670dd87989c82f7097a4cb2c671d3e4d
3
+ metadata.gz: cf82a5943916b99c3c670f1a40f8b56846dd2271d0b1e52ac8d360b9f179980b
4
+ data.tar.gz: 27fac49e6a413ee51444304bdfaa048f992bb2ef6c28a5a4b06b2d9ac956bd94
5
5
  SHA512:
6
- metadata.gz: 1545196f2c54252b9e7388d508e99b4449b2249a4d3eda4a02fe19fd73a87ae9498bccacc589822e0e20434c6d83bc4b941869ec0755275be4717d545d554197
7
- data.tar.gz: 57a55483b2b3af696c09da7fe9998b260ac3486054cfccdb23feba512a06c9644eece4eac4ec8aa54f84946103a330542a3cf1ea9ac6e4a91dfa2cf4f7318b58
6
+ metadata.gz: c797ad1427d795c2003376798713b0c7ba9af1b63b3d927931e617a626b0fee4aed5febaac367f2ebf15d8fd5b4f7fa2f35e42f7df1bab64903c90c527ab180e
7
+ data.tar.gz: f6f38cccc1f50c4cac5b9ba62167e51daf99ce5fe09b18517a63d1f06563426ec35bfbb43780b981b19bda526795ba92628dad3edfbe09ccd5f7fd11d6c86403
data/README.md CHANGED
@@ -100,6 +100,60 @@ end
100
100
 
101
101
  ---
102
102
 
103
+ ## Bounded in-memory storage
104
+
105
+ The memory backend tracks at most **65536 entries**, shared across fixed-window
106
+ counters, GCRA states, and token buckets, including when native speedups are loaded.
107
+
108
+ ```ruby
109
+ ThrottleMachines.storage = ThrottleMachines::Storage::Memory.new(max_keys: 10_000)
110
+ ```
111
+
112
+ When full, expired entries are reclaimed before admission. If no slot is
113
+ available, new identities are throttled; active identities are never evicted
114
+ to make room and can continue under their existing limits. Peeks also fail
115
+ closed for untrackable identities. Capacity refusals use a one-second retry
116
+ hint, not a reservation. At saturation, on-demand expiry scans run at most
117
+ once per second, even with `auto_cleanup: false`.
118
+
119
+ `max_keys` must be a non-negative integer; zero denies all identities. For
120
+ direct storage callers, an untrackable fixed-window counter reads/increments
121
+ as `Float::INFINITY` so it cannot pass a finite rate limit.
122
+
123
+ This bounds entry count, not bytes: also bound untrusted identity lengths.
124
+ Redis storage uses TTLs but has no application-level key cap; configure Redis
125
+ memory limits separately. Evicting active Redis keys can reset their limits.
126
+
127
+ ---
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
+
103
157
  ## 🌠 The Ultra-Thin Philosophy
104
158
 
105
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
@@ -21,6 +21,7 @@ begin
21
21
  # Native GCRA implementation using Rust.
22
22
  module NativeGCRA
23
23
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
24
+ reclaim_capacity
24
25
  with_write_lock(key) do
25
26
  now = current_time
26
27
  state = @gcra_states[key] || { tat: 0.0 }
@@ -30,15 +31,20 @@ begin
30
31
  state[:tat], now, emission_interval, delay_tolerance
31
32
  ) => { allowed:, state: new_tat, retry_after: }
32
33
 
33
- @gcra_states[key] = { tat: new_tat, expires_at: now + ttl } if allowed
34
+ if allowed && !store_entry(@gcra_states, key, { tat: new_tat, expires_at: now + ttl })
35
+ return capacity_gcra_refusal(now, delay_tolerance)
36
+ end
34
37
 
35
38
  { allowed: allowed, retry_after: retry_after, tat: new_tat }
36
39
  end
37
40
  end
38
41
 
39
42
  def peek_gcra_limit(key, _emission_interval, delay_tolerance)
43
+ reclaim_capacity
40
44
  with_read_lock(key) do
41
45
  now = current_time
46
+ return capacity_gcra_refusal(now, delay_tolerance) unless capacity_available?(@gcra_states, key)
47
+
42
48
  state = @gcra_states[key] || { tat: 0.0 }
43
49
 
44
50
  # Rust does the math
@@ -54,6 +60,7 @@ begin
54
60
  # Native Token Bucket implementation using Rust.
55
61
  module NativeTokenBucket
56
62
  def check_token_bucket(key, capacity, refill_rate, ttl)
63
+ reclaim_capacity
57
64
  with_write_lock(key) do
58
65
  now = current_time
59
66
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
@@ -64,7 +71,9 @@ begin
64
71
  ) => { allowed:, state: new_tokens, retry_after: }
65
72
 
66
73
  if allowed
67
- @token_buckets[key] = { tokens: new_tokens, last_refill: now, expires_at: now + ttl }
74
+ unless store_entry(@token_buckets, key, { tokens: new_tokens, last_refill: now, expires_at: now + ttl })
75
+ return capacity_token_bucket_refusal
76
+ end
68
77
  end
69
78
 
70
79
  { allowed: allowed, retry_after: retry_after, tokens_remaining: new_tokens.floor }
@@ -72,8 +81,11 @@ begin
72
81
  end
73
82
 
74
83
  def peek_token_bucket(key, capacity, refill_rate)
84
+ reclaim_capacity
75
85
  with_read_lock(key) do
76
86
  now = current_time
87
+ return capacity_token_bucket_refusal unless capacity_available?(@token_buckets, key)
88
+
77
89
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
78
90
 
79
91
  # Rust does the math
@@ -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
- raise NotImplementedError
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
@@ -5,8 +5,20 @@ require 'concurrent'
5
5
  module ThrottleMachines
6
6
  module Storage
7
7
  class Memory < Base
8
+ DEFAULT_MAX_KEYS = 65_536
9
+ CAPACITY_RETRY_AFTER = 1.0
10
+
8
11
  def initialize(options = {})
9
12
  super
13
+ @max_keys = options.fetch(:max_keys, DEFAULT_MAX_KEYS)
14
+ unless @max_keys.is_a?(Integer) && @max_keys >= 0
15
+ raise ArgumentError, 'max_keys must be a non-negative integer'
16
+ end
17
+
18
+ @stored_keys = 0
19
+ @capacity_lock = Mutex.new
20
+ @capacity_cleanup_lock = Mutex.new
21
+ @next_capacity_cleanup_at = -Float::INFINITY
10
22
  @counters = Concurrent::Hash.new
11
23
  @gcra_states = Concurrent::Hash.new
12
24
  @token_buckets = Concurrent::Hash.new
@@ -31,6 +43,7 @@ module ThrottleMachines
31
43
  # Rate limiting operations
32
44
  def increment_counter(key, window, amount = 1)
33
45
  window_key = "#{key}:#{window}"
46
+ reclaim_capacity
34
47
 
35
48
  with_write_lock(window_key) do
36
49
  now = current_time
@@ -40,22 +53,24 @@ module ThrottleMachines
40
53
  if counter.nil? || counter[:expires_at] <= now
41
54
  # Create or reset counter atomically
42
55
  new_count = amount
43
- @counters[window_key] = { count: new_count, expires_at: now + window }
56
+ stored = store_entry(@counters, window_key, { count: new_count, expires_at: now + window })
44
57
  else
45
58
  # Increment existing counter atomically
46
59
  new_count = counter[:count] + amount
47
- @counters[window_key] = { count: new_count, expires_at: counter[:expires_at] }
60
+ stored = store_entry(@counters, window_key, { count: new_count, expires_at: counter[:expires_at] })
48
61
  end
49
- new_count
62
+ # An untrackable counter must never look like an admitted request.
63
+ stored ? new_count : Float::INFINITY
50
64
  end
51
65
  end
52
66
 
53
67
  def get_counter(key, window)
54
68
  window_key = "#{key}:#{window}"
69
+ reclaim_capacity
55
70
 
56
71
  with_read_lock(window_key) do
57
72
  counter = @counters[window_key]
58
- return 0 unless counter
73
+ return capacity_available?(@counters, window_key) ? 0 : Float::INFINITY unless counter
59
74
  return 0 if counter[:expires_at] <= current_time
60
75
 
61
76
  counter[:count]
@@ -67,7 +82,7 @@ module ThrottleMachines
67
82
 
68
83
  with_read_lock(window_key) do
69
84
  counter = @counters[window_key]
70
- return 0 unless counter
85
+ return capacity_available?(@counters, window_key) ? 0 : CAPACITY_RETRY_AFTER unless counter
71
86
 
72
87
  ttl = counter[:expires_at] - current_time
73
88
  [ttl, 0].max
@@ -76,11 +91,12 @@ module ThrottleMachines
76
91
 
77
92
  def reset_counter(key, window)
78
93
  window_key = "#{key}:#{window}"
79
- with_write_lock(window_key) { @counters.delete(window_key) }
94
+ with_write_lock(window_key) { delete_entry(@counters, window_key) }
80
95
  end
81
96
 
82
97
  # GCRA operations (atomic simulation)
83
98
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
99
+ reclaim_capacity
84
100
  with_write_lock(key) do
85
101
  now = current_time
86
102
  state = @gcra_states[key] || { tat: 0.0 }
@@ -90,7 +106,9 @@ module ThrottleMachines
90
106
 
91
107
  if allow
92
108
  new_tat = tat + emission_interval
93
- @gcra_states[key] = { tat: new_tat, expires_at: now + ttl }
109
+ unless store_entry(@gcra_states, key, { tat: new_tat, expires_at: now + ttl })
110
+ return capacity_gcra_refusal(now, delay_tolerance)
111
+ end
94
112
  end
95
113
 
96
114
  {
@@ -102,8 +120,11 @@ module ThrottleMachines
102
120
  end
103
121
 
104
122
  def peek_gcra_limit(key, _emission_interval, delay_tolerance)
123
+ reclaim_capacity
105
124
  with_read_lock(key) do
106
125
  now = current_time
126
+ return capacity_gcra_refusal(now, delay_tolerance) unless capacity_available?(@gcra_states, key)
127
+
107
128
  state = @gcra_states[key] || { tat: 0.0 }
108
129
 
109
130
  tat = [state[:tat], now].max
@@ -119,6 +140,7 @@ module ThrottleMachines
119
140
 
120
141
  # Token bucket operations (atomic simulation)
121
142
  def check_token_bucket(key, capacity, refill_rate, ttl)
143
+ reclaim_capacity
122
144
  with_write_lock(key) do
123
145
  now = current_time
124
146
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
@@ -132,7 +154,9 @@ module ThrottleMachines
132
154
  # Check if we can consume a token
133
155
  if token_available?(bucket[:tokens], now, refill_rate)
134
156
  bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
135
- @token_buckets[key] = bucket.merge(expires_at: now + ttl)
157
+ unless store_entry(@token_buckets, key, bucket.merge(expires_at: now + ttl))
158
+ return capacity_token_bucket_refusal
159
+ end
136
160
 
137
161
  {
138
162
  allowed: true,
@@ -152,8 +176,11 @@ module ThrottleMachines
152
176
  end
153
177
 
154
178
  def peek_token_bucket(key, capacity, refill_rate)
179
+ reclaim_capacity
155
180
  with_read_lock(key) do
156
181
  now = current_time
182
+ return capacity_token_bucket_refusal unless capacity_available?(@token_buckets, key)
183
+
157
184
  bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
158
185
 
159
186
  # Calculate tokens without modifying state
@@ -188,14 +215,17 @@ module ThrottleMachines
188
215
 
189
216
  # Clear matching keys from all stores
190
217
  [@counters, @gcra_states, @token_buckets].each do |store|
191
- store.each_key do |k|
192
- store.delete(k) if k&.match?(regex)
218
+ store.keys.each do |k|
219
+ with_write_lock(k) { delete_entry(store, k) } if k&.match?(regex)
193
220
  end
194
221
  end
195
222
  else
196
- @counters.clear
197
- @gcra_states.clear
198
- @token_buckets.clear
223
+ @capacity_lock.synchronize do
224
+ @counters.clear
225
+ @gcra_states.clear
226
+ @token_buckets.clear
227
+ @stored_keys = 0
228
+ end
199
229
  end
200
230
  end
201
231
 
@@ -212,6 +242,57 @@ module ThrottleMachines
212
242
 
213
243
  private
214
244
 
245
+ # Called with the key's write lock held. The short admission lock makes
246
+ # the cap atomic across stripes and all three stores.
247
+ def store_entry(store, key, value)
248
+ @capacity_lock.synchronize do
249
+ new_key = !store.key?(key)
250
+ return false if new_key && @stored_keys >= @max_keys
251
+
252
+ store[key] = value
253
+ @stored_keys += 1 if new_key
254
+ true
255
+ end
256
+ end
257
+
258
+ def delete_entry(store, key)
259
+ @capacity_lock.synchronize do
260
+ value = store.delete(key)
261
+ @stored_keys -= 1 if value
262
+ value
263
+ end
264
+ end
265
+
266
+ def capacity_available?(store, key)
267
+ @capacity_lock.synchronize { store.key?(key) || @stored_keys < @max_keys }
268
+ end
269
+
270
+ def capacity_gcra_refusal(now, delay_tolerance)
271
+ { allowed: false, retry_after: CAPACITY_RETRY_AFTER, tat: now + delay_tolerance + CAPACITY_RETRY_AFTER }
272
+ end
273
+
274
+ def capacity_token_bucket_refusal
275
+ { allowed: false, retry_after: CAPACITY_RETRY_AFTER, tokens_remaining: 0 }
276
+ end
277
+
278
+ # Run before taking any striped lock. At saturation, attempt expiry
279
+ # cleanup at most once per second, even with auto_cleanup disabled.
280
+ # Rejected identity sprays must not trigger a full scan on every call.
281
+ def reclaim_capacity
282
+ return unless @capacity_lock.synchronize { @stored_keys >= @max_keys }
283
+ return unless @capacity_cleanup_lock.try_lock
284
+
285
+ begin
286
+ now = current_time
287
+ return if now < @next_capacity_cleanup_at
288
+
289
+ @next_capacity_cleanup_at = now + CAPACITY_RETRY_AFTER
290
+ clean_expired_entries
291
+ ensure
292
+ @capacity_cleanup_lock.unlock
293
+ end
294
+ end
295
+
215
296
  def with_read_lock(key, &block)
216
297
  lock_for(key).with_read_lock(&block)
217
298
  end
@@ -242,19 +323,14 @@ module ThrottleMachines
242
323
  def clean_expired_entries
243
324
  now = current_time
244
325
 
245
- # Clean expired counters
246
- @counters.each_pair do |key, data|
247
- with_write_lock(key) { @counters.delete(key) } if data[:expires_at] && data[:expires_at] <= now
248
- end
249
-
250
- # Clean expired GCRA states
251
- @gcra_states.each_pair do |key, data|
252
- with_write_lock(key) { @gcra_states.delete(key) } if data[:expires_at] && data[:expires_at] <= now
253
- end
254
-
255
- # Clean expired token buckets
256
- @token_buckets.each_pair do |key, data|
257
- with_write_lock(key) { @token_buckets.delete(key) } if data[:expires_at] && data[:expires_at] <= now
326
+ [@counters, @gcra_states, @token_buckets].each do |store|
327
+ store.keys.each do |key|
328
+ with_write_lock(key) do
329
+ # Re-read under the lock: a writer may have refreshed the TTL.
330
+ data = store[key]
331
+ delete_entry(store, key) if data && data[:expires_at] && data[:expires_at] <= now
332
+ end
333
+ end
258
334
  end
259
335
 
260
336
  # No breaker state cleanup: breaker state is managed by BreakerMachines
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class UnsupportedOperationError < StandardError; end
5
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThrottleMachines
4
- VERSION = '0.3.1'
4
+ VERSION = '0.3.4'
5
5
  end
@@ -12,6 +12,7 @@ require 'breaker_machines'
12
12
  # Set up Zeitwerk loader
13
13
  loader = Zeitwerk::Loader.for_gem
14
14
  loader.ignore("#{__dir__}/throttle_machines/engine.rb") unless defined?(Rails::Engine)
15
+ loader.ignore("#{__dir__}/throttle_machines/native_speedup.rb")
15
16
  loader.setup
16
17
 
17
18
  module ThrottleMachines
@@ -158,6 +159,8 @@ module ThrottleMachines
158
159
  case symbol
159
160
  when :memory
160
161
  Storage::Memory.new
162
+ when :cache
163
+ Storage::Cache.new
161
164
  when :redis
162
165
  raise ArgumentError, 'Redis storage requires redis gem' unless defined?(Redis)
163
166
 
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.1
4
+ version: 0.3.4
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
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: '0.20'
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: '0.20'
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