speed_limiter 0.0.1 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee6151563233234c9c453843d4d3d98e8de465905e38e99437caaa6a0e258d6b
4
- data.tar.gz: 9efaa7b0784d79a9a23c77015013519380d20b94645e39e725d390030ad48569
3
+ metadata.gz: 49d4e898db4491c876e3cdf95a157aa2b9b832444c10a62598dae6214204e864
4
+ data.tar.gz: f6c223a5b30ab88f844de353e9d4a893d40e629c7602ab521bf59d30a6e314e8
5
5
  SHA512:
6
- metadata.gz: a9672f7b225e6325589c9bb8fae4cc76ad860024b9fbbcdb6f10edc5d4137ac593b4d6ac5370e6490b1ccc1186b0032bf20d572644ec8177afa783d5603cfe2d
7
- data.tar.gz: 74020c4965b083e2cea5af94a05299455d2ee64060fc008e8fa9b7b5afacae0a587d432925ece9f7efb860388fa15886742d7c5d722751cdb877d6bf31d30110
6
+ metadata.gz: eb26517138fca5d75e5a43a45765ccc48b54586e9bc38b7b644663a8954f7102a513d36e6a8e94575561ad575256c52aa2c1931f481c1f65c64a6a83c663be08
7
+ data.tar.gz: 54edbdd81701def3bdc0c1ac738c835198245ca187cfe53393e3887c6a59691332d957d7c0d8cd7362feb3ba18e440524d075bbb493a7b61fc21c929de90a86f
@@ -18,6 +18,7 @@ jobs:
18
18
  timeout-minutes: 10
19
19
 
20
20
  strategy:
21
+ max-parallel: 6
21
22
  matrix:
22
23
  ruby-version: ['3.0', '3.1', '3.2', ruby-head]
23
24
  redis-version: ['5.0', '6.0', '6.2', '7.0', '7.2', latest]
@@ -37,8 +38,8 @@ jobs:
37
38
  ruby-version: ${{ matrix.ruby-version }}
38
39
  bundler-cache: true
39
40
 
40
- - name: Rackup test throttle server
41
- run: bundle exec pumad -C throttle_server/puma.rb throttle_server/config.ru
41
+ - name: Rackup test web server
42
+ run: bundle exec rake throttle_server:start_daemon
42
43
 
43
44
  - name: Run tests
44
45
  run: bundle exec rspec -fd
data/.rubocop.yml CHANGED
@@ -18,3 +18,6 @@ Rspec/MultipleExpectations:
18
18
  Rspec/ExampleLength:
19
19
  Enabled: false
20
20
 
21
+ Metrics/BlockLength:
22
+ Exclude:
23
+ - 'Rakefile'
data/README.md CHANGED
@@ -1,12 +1,14 @@
1
- [![lint](https://github.com/seibii/speed_limiter/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/seibii/speed_limiter/actions/workflows/lint.yml) [![test](https://github.com/seibii/speed_limiter/actions/workflows/test.yml/badge.svg)](https://github.com/seibii/speed_limiter/actions/workflows/test.yml) [![Gem Version](https://badge.fury.io/rb/speed_limiter.svg)](https://badge.fury.io/rb/speed_limiter) [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
1
+ [![lint](https://github.com/seibii/speed_limiter/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/seibii/speed_limiter/actions/workflows/lint.yml) [![test](https://github.com/seibii/speed_limiter/actions/workflows/test.yml/badge.svg)](https://github.com/seibii/speed_limiter/actions/workflows/test.yml) [![Gem Version](https://badge.fury.io/rb/speed_limiter.svg)](https://badge.fury.io/rb/speed_limiter) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
2
2
 
3
3
  # SpeedLimiter
4
4
 
5
5
  <img src="README_image.jpg" width="400px" />
6
6
 
7
- This is a Gem for execution limits in multi-process and multi-threaded environments. By using Redis, you can limit execution across multiple processes and threads.
7
+ SpeedLimiter is a gem that limits the number of executions per unit of time.
8
+ By default, it achieves throttling through sleep.
9
+
10
+ You can also use the `on_throttled` event to raise an exception instead of sleeping, or to re-enqueue the task.
8
11
 
9
- It was mainly created to avoid hitting access limits to the API server.
10
12
 
11
13
  ## Installation
12
14
 
@@ -26,19 +28,65 @@ Or install it yourself as:
26
28
 
27
29
  ## Usage
28
30
 
31
+ Limit the number of executions to 10 times per second.
32
+
29
33
  ```ruby
30
- # Limit the number of executions to 10 times per second
31
- SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1) do
32
- # Do something
34
+ SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1) do |state|
35
+ puts state #=> <SpeedLimiter::State key=server_name/method_name count=1 ttl=0>
36
+ http.get(path)
37
+ end
38
+ ```
39
+
40
+ It returns the result of the block execution.
41
+
42
+ ```ruby
43
+ result = SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1) do
44
+ http.get(path)
45
+ end
46
+ puts result.code #=> 200
47
+ ```
48
+
49
+ Specify the process when the limit is exceeded.
50
+
51
+ ```ruby
52
+ on_throttled = proc { |state| logger.info("limit exceeded #{state.key} #{state.ttl}") }
53
+ SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1, on_throttled: on_throttled) do
54
+ http.get(path)
55
+ end
56
+ ```
57
+
58
+ Reinitialize the queue instead of sleeping when the limit is reached in ActiveJob.
59
+
60
+ ```ruby
61
+ class CreateSlackChannelJob < ApplicationJob
62
+ def perform(*args)
63
+ on_throttled = proc do |state|
64
+ raise Slack::LimitExceeded, state.ttl if state.ttl > 5
65
+ end
66
+
67
+ SpeedLimiter.throttle("slack", limit: 20, period: 1.minute, on_throttled: on_throttled) do
68
+ create_slack_channel(*args)
69
+ end
70
+ rescue Slack::LimitExceeded => e
71
+ self.class.set(wait: e.ttl).perform_later(*args)
72
+ end
33
73
  end
34
74
  ```
35
75
 
36
76
  ### Configuration
37
77
 
78
+ #### Redis configuration
79
+
80
+ Redis can be specified as follows
81
+
82
+ 1. default url `redis://localhost:6379/0`
83
+ 2. `SPEED_LIMITER_REDIS_URL` environment variable
84
+ 3. Configure `SpeedLimiter.configure`
85
+
38
86
  ```ruby
39
87
  # config/initializers/speed_limiter.rb
40
88
  SpeedLimiter.configure do |config|
41
- config.redis_url = ENV['SPEED_LIMITER_REDIS_URL'] || 'redis://localhost:6379/2'
89
+ config.redis_url = ENV.fetch('SPEED_LIMITER_REDIS_URL', 'redis://localhost:6379/2')
42
90
  end
43
91
  ```
44
92
 
@@ -51,6 +99,20 @@ SpeedLimiter.configure do |config|
51
99
  end
52
100
  ```
53
101
 
102
+ #### Other configuration defaults
103
+
104
+ ```ruby
105
+ SpeedLimiter.configure do |config|
106
+ config.redis_url = ENV.fetch("SPEED_LIMITER_REDIS_URL", "redis://localhost:6379/0")
107
+ config.redis = nil
108
+ config.no_limit = false # If true, it will not be throttled
109
+ config.prefix = "speed_limiter" # Redis key prefix
110
+ config.on_throttled = nil # Proc to be executed when the limit is exceeded
111
+ end
112
+ ```
113
+
114
+ #### Example
115
+
54
116
  If you do not want to impose a limit in the test environment, please set it as follows.
55
117
 
56
118
  ```ruby
@@ -64,6 +126,24 @@ RSpec.configure do |config|
64
126
  end
65
127
  ```
66
128
 
129
+ If you want to detect the limit in the test environment, please set it as follows.
130
+
131
+ ```ruby
132
+ Rspec.describe do
133
+ around do |example|
134
+ SpeedLimiter.config.on_throttled = proc { |state| raise "limit exceeded #{state.key} #{state.ttl}" }
135
+
136
+ example.run
137
+
138
+ SpeedLimiter.config.on_throttled = nil
139
+ end
140
+
141
+ it do
142
+ expect { over_limit_method }.to raise_error('limit exceeded key_name [\d.]+')
143
+ end
144
+ end
145
+ ```
146
+
67
147
  ## Compatibility
68
148
 
69
149
  SpeedLimiter officially supports the following Ruby implementations and Redis :
@@ -82,11 +162,11 @@ You can also run bin/console for an interactive prompt that will allow you to ex
82
162
 
83
163
  ### rspec
84
164
 
85
- Start a web server and Redis for testing with the following command.
165
+ Start a test web server and Redis for testing with the following command.
86
166
 
87
- ```
88
- $ rake test:throttle_server
89
- $ docker compose up
167
+ ```console
168
+ $ rake throttle_server:start_daemon # or rake throttle_server:start
169
+ $ docker compose up -d
90
170
  ```
91
171
 
92
172
  After that, please run the test with the following command.
data/Rakefile CHANGED
@@ -12,9 +12,33 @@ RuboCop::RakeTask.new
12
12
 
13
13
  task default: :spec
14
14
 
15
- namespace :test do
16
- desc "Run rackup for throttle server daemon"
17
- task :throttle_server do
15
+ namespace :throttle_server do
16
+ desc "Run rackup for Throttle server"
17
+ task :start do
18
18
  system "puma -C throttle_server/puma.rb throttle_server/config.ru"
19
19
  end
20
+
21
+ desc "Run rackup for Throttle server daemon"
22
+ task :start_daemon do
23
+ system "pumad -C throttle_server/puma.rb throttle_server/config.ru"
24
+ end
25
+
26
+ desc "Stop the Throttle server daemon"
27
+ task :stop do
28
+ pid_file = "throttle_server/tmp/puma.pid"
29
+
30
+ if File.exist?(pid_file)
31
+ pid = File.read(pid_file).to_i
32
+ begin
33
+ Process.kill("TERM", pid)
34
+ puts "Throttle server (PID: #{pid}) has been stopped."
35
+ rescue Errno::ESRCH
36
+ puts "Throttle server (PID: #{pid}) not found. It might have already stopped."
37
+ rescue StandardError => e
38
+ puts "Failed to stop Throttle server (PID: #{pid}): #{e.message}"
39
+ end
40
+ else
41
+ puts "PID file not found. Is the Throttle server running?"
42
+ end
43
+ end
20
44
  end
@@ -3,13 +3,14 @@
3
3
  module SpeedLimiter
4
4
  # config model
5
5
  class Config
6
- attr_accessor :redis_url, :redis, :no_limit, :prefix
6
+ attr_accessor :redis_url, :redis, :no_limit, :prefix, :on_throttled
7
7
 
8
8
  def initialize
9
- @redis_url = "redis://localhost:6379/0"
9
+ @redis_url = ENV.fetch("SPEED_LIMITER_REDIS_URL", "redis://localhost:6379/0")
10
10
  @redis = nil
11
11
  @no_limit = false
12
12
  @prefix = "speed_limiter"
13
+ @on_throttled = nil
13
14
  end
14
15
 
15
16
  alias no_limit? no_limit
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpeedLimiter
4
+ # Redis wrapper
5
+ class Redis
6
+ def initialize(redis)
7
+ @redis = redis
8
+ end
9
+ attr_reader :redis
10
+
11
+ def ttl(key)
12
+ redis.pttl(key) / 1000.0
13
+ end
14
+
15
+ def increment(key, period) # rubocop:disable Metrics/MethodLength
16
+ if supports_expire_nx?
17
+ count, ttl = redis.pipelined do |pipeline|
18
+ pipeline.incrby(key, 1)
19
+ pipeline.call(:expire, key, period.to_i, "NX")
20
+ end
21
+ else
22
+ count, ttl = redis.pipelined do |pipeline|
23
+ pipeline.incrby(key, 1)
24
+ pipeline.ttl(key)
25
+ end
26
+ redis.expire(key, period.to_i) if ttl.negative?
27
+ end
28
+
29
+ [count, ttl]
30
+ end
31
+
32
+ private
33
+
34
+ def supports_expire_nx?
35
+ return @supports_expire_nx if defined?(@supports_expire_nx)
36
+
37
+ redis_versions = redis.info("server")["redis_version"]
38
+ @supports_expire_nx = Gem::Version.new(redis_versions) >= Gem::Version.new("7.0.0")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "speed_limiter/state"
4
+ require "forwardable"
5
+
6
+ module SpeedLimiter
7
+ # Execution status model
8
+ class State
9
+ extend Forwardable
10
+
11
+ def initialize(params:, count:, ttl:)
12
+ @params = params
13
+ @count = count
14
+ @ttl = ttl
15
+ end
16
+
17
+ attr_reader :params, :count, :ttl
18
+
19
+ def_delegators(:params, :config, :key, :limit, :period, :on_throttled)
20
+
21
+ def inspect
22
+ "<#{self.class.name} key=#{key.inspect} count=#{count} ttl=#{ttl}>"
23
+ end
24
+ alias to_s inspect
25
+ end
26
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "speed_limiter/redis"
5
+ require "speed_limiter/throttle_params"
6
+
7
+ module SpeedLimiter
8
+ # with actual throttle limits
9
+ class Throttle
10
+ extend Forwardable
11
+
12
+ # @option params [String] :key key name
13
+ # @option params [Integer] :limit limit count per period
14
+ # @option params [Integer] :period period time (seconds)
15
+ # @option params [Proc] :on_throttled Block called when limit exceeded, with ttl(Float) and key as argument
16
+ # @yield [count] Block called to not reach limit
17
+ # @yieldparam count [Integer] count of period
18
+ # @yieldreturn [any] block return value
19
+ def initialize(config:, **params, &block)
20
+ @config = config
21
+ @params = ThrottleParams.new(config: config, **params)
22
+ @block = block
23
+ end
24
+ attr_reader :config, :params, :block
25
+
26
+ def_delegators(:params, :key, :redis_key, :limit, :period, :on_throttled, :create_state)
27
+
28
+ def throttle
29
+ return block.call(create_state) if config.no_limit?
30
+
31
+ loop do
32
+ count, ttl = redis.increment(redis_key, period)
33
+
34
+ break(block.call(create_state(count: count, ttl: ttl))) if count <= limit
35
+
36
+ wait_for_interval(count)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def wait_for_interval(count)
43
+ ttl = redis.ttl(redis_key)
44
+ return if ttl.negative?
45
+
46
+ config.on_throttled.call(create_state(count: count, ttl: ttl)) if config.on_throttled.respond_to?(:call)
47
+ on_throttled.call(create_state(count: count, ttl: ttl)) if on_throttled.respond_to?(:call)
48
+
49
+ ttl = redis.ttl(redis_key)
50
+ return if ttl.negative?
51
+
52
+ sleep ttl
53
+ end
54
+
55
+ def redis
56
+ @redis ||= SpeedLimiter::Redis.new(config.redis || ::Redis.new(url: config.redis_url))
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "speed_limiter/state"
4
+
5
+ module SpeedLimiter
6
+ # Throttle params model
7
+ class ThrottleParams
8
+ def initialize(config:, key:, limit:, period:, on_throttled: nil)
9
+ @config = config
10
+ @key = key
11
+ @limit = limit
12
+ @period = period
13
+ @on_throttled = on_throttled
14
+ end
15
+
16
+ attr_reader :config, :key, :limit, :period, :on_throttled
17
+
18
+ def redis_key
19
+ "#{config.prefix}:#{key}"
20
+ end
21
+
22
+ def create_state(count: nil, ttl: nil)
23
+ State.new(params: self, count: count, ttl: ttl)
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpeedLimiter
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/speed_limiter.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "speed_limiter/version"
4
4
  require "speed_limiter/config"
5
+ require "speed_limiter/throttle"
5
6
  require "redis"
6
7
 
7
8
  # Call speed limiter
@@ -19,52 +20,15 @@ module SpeedLimiter
19
20
  @redis ||= config.redis || Redis.new(url: config.redis_url)
20
21
  end
21
22
 
22
- def throttle(key, limit:, period:, &block)
23
- return block&.call if config.no_limit?
24
-
25
- key_name = "#{config.prefix}:#{key}"
26
- loop do
27
- count = increment(key_name, period)
28
-
29
- break(block&.call) if count <= limit
30
-
31
- wait_for_interval(key_name)
32
- end
33
- end
34
-
35
- private
36
-
37
- def wait_for_interval(key)
38
- pttl = redis.pttl(key)
39
- ttl = pttl / 1000.0
40
-
41
- return if ttl.negative?
42
-
43
- sleep ttl
44
- end
45
-
46
- def increment(key, period) # rubocop:disable Metrics/MethodLength
47
- if supports_expire_nx?
48
- count, = redis.pipelined do |pipeline|
49
- pipeline.incrby(key, 1)
50
- pipeline.call(:expire, key, period.to_i, "NX")
51
- end
52
- else
53
- count, ttl = redis.pipelined do |pipeline|
54
- pipeline.incrby(key, 1)
55
- pipeline.ttl(key)
56
- end
57
- redis.expire(key, period.to_i) if ttl.negative?
58
- end
59
-
60
- count
61
- end
62
-
63
- def supports_expire_nx?
64
- return @supports_expire_nx if defined?(@supports_expire_nx)
65
-
66
- redis_versions = redis.info("server")["redis_version"]
67
- @supports_expire_nx = Gem::Version.new(redis_versions) >= Gem::Version.new("7.0.0")
23
+ # @param key [String] key name
24
+ # @param limit [Integer] limit count per period
25
+ # @param period [Integer] period time (seconds)
26
+ # @param on_throttled [Proc] Block called when limit exceeded, with ttl(Float) and key as argument
27
+ # @yield [count] Block called to not reach limit
28
+ # @yieldparam count [Integer] count of period
29
+ # @yieldreturn [any] block return value
30
+ def throttle(key, **params, &block)
31
+ Throttle.new(config: config, key: key, **params, &block).throttle
68
32
  end
69
33
  end
70
34
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: speed_limiter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - yuhei mukoyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-11-30 00:00:00.000000000 Z
11
+ date: 2023-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -45,8 +45,12 @@ files:
45
45
  - compose.yaml
46
46
  - lib/speed_limiter.rb
47
47
  - lib/speed_limiter/config.rb
48
+ - lib/speed_limiter/redis.rb
49
+ - lib/speed_limiter/state.rb
50
+ - lib/speed_limiter/throttle.rb
51
+ - lib/speed_limiter/throttle_params.rb
48
52
  - lib/speed_limiter/version.rb
49
- - speed_limitter.gemspec
53
+ - speed_limiter.gemspec
50
54
  - throttle_server/config.ru
51
55
  - throttle_server/puma.rb
52
56
  - throttle_server/tmp/.keep
File without changes