throttling 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/.travis.yml +9 -0
- data/Gemfile +4 -0
- data/Guardfile +6 -0
- data/LICENSE +22 -0
- data/README.md +113 -0
- data/Rakefile +8 -0
- data/lib/throttling.rb +109 -0
- data/lib/throttling/base.rb +60 -0
- data/lib/throttling/indifferent_access.rb +152 -0
- data/lib/throttling/version.rb +3 -0
- data/spec/base_spec.rb +112 -0
- data/spec/fixtures/throttling.yml +14 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/throttling_spec.rb +103 -0
- data/throttling.gemspec +24 -0
- metadata +133 -0
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Guardfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Dmytro Shteflyuk
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
# Throttling
|
2
|
+
|
3
|
+
[![Travis-CI build status](https://secure.travis-ci.org/kpumuk/throttling.png)](http://travis-ci.org/kpumuk/throttling)
|
4
|
+
|
5
|
+
Throttling gem provides basic, but very powerful way to throttle various user actions in your application. Basically you can specify how many times some action could be performed over a specified period(s) of time.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
gem 'throttling'
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install throttling
|
20
|
+
|
21
|
+
## Configuration
|
22
|
+
|
23
|
+
You can configure Throttling parameters by accessing attributes of `Throttling` module. Currently it supports only Memcached through `Rails.cache`.
|
24
|
+
|
25
|
+
Throttling.storage = Rails.cache
|
26
|
+
Throttling.logger = Rails.logger
|
27
|
+
|
28
|
+
Throttling limits could be stored in a configuration file in `config/throttling.yml`. You can also specify another file to read limits from:
|
29
|
+
|
30
|
+
Throttling.limits_config = "#{Rails.root}/config/throttling.yml"
|
31
|
+
|
32
|
+
The basic structure of the file is:
|
33
|
+
|
34
|
+
user_signup:
|
35
|
+
limit: 20
|
36
|
+
period: 3600
|
37
|
+
|
38
|
+
search_requests:
|
39
|
+
minutely:
|
40
|
+
limit: 300
|
41
|
+
period: 600
|
42
|
+
hourly:
|
43
|
+
limit: 1000
|
44
|
+
period: 3600
|
45
|
+
daily:
|
46
|
+
limit: 10000
|
47
|
+
period: 86400
|
48
|
+
|
49
|
+
You can also specify limits as a Hash:
|
50
|
+
|
51
|
+
Throttling.limits = {
|
52
|
+
:user_signup => {
|
53
|
+
:limit => 20,
|
54
|
+
:period => 3600
|
55
|
+
},
|
56
|
+
:search_requests => {
|
57
|
+
:minutely => {
|
58
|
+
:limit => 20,
|
59
|
+
:period => 3600
|
60
|
+
},
|
61
|
+
:hourly => {
|
62
|
+
:limit => 1000,
|
63
|
+
:period => 3600
|
64
|
+
},
|
65
|
+
:daily =>
|
66
|
+
:limit => 10000,
|
67
|
+
:period => 86400
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
You can completely disable throttling by setting `enabled` to `false`:
|
73
|
+
|
74
|
+
Throttling.enabled = false
|
75
|
+
|
76
|
+
## Usage
|
77
|
+
|
78
|
+
The basic usage of Throttling gem is following:
|
79
|
+
|
80
|
+
Throttling.for(:user_signup).check(:user_id, current_user.id) do
|
81
|
+
# Do your stuff here
|
82
|
+
end
|
83
|
+
|
84
|
+
if Throttling.for(:user_signup).check(:user_id, current_user.id)
|
85
|
+
# Action allowed
|
86
|
+
else
|
87
|
+
# Action denied
|
88
|
+
end
|
89
|
+
|
90
|
+
For convenience, there are some simplified methods:
|
91
|
+
|
92
|
+
Throttling.for(:user_signup).check_ip(request.remote_ip)
|
93
|
+
Throttling.for(:user_signup).check_user_id(current_user.id)
|
94
|
+
|
95
|
+
You can add more helpers like this:
|
96
|
+
|
97
|
+
Throttling::Base.class_eval do
|
98
|
+
def check_user_id_and_document_id(user_id, doc_id)
|
99
|
+
check("user_id:doc_id", "#{user_id}:#{doc_id}")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
## Contributing
|
104
|
+
|
105
|
+
1. Fork it
|
106
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
107
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
108
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
109
|
+
5. Create new Pull Request
|
110
|
+
|
111
|
+
## Who are the authors?
|
112
|
+
|
113
|
+
This plugin has been created in Scribd.com for our internal use and then the sources were opened for other people to use. Most of the code in this package has been developed by Oleksiy Kovyrin and Dmytro Shteflyuk for Scribd.com and is released under the MIT license. For more details, see the LICENSE file.
|
data/Rakefile
ADDED
data/lib/throttling.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
# Simple throttling library to limit number of actions in time.
|
5
|
+
module Throttling
|
6
|
+
class << self
|
7
|
+
# Gets current Throttling storage. By default returns Rails.cache
|
8
|
+
# (if it is a Rails application).
|
9
|
+
def storage
|
10
|
+
@@storage ||= (defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache) || nil
|
11
|
+
raise ArgumentError, 'Throttling.storage is not specified' unless @@storage
|
12
|
+
@@storage
|
13
|
+
end
|
14
|
+
|
15
|
+
# Sets a storage instance to store Throttling information in. Should implement to
|
16
|
+
# to methods:
|
17
|
+
#
|
18
|
+
# def fetch(key, options = {}, &block)
|
19
|
+
# def increment(key)
|
20
|
+
#
|
21
|
+
# Rails.cache is one of the storages conforming this interface.
|
22
|
+
def storage=(storage)
|
23
|
+
@@storage = storage
|
24
|
+
end
|
25
|
+
|
26
|
+
# Gets the logger used to output errors or warnings.
|
27
|
+
def logger
|
28
|
+
@@logger ||= (defined?(Rails) && Rails.respond_to(:logger) && Rails.logger) || Logger.new(STDOUT)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Sets the logger used to output errors or warnings.
|
32
|
+
def logger=(logger)
|
33
|
+
@@logger = logger
|
34
|
+
end
|
35
|
+
|
36
|
+
# Gets a throttling limits config file path.
|
37
|
+
def limits_config
|
38
|
+
root = (defined?(Rails) && Rails.respond_to?(:root) && Rails.root) || Dir.pwd
|
39
|
+
@@limits_config ||= "#{root}/config/throttling.yml"
|
40
|
+
end
|
41
|
+
|
42
|
+
# Sets the configuration file path containing throttling limits.
|
43
|
+
def limits_config=(path)
|
44
|
+
@@limits = nil
|
45
|
+
@@limits_config = path
|
46
|
+
end
|
47
|
+
|
48
|
+
# Gets a Hash with current throttling limits.
|
49
|
+
def limits
|
50
|
+
@@limits ||= load_config(limits_config)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Sets current throttling limits.
|
54
|
+
def limits=(limits)
|
55
|
+
@@limits = limits && limits.with_indifferent_access
|
56
|
+
end
|
57
|
+
|
58
|
+
# Get the value indicating whether throttling is enabled.
|
59
|
+
def enabled?
|
60
|
+
!!@@enabled
|
61
|
+
end
|
62
|
+
alias :enabled :enabled?
|
63
|
+
|
64
|
+
# Sets the value indicating whether throttling is enabled.
|
65
|
+
def enabled=(enabled)
|
66
|
+
@@enabled = !!enabled
|
67
|
+
end
|
68
|
+
|
69
|
+
# Enables throttling.
|
70
|
+
def enable!
|
71
|
+
@@enabled = true
|
72
|
+
end
|
73
|
+
|
74
|
+
# Disables throttling.
|
75
|
+
def disable!
|
76
|
+
@@enabled = false
|
77
|
+
end
|
78
|
+
|
79
|
+
# Returns a Throttling::Base instance for a given action.
|
80
|
+
def for(action)
|
81
|
+
@@instances[action.to_s] ||= Base.new(action.to_s)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Resets all values to their default state (mostly for testing purpose).
|
85
|
+
def reset_defaults!
|
86
|
+
@@enabled = true
|
87
|
+
@@logger = nil
|
88
|
+
@@storage = nil
|
89
|
+
@@limits_config = nil
|
90
|
+
|
91
|
+
# Internal variables
|
92
|
+
@@instances = {}
|
93
|
+
@@config = nil
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def load_config(path)
|
99
|
+
return nil unless File.exists?(path)
|
100
|
+
YAML.load_file(path).with_indifferent_access
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
reset_defaults!
|
105
|
+
end
|
106
|
+
|
107
|
+
require 'throttling/indifferent_access'
|
108
|
+
require 'throttling/base'
|
109
|
+
require "throttling/version"
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Throttling
|
2
|
+
# Class implements throttling for a single action.
|
3
|
+
class Base
|
4
|
+
attr_accessor :action, :limits
|
5
|
+
|
6
|
+
def initialize(action)
|
7
|
+
@action = action.to_s
|
8
|
+
|
9
|
+
raise ArgumentError, "No throttling limits specified" unless Throttling.limits
|
10
|
+
@limits = Throttling.limits[action]
|
11
|
+
raise ArgumentError, "No Throttling.limits[#{action}] section found" unless limits
|
12
|
+
|
13
|
+
# Convert simple limits to a hash
|
14
|
+
if @limits[:limit] && @limits[:period]
|
15
|
+
@limits = { 'global' => @limits }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def check_ip(ip)
|
20
|
+
check(:ip, ip)
|
21
|
+
end
|
22
|
+
|
23
|
+
def check_user_id(user_id)
|
24
|
+
check(:user_id, user_id)
|
25
|
+
end
|
26
|
+
|
27
|
+
def check(check_type, check_value, auto_increment = true)
|
28
|
+
# Disabled?
|
29
|
+
return true if !Throttling.enabled? || check_value.nil?
|
30
|
+
|
31
|
+
limits.each do |period_name, params|
|
32
|
+
raise ArgumentError, "Invalid or no 'period' parameter in the limits[#{period_name}] config" if params[:period].to_i < 1
|
33
|
+
raise ArgumentError, "Invalid or no 'limit' parameter in the limits[#{period_name}] config" if params[:limit].nil? || params[:limit].to_i < 0
|
34
|
+
|
35
|
+
period = params[:period].to_i
|
36
|
+
key = hits_store_key(check_type, check_value, period_name, period)
|
37
|
+
|
38
|
+
# Retrieve current value
|
39
|
+
hits = Throttling.storage.fetch(key, :expires_in => hits_store_ttl(period), :raw => true) { '0' }
|
40
|
+
|
41
|
+
# Over limit?
|
42
|
+
return false if hits.to_i > params[:limit].to_i
|
43
|
+
|
44
|
+
Throttling.storage.increment(key) if auto_increment
|
45
|
+
end
|
46
|
+
|
47
|
+
return true
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def hits_store_key(check_type, check_value, period_name, period_value)
|
53
|
+
"throttle:#{action}:#{check_type}:#{check_value}:#{period_name}:#{Time.now.to_i / period_value}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def hits_store_ttl(check_period)
|
57
|
+
check_period - Time.now.to_i % check_period
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
module Throttling
|
2
|
+
begin
|
3
|
+
HashWithIndifferentAccess = ::HashWithIndifferentAccess
|
4
|
+
rescue NameError
|
5
|
+
# This class has dubious semantics and we only have it so that
|
6
|
+
# people can write params[:key] instead of params['key']
|
7
|
+
# and they get the same value for both keys.
|
8
|
+
#
|
9
|
+
# This is part of Rails' ActiveSupport project. If you are
|
10
|
+
# using Rails, this class will not be used.
|
11
|
+
#
|
12
|
+
class HashWithIndifferentAccess < Hash
|
13
|
+
def initialize(constructor = {})
|
14
|
+
if constructor.is_a?(Hash)
|
15
|
+
super()
|
16
|
+
update(constructor)
|
17
|
+
else
|
18
|
+
super(constructor)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def default(key = nil)
|
23
|
+
if key.is_a?(Symbol) && include?(key = key.to_s)
|
24
|
+
self[key]
|
25
|
+
else
|
26
|
+
super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
alias_method :regular_writer, :[]= unless method_defined?(:regular_writer)
|
31
|
+
alias_method :regular_update, :update unless method_defined?(:regular_update)
|
32
|
+
|
33
|
+
# Assigns a new value to the hash:
|
34
|
+
#
|
35
|
+
# hash = HashWithIndifferentAccess.new
|
36
|
+
# hash[:key] = "value"
|
37
|
+
#
|
38
|
+
def []=(key, value)
|
39
|
+
regular_writer(convert_key(key), convert_value(value))
|
40
|
+
end
|
41
|
+
|
42
|
+
# Updates the instantized hash with values from the second:
|
43
|
+
#
|
44
|
+
# hash_1 = HashWithIndifferentAccess.new
|
45
|
+
# hash_1[:key] = "value"
|
46
|
+
#
|
47
|
+
# hash_2 = HashWithIndifferentAccess.new
|
48
|
+
# hash_2[:key] = "New Value!"
|
49
|
+
#
|
50
|
+
# hash_1.update(hash_2) # => {"key"=>"New Value!"}
|
51
|
+
#
|
52
|
+
def update(other_hash)
|
53
|
+
other_hash.each_pair { |key, value| regular_writer(convert_key(key), convert_value(value)) }
|
54
|
+
self
|
55
|
+
end
|
56
|
+
|
57
|
+
alias_method :merge!, :update
|
58
|
+
|
59
|
+
# Checks the hash for a key matching the argument passed in:
|
60
|
+
#
|
61
|
+
# hash = HashWithIndifferentAccess.new
|
62
|
+
# hash["key"] = "value"
|
63
|
+
# hash.key? :key # => true
|
64
|
+
# hash.key? "key" # => true
|
65
|
+
#
|
66
|
+
def key?(key)
|
67
|
+
super(convert_key(key))
|
68
|
+
end
|
69
|
+
|
70
|
+
alias_method :include?, :key?
|
71
|
+
alias_method :has_key?, :key?
|
72
|
+
alias_method :member?, :key?
|
73
|
+
|
74
|
+
# Fetches the value for the specified key, same as doing hash[key]
|
75
|
+
def fetch(key, *extras)
|
76
|
+
super(convert_key(key), *extras)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Returns an array of the values at the specified indices:
|
80
|
+
#
|
81
|
+
# hash = HashWithIndifferentAccess.new
|
82
|
+
# hash[:a] = "x"
|
83
|
+
# hash[:b] = "y"
|
84
|
+
# hash.values_at("a", "b") # => ["x", "y"]
|
85
|
+
#
|
86
|
+
def values_at(*indices)
|
87
|
+
indices.collect {|key| self[convert_key(key)]}
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returns an exact copy of the hash.
|
91
|
+
def dup
|
92
|
+
HashWithIndifferentAccess.new(self)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Merges the instantized and the specified hashes together, giving precedence to the values from the second hash
|
96
|
+
# Does not overwrite the existing hash.
|
97
|
+
def merge(hash)
|
98
|
+
self.dup.update(hash)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Performs the opposite of merge, with the keys and values from the first hash taking precedence over the second.
|
102
|
+
# This overloaded definition prevents returning a regular hash, if reverse_merge is called on a HashWithDifferentAccess.
|
103
|
+
def reverse_merge(other_hash)
|
104
|
+
super other_hash.with_indifferent_access
|
105
|
+
end
|
106
|
+
|
107
|
+
# Removes a specified key from the hash.
|
108
|
+
def delete(key)
|
109
|
+
super(convert_key(key))
|
110
|
+
end
|
111
|
+
|
112
|
+
def stringify_keys!; self end
|
113
|
+
def symbolize_keys!; self end
|
114
|
+
def to_options!; self end
|
115
|
+
|
116
|
+
# Convert to a Hash with String keys.
|
117
|
+
def to_hash
|
118
|
+
Hash.new(default).merge(self)
|
119
|
+
end
|
120
|
+
|
121
|
+
protected
|
122
|
+
def convert_key(key)
|
123
|
+
key.kind_of?(Symbol) ? key.to_s : key
|
124
|
+
end
|
125
|
+
|
126
|
+
def convert_value(value)
|
127
|
+
case value
|
128
|
+
when Hash
|
129
|
+
value.with_indifferent_access
|
130
|
+
when Array
|
131
|
+
value.collect { |e| e.is_a?(Hash) ? e.with_indifferent_access : e }
|
132
|
+
else
|
133
|
+
value
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
module HashIndifferentAccess #:nodoc:
|
139
|
+
def with_indifferent_access
|
140
|
+
hash = HashWithIndifferentAccess.new(self)
|
141
|
+
hash.default = self.default
|
142
|
+
hash
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
class ::Hash #:nodoc:
|
147
|
+
unless respond_to?(:with_indifferent_access)
|
148
|
+
include Throttling::HashIndifferentAccess
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
data/spec/base_spec.rb
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Throttling do
|
4
|
+
before do
|
5
|
+
Throttling.reset_defaults!
|
6
|
+
@storage = Throttling.storage = TestStorage.new
|
7
|
+
end
|
8
|
+
|
9
|
+
describe 'instance methods' do
|
10
|
+
before do
|
11
|
+
Throttling.limits = { 'foo' => {'limit' => 5, 'period' => 2} }
|
12
|
+
@t = Throttling.for('foo')
|
13
|
+
end
|
14
|
+
|
15
|
+
{ :check_ip => '127.0.0.1', :check_user_id => 123 }.each do |check_method, valid_value|
|
16
|
+
describe check_method do
|
17
|
+
it 'should return true for nil check_values' do
|
18
|
+
@t.send(check_method, nil).should be_true
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'should raise an exception if no limit specified in configs' do
|
22
|
+
Throttling.limits['foo']['limit'] = nil
|
23
|
+
lambda { @t.send(check_method, valid_value) }.should raise_error(ArgumentError)
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'should raise an exception if no period specified in configs' do
|
27
|
+
Throttling.limits['foo']['period'] = nil
|
28
|
+
lambda { @t.send(check_method, valid_value) }.should raise_error(ArgumentError)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'should raise an exception if invalid period specified in configs' do
|
32
|
+
Throttling.limits['foo']['period'] = -1
|
33
|
+
lambda { @t.send(check_method, valid_value) }.should raise_error(ArgumentError)
|
34
|
+
|
35
|
+
Throttling.limits['foo']['period'] = 'foo'
|
36
|
+
lambda { @t.send(check_method, valid_value) }.should raise_error(ArgumentError)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'should return true if throttling limit is not passed' do
|
40
|
+
@storage.should_receive(:fetch).and_return(1)
|
41
|
+
@t.send(check_method, valid_value).should be_true
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'should return false if throttling limit is passed' do
|
45
|
+
@storage.should_receive(:fetch).and_return(Throttling.limits['foo']['limit'] + 1)
|
46
|
+
@t.send(check_method, valid_value).should be_false
|
47
|
+
end
|
48
|
+
|
49
|
+
context 'around limit' do
|
50
|
+
it 'should increase hit counter when values equals to limit - 1' do
|
51
|
+
@storage.should_receive(:fetch).and_return(Throttling.limits['foo']['limit'] - 1)
|
52
|
+
@storage.should_receive(:increment)
|
53
|
+
@t.send(check_method, valid_value)
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should increase hit counter when values equals to limit' do
|
57
|
+
@storage.should_receive(:fetch).and_return(Throttling.limits['foo']['limit'])
|
58
|
+
@storage.should_receive(:increment)
|
59
|
+
@t.send(check_method, valid_value)
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'should increase hit counter when values equals to limit + 1' do
|
63
|
+
@storage.should_receive(:fetch).and_return(Throttling.limits['foo']['limit'] + 1)
|
64
|
+
@storage.should_not_receive(:increment)
|
65
|
+
@t.send(check_method, valid_value)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe 'with multi-level limits' do
|
73
|
+
before do
|
74
|
+
Throttling.limits = { 'foo' => { 'one' => { 'limit' => 5, 'period' => 2 }, 'two' => { 'limit' => 10, 'period' => 20 } } }
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should return false if at least one limit is reached" do
|
78
|
+
@storage.should_receive(:fetch).and_return(1, 100)
|
79
|
+
Throttling.for('foo').check_ip('127.0.0.1').should be_false
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should return true if none limits reached" do
|
83
|
+
@storage.should_receive(:fetch).and_return(1, 2)
|
84
|
+
Throttling.for('foo').check_ip('127.0.0.1').should be_true
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context do
|
89
|
+
before do
|
90
|
+
Throttling.limits = { 'foo' => {'limit' => 5, 'period' => 86400} }
|
91
|
+
@timestamp = 1334261569
|
92
|
+
end
|
93
|
+
|
94
|
+
describe 'key name' do
|
95
|
+
it 'should include type, value, name, and period start' do
|
96
|
+
Timecop.freeze(Time.at(@timestamp)) do
|
97
|
+
Throttling.for('foo').check_ip('127.0.0.1')
|
98
|
+
end
|
99
|
+
@storage.values.keys.first.should == 'throttle:foo:ip:127.0.0.1:global:15442'
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe 'key expiration' do
|
104
|
+
it 'should calculate expiration time' do
|
105
|
+
Timecop.freeze(Time.at(@timestamp)) do
|
106
|
+
Throttling.for('foo').check_ip('127.0.0.1')
|
107
|
+
end
|
108
|
+
@storage.values.values.first[:expires_in].should == 13631
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'throttling'
|
3
|
+
require 'timecop'
|
4
|
+
|
5
|
+
class TestStorage
|
6
|
+
attr_reader :values
|
7
|
+
|
8
|
+
def fetch(key, options = {}, &block)
|
9
|
+
@values ||= {}
|
10
|
+
value = @values.fetch(key, &block)
|
11
|
+
@values[key] = options.merge(:value => value.to_s)
|
12
|
+
value
|
13
|
+
end
|
14
|
+
|
15
|
+
def increment(key)
|
16
|
+
@values ||= {}
|
17
|
+
@values[key] ||= { :value => 0 }
|
18
|
+
@values[key][:value] = (@values[key][:value].to_i + 1).to_s
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Throttling do
|
4
|
+
after :each do
|
5
|
+
Throttling.reset_defaults!
|
6
|
+
end
|
7
|
+
|
8
|
+
context 'with defaults' do
|
9
|
+
it 'should create logger' do
|
10
|
+
Throttling.logger.should be_a(Logger)
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'should be enabled' do
|
14
|
+
Throttling.enabled?.should be_true
|
15
|
+
Throttling.enabled.should be_true
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'should set config file path' do
|
19
|
+
Throttling.limits_config.should == "#{Dir.pwd}/config/throttling.yml"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
context 'setters' do
|
24
|
+
it 'should allow to enabled and disable client' do
|
25
|
+
Throttling.enabled = false
|
26
|
+
Throttling.should_not be_enabled
|
27
|
+
|
28
|
+
Throttling.enable!
|
29
|
+
Throttling.should be_enabled
|
30
|
+
|
31
|
+
Throttling.disable!
|
32
|
+
Throttling.should_not be_enabled
|
33
|
+
|
34
|
+
Throttling.enabled = true
|
35
|
+
Throttling.should be_enabled
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'should allow to change logger' do
|
39
|
+
mock = Throttling.logger = mock('Logger')
|
40
|
+
Throttling.logger.should be(mock)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'should allow to set limits' do
|
44
|
+
limits = { 'foo' => {'limit' => 5, 'period' => 2} }
|
45
|
+
Throttling.limits = limits
|
46
|
+
Throttling.limits.should == limits
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'should allow to change config file path' do
|
50
|
+
path = File.expand_path('../fixtures/throttling.yml', __FILE__)
|
51
|
+
Throttling.limits_config = path
|
52
|
+
Throttling.limits_config.should == path
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe '.for' do
|
57
|
+
it "should return a throttling class instance" do
|
58
|
+
Throttling.limits = { 'foo' => {'limit' => 5, 'period' => 2} }
|
59
|
+
Throttling.for('foo').should be_instance_of(Throttling::Base)
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should raise an exception if no throttling_limits found in config" do
|
63
|
+
Throttling.limits = nil
|
64
|
+
lambda { Throttling.for('foo') }.should raise_error(ArgumentError)
|
65
|
+
end
|
66
|
+
|
67
|
+
it "should raise an exception if no throttling_limits[action] found in config" do
|
68
|
+
Throttling.limits = { 'foo' => nil }
|
69
|
+
lambda { Throttling.for('foo') }.should raise_error(ArgumentError)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe 'limits' do
|
74
|
+
context 'when set using .limits' do
|
75
|
+
it 'should convert Hash to HashWithIndifferentAccess' do
|
76
|
+
Throttling.limits = { 'foo' => {'limit' => 5, 'period' => 2} }
|
77
|
+
Throttling.limits.should have_key(:foo)
|
78
|
+
Throttling.limits.should have_key('foo')
|
79
|
+
Throttling.limits[:foo].should have_key(:limit)
|
80
|
+
Throttling.limits[:foo].should have_key('limit')
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
context 'when set using .limits_config' do
|
85
|
+
before do
|
86
|
+
Throttling.limits_config = File.expand_path('../fixtures/throttling.yml', __FILE__)
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'should load limits from configuration file' do
|
90
|
+
Throttling.limits.should be_kind_of(Hash)
|
91
|
+
Throttling.limits.should have_key('search_requests')
|
92
|
+
Throttling.limits.should have_key('user_signup')
|
93
|
+
end
|
94
|
+
|
95
|
+
it 'should convert Hash to HashWithIndifferentAccess' do
|
96
|
+
Throttling.limits.should have_key(:search_requests)
|
97
|
+
Throttling.limits.should have_key('search_requests')
|
98
|
+
Throttling.limits[:search_requests].should have_key(:daily)
|
99
|
+
Throttling.limits[:search_requests].should have_key('daily')
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/throttling.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/throttling/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Dmytro Shteflyuk", "Oleksiy Kovyrin"]
|
6
|
+
gem.email = ["kpumuk@kpumuk.info"]
|
7
|
+
gem.description = %q{Throttling gem provides basic, but very powerful way to throttle various user actions in your application}
|
8
|
+
gem.summary = %q{Easy throttling for Ruby applications}
|
9
|
+
gem.homepage = "https://github.com/kpumuk/throttling"
|
10
|
+
|
11
|
+
gem.add_development_dependency 'rake'
|
12
|
+
gem.add_development_dependency 'rspec'
|
13
|
+
gem.add_development_dependency 'timecop'
|
14
|
+
gem.add_development_dependency 'guard-rspec'
|
15
|
+
gem.add_development_dependency 'rb-fsevent'
|
16
|
+
gem.add_development_dependency 'growl'
|
17
|
+
|
18
|
+
gem.files = `git ls-files`.split($\)
|
19
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
20
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
21
|
+
gem.name = "throttling"
|
22
|
+
gem.require_paths = ["lib"]
|
23
|
+
gem.version = Throttling::VERSION
|
24
|
+
end
|
metadata
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: throttling
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Dmytro Shteflyuk
|
9
|
+
- Oleksiy Kovyrin
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2012-04-12 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rake
|
17
|
+
requirement: &70193684278040 !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - ! '>='
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '0'
|
23
|
+
type: :development
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: *70193684278040
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: rspec
|
28
|
+
requirement: &70193684277620 !ruby/object:Gem::Requirement
|
29
|
+
none: false
|
30
|
+
requirements:
|
31
|
+
- - ! '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: *70193684277620
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: timecop
|
39
|
+
requirement: &70193684277200 !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ! '>='
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0'
|
45
|
+
type: :development
|
46
|
+
prerelease: false
|
47
|
+
version_requirements: *70193684277200
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: guard-rspec
|
50
|
+
requirement: &70193684276780 !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ! '>='
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
type: :development
|
57
|
+
prerelease: false
|
58
|
+
version_requirements: *70193684276780
|
59
|
+
- !ruby/object:Gem::Dependency
|
60
|
+
name: rb-fsevent
|
61
|
+
requirement: &70193684276360 !ruby/object:Gem::Requirement
|
62
|
+
none: false
|
63
|
+
requirements:
|
64
|
+
- - ! '>='
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
type: :development
|
68
|
+
prerelease: false
|
69
|
+
version_requirements: *70193684276360
|
70
|
+
- !ruby/object:Gem::Dependency
|
71
|
+
name: growl
|
72
|
+
requirement: &70193684275940 !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
type: :development
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: *70193684275940
|
81
|
+
description: Throttling gem provides basic, but very powerful way to throttle various
|
82
|
+
user actions in your application
|
83
|
+
email:
|
84
|
+
- kpumuk@kpumuk.info
|
85
|
+
executables: []
|
86
|
+
extensions: []
|
87
|
+
extra_rdoc_files: []
|
88
|
+
files:
|
89
|
+
- .gitignore
|
90
|
+
- .travis.yml
|
91
|
+
- Gemfile
|
92
|
+
- Guardfile
|
93
|
+
- LICENSE
|
94
|
+
- README.md
|
95
|
+
- Rakefile
|
96
|
+
- lib/throttling.rb
|
97
|
+
- lib/throttling/base.rb
|
98
|
+
- lib/throttling/indifferent_access.rb
|
99
|
+
- lib/throttling/version.rb
|
100
|
+
- spec/base_spec.rb
|
101
|
+
- spec/fixtures/throttling.yml
|
102
|
+
- spec/spec_helper.rb
|
103
|
+
- spec/throttling_spec.rb
|
104
|
+
- throttling.gemspec
|
105
|
+
homepage: https://github.com/kpumuk/throttling
|
106
|
+
licenses: []
|
107
|
+
post_install_message:
|
108
|
+
rdoc_options: []
|
109
|
+
require_paths:
|
110
|
+
- lib
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
112
|
+
none: false
|
113
|
+
requirements:
|
114
|
+
- - ! '>='
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '0'
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
118
|
+
none: false
|
119
|
+
requirements:
|
120
|
+
- - ! '>='
|
121
|
+
- !ruby/object:Gem::Version
|
122
|
+
version: '0'
|
123
|
+
requirements: []
|
124
|
+
rubyforge_project:
|
125
|
+
rubygems_version: 1.8.15
|
126
|
+
signing_key:
|
127
|
+
specification_version: 3
|
128
|
+
summary: Easy throttling for Ruby applications
|
129
|
+
test_files:
|
130
|
+
- spec/base_spec.rb
|
131
|
+
- spec/fixtures/throttling.yml
|
132
|
+
- spec/spec_helper.rb
|
133
|
+
- spec/throttling_spec.rb
|