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 +4 -4
- data/.github/workflows/test.yml +3 -2
- data/.rubocop.yml +3 -0
- data/README.md +91 -11
- data/Rakefile +27 -3
- data/lib/speed_limiter/config.rb +3 -2
- data/lib/speed_limiter/redis.rb +41 -0
- data/lib/speed_limiter/state.rb +26 -0
- data/lib/speed_limiter/throttle.rb +59 -0
- data/lib/speed_limiter/throttle_params.rb +26 -0
- data/lib/speed_limiter/version.rb +1 -1
- data/lib/speed_limiter.rb +10 -46
- metadata +7 -3
- /data/{speed_limitter.gemspec → speed_limiter.gemspec} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 49d4e898db4491c876e3cdf95a157aa2b9b832444c10a62598dae6214204e864
|
4
|
+
data.tar.gz: f6c223a5b30ab88f844de353e9d4a893d40e629c7602ab521bf59d30a6e314e8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eb26517138fca5d75e5a43a45765ccc48b54586e9bc38b7b644663a8954f7102a513d36e6a8e94575561ad575256c52aa2c1931f481c1f65c64a6a83c663be08
|
7
|
+
data.tar.gz: 54edbdd81701def3bdc0c1ac738c835198245ca187cfe53393e3887c6a59691332d957d7c0d8cd7362feb3ba18e440524d075bbb493a7b61fc21c929de90a86f
|
data/.github/workflows/test.yml
CHANGED
@@ -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
|
41
|
-
run: bundle exec
|
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
data/README.md
CHANGED
@@ -1,12 +1,14 @@
|
|
1
|
-
[](https://github.com/seibii/speed_limiter/actions/workflows/lint.yml) [](https://github.com/seibii/speed_limiter/actions/workflows/test.yml) [](https://badge.fury.io/rb/speed_limiter) [](https://github.com/seibii/speed_limiter/actions/workflows/lint.yml) [](https://github.com/seibii/speed_limiter/actions/workflows/test.yml) [](https://badge.fury.io/rb/speed_limiter) [](LICENSE)
|
2
2
|
|
3
3
|
# SpeedLimiter
|
4
4
|
|
5
5
|
<img src="README_image.jpg" width="400px" />
|
6
6
|
|
7
|
-
|
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
|
-
|
31
|
-
SpeedLimiter
|
32
|
-
|
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
|
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
|
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 :
|
16
|
-
desc "Run rackup for
|
17
|
-
task :
|
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
|
data/lib/speed_limiter/config.rb
CHANGED
@@ -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
|
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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
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
|
+
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
|
-
-
|
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
|