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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8094e1210ce074e1bec5f730d483a1fd3968ffcc3b3cb10b2c1705313a6c3fee
4
- data.tar.gz: 415ff2e1267150c4c466545cfeefe01e9ea30ba6502ddc7e510526bfcbe2679f
3
+ metadata.gz: 2c66ed002f19fead80fd931c2fef895b7ac3778437d72d9ff01642b1876ae7e4
4
+ data.tar.gz: 364c295404364d6206048e7a7f350907400450cfd47e73cbcd832294f00aa592
5
5
  SHA512:
6
- metadata.gz: b1024d836469d3500560e115232ed349e72b584e734f1ee2d5c39c399c2a760274662383bb7d20f82b104cd2742216f6634e4c590bb7f4335158fa5bf1711220
7
- data.tar.gz: 1ea545b6198221294e78abeb5240741d26d3d36f61581610fe508a21e4a32413a7596079d9cbd4973028bfa696262918faff497b6c0eaecfdcb90d4414c80056
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
- 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
@@ -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.3'
4
+ VERSION = '0.3.4'
5
5
  end
@@ -159,6 +159,8 @@ module ThrottleMachines
159
159
  case symbol
160
160
  when :memory
161
161
  Storage::Memory.new
162
+ when :cache
163
+ Storage::Cache.new
162
164
  when :redis
163
165
  raise ArgumentError, 'Redis storage requires redis gem' unless defined?(Redis)
164
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.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-27 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