simple_throttle 1.0.1 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: d3891997e2673589d91401b2f7c721786264c1b9
4
- data.tar.gz: b4bbcffae594f108495bfe7de6de20b00038f5d8
2
+ SHA256:
3
+ metadata.gz: 25cae7f3e3702407aca9ce8b20dd4a2016cdefffd9220261a0397150b10796de
4
+ data.tar.gz: f460f12908747e7d8d5431a7ed13274fa6eec4980a60b95949d78e553b297fb0
5
5
  SHA512:
6
- metadata.gz: d2da1554c54fdd458f698c66e0a246eaf856d89e5de639f73e7a8592a820972e6a11ea90259ea6421118ed15adb043cd3cd65f40147bbb9ca7a71b87542a023b
7
- data.tar.gz: b84de83b7b3dfbd61b2c60560b159e17e255cd125b58551620c5382b36cfdbfeb9df762a207493c62cea41639c3ec7ca274a61712f5d817f834464cafbed388c
6
+ metadata.gz: 96a6efaf9ed64b918ef077296bc1b04c36ef10f8e0e08948e505bf5203b79c4673a0435dc052c750249fb5dd7de1f9b71cab1790743aee10508bef72546bf3bc
7
+ data.tar.gz: 5dd505cf1da9506c505598013ab644367da24b9542687d5cf2e1f8a5cb571ad685388bd1dad8621598035d5810a615ce85baf65efc272aa6f1642fc637cf187b
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+ All notable changes to this project will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## 1.0.2
8
+
9
+ ### Added
10
+ - Throttle insances can now specify the Redis instance to override the global setting
11
+ - Redis instance now defaults to the default redis instance: `Redis.new`
12
+ - Optimize loading LUA script to Redis; now done globally instead of per throttle instance
13
+
14
+
15
+ ## 1.0.1
16
+
17
+ ### Added
18
+ - Added mutex in `SimpleThrottle.add` to ensure thread safety when adding global throttles.
19
+
20
+
21
+ ## 1.0.0
22
+
23
+ ### Added
24
+ - Simple Redis backed throttle for Ruby.
data/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ [![Maintainability](https://api.codeclimate.com/v1/badges/0535eef45908cc64b740/maintainability)](https://codeclimate.com/github/weheartit/simple_throttle/maintainability)
2
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
3
+
1
4
  This gem provides a very simple throttling mechanism backed by redis for limiting access to a resource. The throttle can be thought of as a limit on the number of calls in a set time frame (i.e. 100 calls per hour). These
2
5
 
3
6
  ## Usage
@@ -9,6 +12,9 @@ SimpleThrottle.set_redis(Redis.new)
9
12
  # ...or provide a block that returns a redis client
10
13
  SimpleThrottle.set_redis{ connection_pool.redis }
11
14
 
15
+ # ...or provide a Redis for a throttle to use
16
+ SimpleThrottle.new("user#{user.it}", limit: 10, ttl: 60, redis: Redis.new)
17
+
12
18
  # Add a global throttle (max of 10 requests in 60 seconds)
13
19
  SimpleThrottle.add(:things, limit: 10, ttl: 60)
14
20
 
@@ -32,4 +38,32 @@ Calling `allowed!` will return `true` if the throttle limit has not yet been rea
32
38
 
33
39
  The throttle data is kept in redis as a list of timestamps and will be auto expired if it falls out of use. The thottles time windows are rolling time windows and more calls will be allowed as soon as possible. So, if you have a throttle of, 100 requests per hour, and the throttle kicks in, you will be able to make the next throttled call one hour after the first call being tracked, not one hour after the last call.
34
40
 
35
- Redis server 2.6 or greater is required.
41
+ Redis server 2.6 or greater is required.
42
+
43
+ ## Installation
44
+
45
+ Add this line to your application's Gemfile:
46
+
47
+ ```ruby
48
+ gem 'simple_throttle'
49
+ ```
50
+
51
+ And then execute:
52
+ ```bash
53
+ $ bundle
54
+ ```
55
+
56
+ Or install it yourself as:
57
+ ```bash
58
+ $ gem install simple_throttle
59
+ ```
60
+
61
+ ## Contributing
62
+
63
+ Open a pull request on GitHub.
64
+
65
+ Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
66
+
67
+ ## License
68
+
69
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.1
1
+ 1.0.2
@@ -1,30 +1,60 @@
1
- require 'redis'
2
- require 'thread'
1
+ # frozen_string_literal: true
3
2
 
3
+ require "redis"
4
4
  # Create a simple throttle that can be used to limit the number of request for a resouce
5
5
  # per time period. These objects are thread safe.
6
6
  class SimpleThrottle
7
-
8
- @@lock = Mutex.new
9
-
7
+ # Server side Lua script that maintains the throttle in redis. The throttle is stored as a list
8
+ # of timestamps in milliseconds. When the script is invoked it will scan the oldest entries
9
+ # removing any that should be expired from the list. If the list is below the specified limit
10
+ # then the current entry will be added. The list is marked to expire with the oldest entry so
11
+ # there's no need to cleanup the lists.
12
+ LUA_SCRIPT = <<~LUA
13
+ local list_key = KEYS[1]
14
+ local limit = tonumber(ARGV[1])
15
+ local ttl = tonumber(ARGV[2])
16
+ local now = ARGV[3]
17
+ local push = tonumber(ARGV[4])
18
+
19
+ local size = redis.call('llen', list_key)
20
+ if size >= limit then
21
+ local expired = tonumber(now) - ttl
22
+ while size > 0 do
23
+ local t = redis.call('lpop', list_key)
24
+ if tonumber(t) > expired then
25
+ redis.call('lpush', list_key, t)
26
+ break
27
+ end
28
+ size = size - 1
29
+ end
30
+ end
31
+
32
+ if push > 0 and size < limit then
33
+ redis.call('rpush', list_key, now)
34
+ redis.call('pexpire', list_key, ttl)
35
+ end
36
+
37
+ return size
38
+ LUA
39
+
40
+ @lock = Mutex.new
41
+
10
42
  class << self
11
43
  # Add a global throttle that can be referenced later with the [] method.
12
- def add(name, limit:, ttl:)
13
- @@lock.synchronize do
14
- @throttles ||= {}
15
- @throttles[name.to_s] = new(name, limit: limit, ttl: ttl)
16
- end
44
+ def add(name, limit:, ttl:, redis: nil)
45
+ @lock.synchronize do
46
+ @throttles ||= {}
47
+ @throttles[name.to_s] = new(name, limit: limit, ttl: ttl, redis: redis)
48
+ end
17
49
  end
18
-
50
+
19
51
  # Returns a globally defined throttle with the specfied name.
20
52
  def [](name)
21
53
  if defined?(@throttles) && @throttles
22
54
  @throttles[name.to_s]
23
- else
24
- nil
25
55
  end
26
56
  end
27
-
57
+
28
58
  # Set the Redis instance to use for maintaining the throttle. This can either be set
29
59
  # with a hard coded value or by the value yielded by a block. If the block form is used
30
60
  # it will be invoked at runtime to get the instance. Use this method if your Redis instance
@@ -33,51 +63,66 @@ class SimpleThrottle
33
63
  def set_redis(client = nil, &block)
34
64
  @redis_client = (client || block)
35
65
  end
36
-
66
+
37
67
  # Return the Redis instance where the throttles are stored.
38
68
  def redis
69
+ @redis_client ||= Redis.new
39
70
  if @redis_client.is_a?(Proc)
40
71
  @redis_client.call
41
72
  else
42
73
  @redis_client
43
74
  end
44
75
  end
76
+
77
+ private
78
+
79
+ def execute_lua_script(redis:, keys:, args:)
80
+ @script_sha_1 ||= redis.script(:load, LUA_SCRIPT)
81
+ begin
82
+ redis.evalsha(@script_sha_1, Array(keys), Array(args))
83
+ rescue Redis::CommandError => e
84
+ if e.message.include?("NOSCRIPT")
85
+ @script_sha_1 = redis.script(:load, LUA_SCRIPT)
86
+ retry
87
+ else
88
+ raise e
89
+ end
90
+ end
91
+ end
45
92
  end
46
-
93
+
47
94
  attr_reader :name, :limit, :ttl
48
-
49
- # Create a new throttle with the given name. The ttl argument specifies the time
50
- # range that is being used for measuring in seconds while the limit specifies how
51
- # many calls are allowed in that range.
52
- def initialize(name, limit:, ttl:)
95
+
96
+ # Create a new throttle
97
+ # @param name [String] unique name for the throttle
98
+ # @param ttl [Numeric] number of seconds that the throttle will remain active
99
+ # @param limit [Integer] number of allowed requests within the throttle ttl
100
+ # @param redis [Redis] Redis client to use
101
+ def initialize(name, ttl:, limit:, redis: nil)
53
102
  @name = name.to_s
54
- @name.freeze unless @name.frozen?
103
+ @name = name.dup.freeze unless name.frozen?
55
104
  @limit = limit
56
105
  @ttl = ttl
57
- @script_sha_1 = nil
106
+ @redis = redis
58
107
  end
59
-
108
+
60
109
  # Returns true if the limit for the throttle has not been reached yet. This method
61
110
  # will also track the throttled resource as having been invoked on each call.
62
111
  def allowed!
63
112
  size = current_size(true)
64
- if size < limit
65
- true
66
- else
67
- false
68
- end
113
+ size < limit
69
114
  end
70
-
115
+
71
116
  # Reset a throttle back to zero.
72
117
  def reset!
73
- self.class.redis.del(redis_key)
118
+ redis_client.del(redis_key)
74
119
  end
75
-
120
+
76
121
  # Peek at the current number for throttled calls being tracked.
77
122
  def peek
78
123
  current_size(false)
79
124
  end
80
-
125
+
81
126
  # Returns when the next resource call should be allowed. Note that this doesn't guarantee that
82
127
  # calling allow! will return true if the wait time is zero since other processes or threads can
83
128
  # claim the resource.
@@ -85,71 +130,33 @@ class SimpleThrottle
85
130
  if peek < limit
86
131
  0.0
87
132
  else
88
- first = self.class.redis.lindex(redis_key, 0).to_f / 1000.0
133
+ first = redis_client.lindex(redis_key, 0).to_f / 1000.0
89
134
  delta = Time.now.to_f - first
90
135
  delta = 0.0 if delta < 0
91
136
  delta
92
137
  end
93
138
  end
94
-
139
+
95
140
  private
96
-
141
+
142
+ def redis_client
143
+ if @redis.is_a?(Proc)
144
+ @redis.call || self.class.redis
145
+ else
146
+ @redis || self.class.redis
147
+ end
148
+ end
149
+
97
150
  # Evaluate and execute a Lua script on the redis server that returns the number calls currently being tracked.
98
151
  # If push is set to true then a new item will be added to the list.
99
152
  def current_size(push)
100
- redis = self.class.redis
101
- @script_sha_1 ||= redis.script(:load, lua_script)
102
- begin
103
- push_arg = (push ? 1 : 0)
104
- time_ms = (Time.now.to_f * 1000).round
105
- ttl_ms = ttl * 1000
106
- redis.evalsha(@script_sha_1, [], [redis_key, limit, ttl_ms, time_ms, push_arg])
107
- rescue Redis::CommandError => e
108
- if e.message.include?('NOSCRIPT'.freeze)
109
- @script_sha_1 = redis.script(:load, lua_script)
110
- retry
111
- else
112
- raise e
113
- end
114
- end
153
+ push_arg = (push ? 1 : 0)
154
+ time_ms = (Time.now.to_f * 1000).round
155
+ ttl_ms = ttl * 1000
156
+ self.class.send(:execute_lua_script, redis: redis_client, keys: [redis_key], args: [limit, ttl_ms, time_ms, push_arg])
115
157
  end
116
-
158
+
117
159
  def redis_key
118
160
  "simple_throttle.#{name}"
119
161
  end
120
-
121
- # Server side Lua script that maintains the throttle in redis. The throttle is stored as a list
122
- # of timestamps in milliseconds. When the script is invoked it will scan the oldest entries
123
- # removing any that should be expired from the list. If the list is below the specified limit
124
- # then the current entry will be added. The list is marked to expire with the oldest entry so
125
- # there's no need to cleanup the lists.
126
- def lua_script
127
- <<-LUA
128
- local list_key = ARGV[1]
129
- local limit = tonumber(ARGV[2])
130
- local ttl = tonumber(ARGV[3])
131
- local now = ARGV[4]
132
- local push = tonumber(ARGV[5])
133
-
134
- local size = redis.call('llen', list_key)
135
- if size >= limit then
136
- local expired = tonumber(now) - ttl
137
- while size > 0 do
138
- local t = redis.call('lpop', list_key)
139
- if tonumber(t) > expired then
140
- redis.call('lpush', list_key, t)
141
- break
142
- end
143
- size = size - 1
144
- end
145
- end
146
-
147
- if push > 0 and size < limit then
148
- redis.call('rpush', list_key, now)
149
- redis.call('pexpire', list_key, ttl)
150
- end
151
-
152
- return size
153
- LUA
154
- end
155
162
  end
@@ -1,25 +1,34 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
-
5
1
  Gem::Specification.new do |spec|
6
- spec.name = "simple_throttle"
7
- spec.version = File.read(File.expand_path("../VERSION", __FILE__)).chomp
8
- spec.authors = ["We Heart It", "Brian Durand"]
9
- spec.email = ["dev@weheartit.com", "bbdurand@gmail.com"]
10
- spec.summary = "Simple redis backed throttling mechanism to limit access to a resource"
11
- spec.description = "Simple redis backed throttling mechanism to limit access to a resource."
12
- spec.homepage = "https://github.com/weheartit/simple_throttle"
13
- spec.license = "MIT"
2
+ spec.name = "simple_throttle"
3
+ spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
4
+ spec.authors = ["We Heart It", "Brian Durand"]
5
+ spec.email = ["dev@weheartit.com", "bbdurand@gmail.com"]
6
+
7
+ spec.summary = "Simple redis backed throttling mechanism to limit access to a resource"
8
+ spec.homepage = "https://github.com/weheartit/simple_throttle"
9
+ spec.license = "MIT"
10
+
11
+ # Specify which files should be added to the gem when it is released.
12
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
13
+ ignore_files = %w[
14
+ .
15
+ Appraisals
16
+ Gemfile
17
+ Gemfile.lock
18
+ Rakefile
19
+ bin/
20
+ gemfiles/
21
+ spec/
22
+ ]
23
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
25
+ end
14
26
 
15
- spec.files = `git ls-files`.split($/)
16
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
27
  spec.require_paths = ["lib"]
19
28
 
20
- spec.add_dependency('redis')
29
+ spec.add_dependency "redis"
30
+
31
+ spec.add_development_dependency "bundler"
21
32
 
22
- spec.add_development_dependency "bundler", "~> 1.3"
23
- spec.add_development_dependency "rake"
24
- spec.add_development_dependency "rspec"
33
+ spec.required_ruby_version = ">= 2.5"
25
34
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_throttle
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - We Heart It
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2018-03-23 00:00:00.000000000 Z
12
+ date: 2021-09-03 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: redis
@@ -27,20 +27,6 @@ dependencies:
27
27
  version: '0'
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: bundler
30
- requirement: !ruby/object:Gem::Requirement
31
- requirements:
32
- - - "~>"
33
- - !ruby/object:Gem::Version
34
- version: '1.3'
35
- type: :development
36
- prerelease: false
37
- version_requirements: !ruby/object:Gem::Requirement
38
- requirements:
39
- - - "~>"
40
- - !ruby/object:Gem::Version
41
- version: '1.3'
42
- - !ruby/object:Gem::Dependency
43
- name: rake
44
30
  requirement: !ruby/object:Gem::Requirement
45
31
  requirements:
46
32
  - - ">="
@@ -53,21 +39,7 @@ dependencies:
53
39
  - - ">="
54
40
  - !ruby/object:Gem::Version
55
41
  version: '0'
56
- - !ruby/object:Gem::Dependency
57
- name: rspec
58
- requirement: !ruby/object:Gem::Requirement
59
- requirements:
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: '0'
63
- type: :development
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - ">="
68
- - !ruby/object:Gem::Version
69
- version: '0'
70
- description: Simple redis backed throttling mechanism to limit access to a resource.
42
+ description:
71
43
  email:
72
44
  - dev@weheartit.com
73
45
  - bbdurand@gmail.com
@@ -75,15 +47,12 @@ executables: []
75
47
  extensions: []
76
48
  extra_rdoc_files: []
77
49
  files:
78
- - ".gitignore"
50
+ - CHANGELOG.md
79
51
  - MIT_LICENSE.txt
80
52
  - README.md
81
- - Rakefile
82
53
  - VERSION
83
54
  - lib/simple_throttle.rb
84
55
  - simple_throttle.gemspec
85
- - spec/simple_throttle_spec.rb
86
- - spec/spec_helper.rb
87
56
  homepage: https://github.com/weheartit/simple_throttle
88
57
  licenses:
89
58
  - MIT
@@ -96,18 +65,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
96
65
  requirements:
97
66
  - - ">="
98
67
  - !ruby/object:Gem::Version
99
- version: '0'
68
+ version: '2.5'
100
69
  required_rubygems_version: !ruby/object:Gem::Requirement
101
70
  requirements:
102
71
  - - ">="
103
72
  - !ruby/object:Gem::Version
104
73
  version: '0'
105
74
  requirements: []
106
- rubyforge_project:
107
- rubygems_version: 2.6.12
75
+ rubygems_version: 3.0.3
108
76
  signing_key:
109
77
  specification_version: 4
110
78
  summary: Simple redis backed throttling mechanism to limit access to a resource
111
- test_files:
112
- - spec/simple_throttle_spec.rb
113
- - spec/spec_helper.rb
79
+ test_files: []
data/.gitignore DELETED
@@ -1,2 +0,0 @@
1
- .DS_Store
2
- pkg
data/Rakefile DELETED
@@ -1,18 +0,0 @@
1
- require "bundler/gem_tasks"
2
-
3
- desc 'Default: run unit tests.'
4
- task :default => :test
5
-
6
- desc 'RVM likes to call it tests'
7
- task :tests => :test
8
-
9
- begin
10
- require 'rspec'
11
- require 'rspec/core/rake_task'
12
- desc 'Run the unit tests'
13
- RSpec::Core::RakeTask.new(:test)
14
- rescue LoadError
15
- task :test do
16
- STDERR.puts "You must have rspec 2.0 installed to run the tests"
17
- end
18
- end
@@ -1,56 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe SimpleThrottle do
4
-
5
- it "should tell if a call is allowed" do
6
- throttle = SimpleThrottle.new("test_simple_throttle", limit: 3, ttl: 1)
7
- throttle.reset!
8
- other_throttle = SimpleThrottle.new("test_simple_throttle_2", limit: 3, ttl: 1)
9
- other_throttle.reset!
10
-
11
- expect(throttle.peek).to eq 0
12
- expect(throttle.allowed!).to eq true
13
- expect(throttle.peek).to eq 1
14
- expect(throttle.allowed!).to eq true
15
- expect(throttle.peek).to eq 2
16
- expect(throttle.allowed!).to eq true
17
- expect(throttle.peek).to eq 3
18
- expect(throttle.allowed!).to eq false
19
- expect(throttle.peek).to eq 3
20
- expect(throttle.allowed!).to eq false
21
- wait_time = throttle.wait_time
22
- expect(wait_time).to be > 0.0
23
- expect(wait_time).to be <= 1.0
24
-
25
- expect(other_throttle.allowed!).to eq true
26
- expect(other_throttle.peek).to eq 1
27
- expect(other_throttle.wait_time).to eq 0.0
28
-
29
- sleep(1.1)
30
-
31
- expect(throttle.allowed!).to eq true
32
- sleep(0.3)
33
- expect(throttle.allowed!).to eq true
34
- sleep(0.3)
35
- expect(throttle.allowed!).to eq true
36
- sleep(0.3)
37
- expect(throttle.allowed!).to eq false
38
- sleep(0.3)
39
- expect(throttle.allowed!).to eq true
40
- expect(throttle.allowed!).to eq false
41
- end
42
-
43
- it "should be able to add global throttles" do
44
- SimpleThrottle.add(:test_1, limit: 4, ttl: 60)
45
- SimpleThrottle.add(:test_2, limit: 10, ttl: 3600)
46
- t1 = SimpleThrottle["test_1"]
47
- expect(t1.name).to eq "test_1"
48
- expect(t1.limit).to eq 4
49
- expect(t1.ttl).to eq 60
50
- t1 = SimpleThrottle[:test_2]
51
- expect(t1.name).to eq "test_2"
52
- expect(t1.limit).to eq 10
53
- expect(t1.ttl).to eq 3600
54
- end
55
-
56
- end
data/spec/spec_helper.rb DELETED
@@ -1,14 +0,0 @@
1
- require File.expand_path('../../lib/simple_throttle', __FILE__)
2
-
3
- SimpleThrottle.set_redis(Redis.new)
4
-
5
- RSpec.configure do |config|
6
- config.run_all_when_everything_filtered = true
7
- config.filter_run :focus
8
-
9
- # Run specs in random order to surface order dependencies. If you find an
10
- # order dependency and want to debug it, you can fix the order by providing
11
- # the seed, which is printed after each run.
12
- # --seed 1234
13
- config.order = 'random'
14
- end