throttle_machines 0.3.3 → 0.3.4

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: a080af0b8e5ed4c52fc3f7df63757a7b8a91beb76124f49ddd6ff06ae02e8072
4
- data.tar.gz: f4ee5682bb2c846e4ec69364da967127285d186b379d76c2b378bdb99ff92675
3
+ metadata.gz: 72c8aa3de40f786c3636692e1c843a3bc06f74e5e18070c8192ada2b4b5dc1af
4
+ data.tar.gz: e13536b0ee7936a34e7012598fa053bdfac68f90cc0bf3d89eee93908bc02e70
5
5
  SHA512:
6
- metadata.gz: 7d10328c5affbb67291563d9190c7395b6800c2e9b7085789afcf7e891693c3d00116d5d63dc6bf634eb7b96e6c60e74e65118629702ca7bedc68e7867f90a21
7
- data.tar.gz: eed2c6323e3ba2f52901ef5280d1fee89ca5e0a8d9f0e316c54ca7151e2870f452a5f59d30096b8d216aa37f4585beb96213ab5190f07d157d8ec31310d1ceb6
6
+ metadata.gz: 210c43367129d549efa9527e01cc37621763c5b8cc818bc3bd980f2304b487792d7138d711c8e621970199534b59e16e3f5604c0ba69061d0fe00837c3bba4c1
7
+ data.tar.gz: 400a4dbe1e43b61ffc79b60ef9bf85819edaa0204294264e4c582d871a0f4086c867181377db507fd45a186b1caf663e78aef90711d811591e96387ab070c7f7
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,7 +1,7 @@
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: 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: '0.20'
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: '0.20'
81
+ version: 0.22.1
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: chrono_machines
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -185,6 +185,7 @@ files:
185
185
  - lib/throttle_machines/rack_middleware/throttle.rb
186
186
  - lib/throttle_machines/rack_middleware/track.rb
187
187
  - lib/throttle_machines/storage/base.rb
188
+ - lib/throttle_machines/storage/cache.rb
188
189
  - lib/throttle_machines/storage/memory.rb
189
190
  - lib/throttle_machines/storage/null.rb
190
191
  - lib/throttle_machines/storage/redis.rb
@@ -194,6 +195,7 @@ files:
194
195
  - lib/throttle_machines/storage/redis/peek_token_bucket.lua
195
196
  - lib/throttle_machines/storage/redis/token_bucket.lua
196
197
  - lib/throttle_machines/throttled_error.rb
198
+ - lib/throttle_machines/unsupported_operation_error.rb
197
199
  - lib/throttle_machines/version.rb
198
200
  homepage: https://github.com/seuros/throttle_machines
199
201
  licenses: