traffic_jam 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d94e33ae19bb7a6f3780230e0236a937dde90cb5
4
+ data.tar.gz: aba261c52a890bf62392c0fda67742af28d9828c
5
+ SHA512:
6
+ metadata.gz: aee5755e8a2585c309523cd1851ed2dbb81ae93e179e3605a0bdc945a02ca05ed3bc72e915bf2bfdb4574052cd3aaa3be6434a64e108186d8a1ec284d6c64e55
7
+ data.tar.gz: fafd3000ef48ddfde67bae79e072429f07cf8d11f088bcb418a754f3bfd5779cc6554fe4ebba6146cd9e98b2076b5618874d48ff28f7a4efb21679ddd27ae8b6
@@ -0,0 +1,63 @@
1
+ require 'ostruct'
2
+ require 'digest/md5'
3
+ require_relative 'traffic_jam/errors'
4
+ require_relative 'traffic_jam/configuration'
5
+ require_relative 'traffic_jam/limit'
6
+ require_relative 'traffic_jam/limit_group'
7
+
8
+
9
+ module TrafficJam
10
+ include Errors
11
+
12
+ @config = Configuration.new(
13
+ key_prefix: 'traffic_jam',
14
+ hash_length: 22
15
+ )
16
+
17
+ class << self
18
+ attr_reader :config
19
+
20
+ # Configure library in a block.
21
+ #
22
+ # @yield [TrafficJam::Configuration]
23
+ def configure
24
+ yield config
25
+ end
26
+
27
+ # Create limit with registed max/period.
28
+ #
29
+ # @param action [Symbol] registered action name
30
+ # @param value [String] limit target value
31
+ # @return [TrafficJam::Limit]
32
+ def limit(action, value)
33
+ limits = config.limits(action.to_sym)
34
+ TrafficJam::Limit.new(action, value, **limits)
35
+ end
36
+
37
+ # Reset all limits associated with the given action. If action is omitted or
38
+ # nil, this will reset all limits.
39
+ #
40
+ # @note Not recommended for use in production.
41
+ # @param action [Symbol] action to reset limits for
42
+ # @return [nil]
43
+ def reset_all(action: nil)
44
+ prefix =
45
+ if action.nil?
46
+ "#{config.key_prefix}:*"
47
+ else
48
+ "#{config.key_prefix}:#{action}:*"
49
+ end
50
+ config.redis.keys(prefix).each do |key|
51
+ config.redis.del(key)
52
+ end
53
+ nil
54
+ end
55
+
56
+ %w( exceeded? increment increment! decrement reset used remaining )
57
+ .each do |method|
58
+ define_method(method) do |action, value, *args|
59
+ limit(action, value).send(method, *args)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ module TrafficJam
2
+ # Configuration for TrafficJam library.
3
+ #
4
+ # @see TrafficJam#configure
5
+ class Configuration
6
+ OPTIONS = %i( key_prefix hash_length redis )
7
+
8
+ # @!attribute redis
9
+ # @return [Redis] the connected Redis client the library uses
10
+ # @!attribute key_prefix
11
+ # @return [String] the prefix of all limit keys in Redis
12
+ # @!attribute hash_length
13
+ # @return [String] the number of characters to use from the Base64 encoded
14
+ # hashes of the limit values
15
+ attr_accessor *OPTIONS
16
+
17
+ def initialize(options = {})
18
+ OPTIONS.each do |option|
19
+ self.send("#{option}=", options[option])
20
+ end
21
+ end
22
+
23
+ # Register a default cap and period with an action name. For use with
24
+ # {TrafficJam.limit}.
25
+ #
26
+ # @param action [Symbol] action name
27
+ # @param max [Integer] limit cap
28
+ # @param period [Fixnum] limit period in seconds
29
+ def register(action, max, period)
30
+ @limits ||= {}
31
+ @limits[action.to_sym] = { max: max, period: period }
32
+ end
33
+
34
+ # Get the limit cap registered to an action.
35
+ #
36
+ # @see #register
37
+ # @return [Integer] limit cap
38
+ def max(action)
39
+ limits(action)[:max]
40
+ end
41
+
42
+ # Get the limit period registered to an action.
43
+ #
44
+ # @see #register
45
+ # @return [Integer] limit period in seconds
46
+ def period(action)
47
+ limits(action)[:period]
48
+ end
49
+
50
+ # Get registered limit parameters for an action.
51
+ #
52
+ # @see #register
53
+ # @param action [Symbol] action name
54
+ # @return [Hash] max and period parameters in a hash
55
+ # @raise [TrafficJam::LimitNotFound] if action is not registered
56
+ def limits(action)
57
+ @limits ||= {}
58
+ limits = @limits[action.to_sym]
59
+ raise TrafficJam::LimitNotFound.new(action) if limits.nil?
60
+ limits
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ module TrafficJam
2
+ module Errors
3
+ class LimitNotFound < StandardError; end
4
+
5
+ class LimitExceededError < StandardError
6
+ attr_accessor :limit
7
+
8
+ def initialize(limit)
9
+ super("Rate limit exceeded: #{limit.action}")
10
+ @limit = limit
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,178 @@
1
+ require_relative 'scripts'
2
+
3
+ module TrafficJam
4
+ # This class represents a rate limit on an action, value pair. For example, if
5
+ # rate limiting the number of requests per IP address, the action could be
6
+ # +:requests+ and the value would be the IP address. The class exposes atomic
7
+ # increment operations and allows querying of the current amount used and
8
+ # amount remaining.
9
+ class Limit
10
+ # @!attribute [r] action
11
+ # @return [Symbol] the name of the action being rate limited.
12
+ # @!attribute [r] value
13
+ # @return [String] the target of the limit. The value should be a string
14
+ # or convertible to a distinct string when +to_s+ is called. If you
15
+ # would like to use objects that can be converted to a unique string,
16
+ # like a database-mapped object with an ID, you can implement
17
+ # +to_rate_limit_value+ on the object, which returns a deterministic
18
+ # string unique to that object.
19
+ # @!attribute [r] max
20
+ # @return [Integer] the integral cap of the limit amount.
21
+ # @!attribute [r] period
22
+ # @return [Integer] the duration of the limit in seconds. Regardless of
23
+ # the current amount used, after the period passes, the amount used will
24
+ # be 0.
25
+ attr_reader :action, :max, :period, :value
26
+
27
+ # Constructor takes an action name as a symbol, a maximum cap, and the
28
+ # period of limit. +max+ and +period+ are required keyword arguments.
29
+ #
30
+ # @param action [Symbol] action name
31
+ # @param value [String] limit target value
32
+ # @param max [Integer] required limit maximum
33
+ # @param period [Integer] required limit period in seconds
34
+ # @raise [ArgumentError] if max or period is nil
35
+ def initialize(action, value, max: nil, period: nil)
36
+ raise ArgumentError('Max is required') if max.nil?
37
+ raise ArgumentError('Period is required') if period.nil?
38
+ @action, @value, @max, @period = action, value, max, period
39
+ end
40
+
41
+ # Return whether incrementing by the given amount would exceed limit. Does
42
+ # not change amount used.
43
+ #
44
+ # @param amount [Integer]
45
+ # @return [Boolean]
46
+ def exceeded?(amount = 1)
47
+ used + amount > max
48
+ end
49
+
50
+ # Return itself if incrementing by the given amount would exceed limit,
51
+ # otherwise nil. Does not change amount used.
52
+ #
53
+ # @return [TrafficJam::Limit, nil]
54
+ def limit_exceeded(amount = 1)
55
+ self if exceeded?(amount)
56
+ end
57
+
58
+ # Increment the amount used by the given number. Does not perform increment
59
+ # if the operation would exceed the limit. Returns whether the operation was
60
+ # successful. Time of increment can be specified optionally with a keyword
61
+ # argument, which is useful for rolling back with a decrement.
62
+ #
63
+ # @param amount [Integer] amount to increment by
64
+ # @param time [Time] time when increment occurs
65
+ # @return [Boolean] true if increment succeded and false if incrementing
66
+ # would exceed the limit
67
+ def increment(amount = 1, time: Time.now)
68
+ return amount <= 0 if max.zero?
69
+
70
+ if amount != amount.to_i
71
+ raise ArgumentError.new("Amount must be an integer")
72
+ end
73
+
74
+ timestamp = (time.to_f * 1000).round
75
+ argv = [timestamp, amount.to_i, max, period * 1000]
76
+
77
+ result =
78
+ begin
79
+ redis.evalsha(
80
+ Scripts::INCREMENT_SCRIPT_HASH, keys: [key], argv: argv)
81
+ rescue Redis::CommandError => e
82
+ redis.eval(Scripts::INCREMENT_SCRIPT, keys: [key], argv: argv)
83
+ end
84
+
85
+ !!result
86
+ end
87
+
88
+ # Increment the amount used by the given number. Does not perform increment
89
+ # if the operation would exceed the limit. Raises an exception if the
90
+ # operation is unsuccessful. Time of# increment can be specified optionally
91
+ # with a keyword argument, which is useful for rolling back with a
92
+ # decrement.
93
+ #
94
+ # @param amount [Integer] amount to increment by
95
+ # @param time [Time] time when increment occurs
96
+ # @return [nil]
97
+ # @raise [TrafficJam::LimitExceededError] if incrementing would exceed the
98
+ # limit
99
+ def increment!(amount = 1, time: Time.now)
100
+ if !increment(amount, time: time)
101
+ raise TrafficJam::LimitExceededError.new(self)
102
+ end
103
+ end
104
+
105
+ # Decrement the amount used by the given number. Time of decrement can be
106
+ # specified optionally with a keyword argument, which is useful for rolling
107
+ # back an increment operation at a certain time.
108
+ #
109
+ # @param amount [Integer] amount to increment by
110
+ # @param time [Time] time when increment occurs
111
+ # @return [true]
112
+ def decrement(amount = 1, time: Time.now)
113
+ increment(-amount, time: time)
114
+ end
115
+
116
+ # Reset amount used to 0.
117
+ #
118
+ # @return [nil]
119
+ def reset
120
+ redis.del(key)
121
+ nil
122
+ end
123
+
124
+ # Return amount of limit used, taking time drift into account.
125
+ #
126
+ # @return [Integer] amount used
127
+ def used
128
+ return 0 if max.zero?
129
+
130
+ obj = redis.hgetall(key)
131
+ timestamp = obj['timestamp']
132
+ amount = obj['amount']
133
+ if timestamp && amount
134
+ time_passed = Time.now.to_f - timestamp.to_i / 1000.0
135
+ drift = max * time_passed / period
136
+ last_amount = [amount.to_f, max].min
137
+ [(last_amount - drift).ceil, 0].max
138
+ else
139
+ 0
140
+ end
141
+ end
142
+
143
+ # Return amount of limit remaining, taking time drift into account.
144
+ #
145
+ # @return [Integer] amount remaining
146
+ def remaining
147
+ max - used
148
+ end
149
+
150
+ def flatten
151
+ [self]
152
+ end
153
+
154
+ private
155
+ def config
156
+ TrafficJam.config
157
+ end
158
+
159
+ def redis
160
+ config.redis
161
+ end
162
+
163
+ def key
164
+ if @key.nil?
165
+ converted_value =
166
+ begin
167
+ value.to_rate_limit_value
168
+ rescue NoMethodError
169
+ value
170
+ end
171
+ hash = Digest::MD5.base64digest(converted_value.to_s)
172
+ hash = hash[0...config.hash_length]
173
+ @key = "#{config.key_prefix}:#{action}:#{hash}"
174
+ end
175
+ @key
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,129 @@
1
+ module TrafficJam
2
+ # A limit group is a way of enforcing a cap over a set of limits with the
3
+ # guarantee that either all limits will be incremented or none. This is useful
4
+ # if you must check multiple limits before allowing an action to be taken.
5
+ # Limit groups can contain other limit groups.
6
+ class LimitGroup
7
+ attr_reader :limits
8
+
9
+ # Creates a limit group from a collection of limits or other limit groups.
10
+ #
11
+ # @param limits [Array<TrafficJam::Limit>] either an array or splat of
12
+ # limits or other limit groups
13
+ # @param ignore_nil_values [Boolean] silently drop limits with a nil value
14
+ def initialize(*limits, ignore_nil_values: false)
15
+ @limits = limits.flatten
16
+ @ignore_nil_values = ignore_nil_values
17
+ if @ignore_nil_values
18
+ @limits.reject! do |limit|
19
+ limit.respond_to?(:value) && limit.value.nil?
20
+ end
21
+ end
22
+ end
23
+
24
+ # Add a limit to the group.
25
+ #
26
+ # @param limit [TrafficJam::Limit, TrafficJam::LimitGroup]
27
+ def <<(limit)
28
+ if !(@ignore_nil_values && limit.value.nil?)
29
+ limits << limit
30
+ end
31
+ end
32
+
33
+ # Attempt to increment the limits by the given amount. Does not increment
34
+ # if incrementing would exceed any limit.
35
+ #
36
+ # @param amount [Integer] amount to increment by
37
+ # @param time [Time] optional time of increment
38
+ # @return [Boolean] whether increment operation was successful
39
+ def increment(amount = 1, time: Time.now)
40
+ exceeded_index = limits.find_index do |limit|
41
+ !limit.increment(amount, time: time)
42
+ end
43
+ if exceeded_index
44
+ limits[0...exceeded_index].each do |limit|
45
+ limit.decrement(amount, time: time)
46
+ end
47
+ end
48
+ exceeded_index.nil?
49
+ end
50
+
51
+ # Increment the limits by the given amount. Raises an error and does not
52
+ # increment if doing so would exceed any limit.
53
+ #
54
+ # @param amount [Integer] amount to increment by
55
+ # @param time [Time] optional time of increment
56
+ # @return [nil]
57
+ # @raise [TrafficJam::LimitExceededError] if increment would exceed any
58
+ # limits
59
+ def increment!(amount = 1, time: Time.now)
60
+ exception = nil
61
+ exceeded_index = limits.find_index do |limit|
62
+ begin
63
+ limit.increment!(amount, time: time)
64
+ rescue TrafficJam::LimitExceededError => e
65
+ exception = e
66
+ true
67
+ end
68
+ end
69
+ if exceeded_index
70
+ limits[0...exceeded_index].each do |limit|
71
+ limit.decrement(amount, time: time)
72
+ end
73
+ raise exception
74
+ end
75
+ end
76
+
77
+ # Decrement the limits by the given amount.
78
+ #
79
+ # @param amount [Integer] amount to decrement by
80
+ # @param time [Time] optional time of decrement
81
+ # @return [true]
82
+ def decrement(amount = 1, time: Time.now)
83
+ limits.all? { |limit| limit.decrement(amount, time: time) }
84
+ end
85
+
86
+ # Return whether incrementing by the given amount would exceed any limit.
87
+ # Does not change amount used.
88
+ #
89
+ # @param amount [Integer]
90
+ # @return [Boolean] whether any limit would be exceeded
91
+ def exceeded?(amount = 1)
92
+ limits.any? { |limit| limit.exceeded?(amount) }
93
+ end
94
+
95
+ # Return the first limit to be exceeded if incrementing by the given amount,
96
+ # or nil otherwise. Does not change amount used for any limit.
97
+ #
98
+ # @param amount [Integer]
99
+ # @return [TrafficJam::Limit, nil]
100
+ def limit_exceeded(amount = 1)
101
+ limits.each do |limit|
102
+ limit_exceeded = limit.limit_exceeded(amount)
103
+ return limit_exceeded if limit_exceeded
104
+ end
105
+ nil
106
+ end
107
+
108
+ # Resets all limits to 0.
109
+ def reset
110
+ limits.each(&:reset)
111
+ nil
112
+ end
113
+
114
+ # Return minimum amount remaining of any limit.
115
+ #
116
+ # @return [Integer] amount remaining in limit group
117
+ def remaining
118
+ limits.map(&:remaining).min
119
+ end
120
+
121
+ # Return flattened list of limit. Will return list limits even if this group
122
+ # contains nested limit groups.
123
+ #
124
+ # @return [Array<TrafficJam::Limit>] list of limits
125
+ def flatten
126
+ limits.map(&:flatten).flatten
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,14 @@
1
+ require 'digest/sha1'
2
+
3
+ module TrafficJam
4
+ module Scripts
5
+ def self.load(name)
6
+ scripts_dir = File.join(File.dirname(__FILE__), '..', '..', 'scripts')
7
+ File.read(File.join(scripts_dir, "#{name}.lua"))
8
+ end
9
+ private_class_method :load
10
+
11
+ INCREMENT_SCRIPT = load('increment')
12
+ INCREMENT_SCRIPT_HASH = Digest::SHA1.hexdigest(INCREMENT_SCRIPT)
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ local arg_timestamp = tonumber(ARGV[1])
2
+ local arg_amount = tonumber(ARGV[2])
3
+ local arg_max = tonumber(ARGV[3])
4
+ local arg_period = tonumber(ARGV[4])
5
+
6
+ local old_timestamp = redis.call("HGET", KEYS[1], "timestamp")
7
+
8
+ local new_amount
9
+ local new_timestamp
10
+
11
+ if not old_timestamp
12
+ then
13
+ new_amount = arg_amount
14
+ new_timestamp = arg_timestamp
15
+ else
16
+ local time_diff = arg_timestamp - tonumber(old_timestamp)
17
+ local drift_amount = time_diff * arg_max / arg_period
18
+ if time_diff < 0
19
+ then
20
+ local incr_amount = arg_amount + drift_amount
21
+ if incr_amount <= 0
22
+ then
23
+ return true
24
+ end
25
+ local old_amount = tonumber(redis.call("HGET", KEYS[1], "amount"))
26
+ old_amount = math.min(old_amount, arg_max)
27
+ new_amount = old_amount + incr_amount
28
+ new_timestamp = old_timestamp
29
+ else
30
+ local old_amount = tonumber(redis.call("HGET", KEYS[1], "amount"))
31
+ old_amount = math.min(old_amount, arg_max)
32
+ local current_amount = math.max(old_amount - drift_amount, 0)
33
+ new_amount = current_amount + arg_amount
34
+ new_timestamp = arg_timestamp
35
+ end
36
+ end
37
+
38
+ if new_amount > arg_max
39
+ then
40
+ return false
41
+ end
42
+
43
+ redis.call("HSET", KEYS[1], "amount", new_amount)
44
+ redis.call("HSET", KEYS[1], "timestamp", new_timestamp)
45
+ redis.call("EXPIRE", KEYS[1], arg_period)
46
+ return true
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: traffic_jam
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jim Posen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-05-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ description: Library for Redis-backed time-based rate limiting
42
+ email: jimpo@coinbase.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/traffic_jam.rb
48
+ - lib/traffic_jam/configuration.rb
49
+ - lib/traffic_jam/errors.rb
50
+ - lib/traffic_jam/limit.rb
51
+ - lib/traffic_jam/limit_group.rb
52
+ - lib/traffic_jam/scripts.rb
53
+ - scripts/increment.lua
54
+ homepage: ''
55
+ licenses:
56
+ - MIT
57
+ metadata: {}
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubyforge_project:
74
+ rubygems_version: 2.2.2
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Library for time-based rate limiting
78
+ test_files: []
79
+ has_rdoc: