forked from ejfinneran/ratelimit
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathratelimit.rb
172 lines (151 loc) · 5.71 KB
/
ratelimit.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
require 'redis'
require 'redis-namespace'
class Ratelimit
COUNT_LUA_SCRIPT = <<-LUA.freeze
local subject = KEYS[1]
local oldest_bucket = tonumber(ARGV[1])
local current_bucket = tonumber(ARGV[2])
local count = 0
for bucket = oldest_bucket + 1, current_bucket do
local value = redis.call('HGET', subject, tostring(bucket))
if value then
count = count + tonumber(value)
end
end
return count
LUA
MAINTENANCE_LUA_SCRIPT = <<-LUA.freeze
local subject = KEYS[1]
local oldest_bucket = tonumber(ARGV[1])
-- Delete expired keys
local all_keys = redis.call('HKEYS', subject)
for _, key in ipairs(all_keys) do
local bucket_key = tonumber(key)
if bucket_key < oldest_bucket then
redis.call('HDEL', subject, tostring(bucket_key))
end
end
LUA
# Create a Ratelimit object.
#
# @param [String] key A name to uniquely identify this rate limit. For example, 'emails'
# @param [Hash] options Options hash
# @option options [Integer] :bucket_span (600) Time span to track in seconds
# @option options [Integer] :bucket_interval (5) How many seconds each bucket represents
# @option options [Integer] :bucket_expiry (@bucket_span) How long we keep data in each bucket before it is auto expired. Cannot be larger than the bucket_span.
# @option options [Redis] :redis (nil) Redis client if you need to customize connection options
#
# @return [Ratelimit] Ratelimit instance
#
def initialize(key, options = {})
@key = key
unless options.is_a?(Hash)
raise ArgumentError.new("Redis object is now passed in via the options hash - options[:redis]")
end
@bucket_span = options[:bucket_span] || 600
@bucket_interval = options[:bucket_interval] || 5
@bucket_expiry = options[:bucket_expiry] || @bucket_span
if @bucket_expiry > @bucket_span
raise ArgumentError.new("Bucket expiry cannot be larger than the bucket span")
end
@bucket_count = (@bucket_span / @bucket_interval).round
if @bucket_count < 3
raise ArgumentError.new("Cannot have less than 3 buckets")
end
@raw_redis = options[:redis]
load_scripts
end
# Add to the counter for a given subject.
#
# @param [String] subject A unique key to identify the subject. For example, '[email protected]'
# @param [Integer] count The number by which to increase the counter
#
# @return [Integer] The counter value
def add(subject, count = 1)
bucket = get_bucket
subject = "#{@key}:#{subject}"
# Cleanup expired keys every 100th request
cleanup_expired_keys(subject) if rand < 0.01
redis.multi do |transaction|
transaction.hincrby(subject, bucket, count)
transaction.expire(subject, @bucket_expiry + @bucket_interval)
end.first
end
# Returns the count for a given subject and interval
#
# @param [String] subject Subject for the count
# @param [Integer] interval How far back (in seconds) to retrieve activity.
def count(subject, interval)
interval = [[interval, @bucket_interval].max, @bucket_span].min
oldest_bucket = get_bucket(Time.now.to_i - interval)
current_bucket = get_bucket
subject = "#{@key}:#{subject}"
execute_script(@count_script_sha, [subject], [oldest_bucket, current_bucket])
end
# Check if the rate limit has been exceeded.
#
# @param [String] subject Subject to check
# @param [Hash] options Options hash
# @option options [Integer] :interval How far back to retrieve activity.
# @option options [Integer] :threshold Maximum number of actions
def exceeded?(subject, options = {})
return count(subject, options[:interval]) >= options[:threshold]
end
# Check if the rate limit is within bounds
#
# @param [String] subject Subject to check
# @param [Hash] options Options hash
# @option options [Integer] :interval How far back to retrieve activity.
# @option options [Integer] :threshold Maximum number of actions
def within_bounds?(subject, options = {})
return !exceeded?(subject, options)
end
# Execute a block once the rate limit is within bounds
# *WARNING* This will block the current thread until the rate limit is within bounds.
#
# @param [String] subject Subject for this rate limit
# @param [Hash] options Options hash
# @option options [Integer] :interval How far back to retrieve activity.
# @option options [Integer] :threshold Maximum number of actions
# @yield The block to be run
#
# @example Send an email as long as we haven't send 5 in the last 10 minutes
# ratelimit.exec_with_threshold(email, [:threshold => 5, :interval => 600]) do
# send_another_email
# ratelimit.add(email)
# end
def exec_within_threshold(subject, options = {}, &block)
options[:threshold] ||= 30
options[:interval] ||= 30
while exceeded?(subject, options)
sleep @bucket_interval
end
yield(self)
end
private
def get_bucket(time = Time.now.to_i)
(time / @bucket_interval).floor
end
# Cleanup expired keys for a given subject
def cleanup_expired_keys(subject)
oldest_bucket = get_bucket(Time.now.to_i - @bucket_expiry)
execute_script(@maintenance_script_sha, [subject], [oldest_bucket])
end
# Execute the script or reload the scripts on error
def execute_script(*args)
redis.evalsha(*args)
rescue Redis::CommandError => e
raise unless e.message =~ /NOSCRIPT/
load_scripts
retry
end
# Load the lua scripts into redis
# This must be on the redis.redis object, not the namespace
def load_scripts
@count_script_sha = redis.redis.script(:load, COUNT_LUA_SCRIPT)
@maintenance_script_sha = redis.redis.script(:load, MAINTENANCE_LUA_SCRIPT)
end
def redis
@redis ||= Redis::Namespace.new(:ratelimit, redis: @raw_redis || Redis.new)
end
end