traffic_jam 1.0.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 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: