Revert "Revert "PERF: improve speed of rate limiter""
This reverts commit 2373d85239
.
This commit is contained in:
parent
2373d85239
commit
dd70ef3abf
|
@ -20,9 +20,10 @@ class RateLimiter
|
||||||
|
|
||||||
# We don't observe rate limits in test mode
|
# We don't observe rate limits in test mode
|
||||||
def self.disabled?
|
def self.disabled?
|
||||||
@disabled || Rails.env.test?
|
@disabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only used in test, only clears current namespace, does not clear globals
|
||||||
def self.clear_all!
|
def self.clear_all!
|
||||||
$redis.delete_prefixed(RateLimiter.key_prefix)
|
$redis.delete_prefixed(RateLimiter.key_prefix)
|
||||||
end
|
end
|
||||||
|
@ -31,65 +32,102 @@ class RateLimiter
|
||||||
"#{RateLimiter.key_prefix}:#{@user && @user.id}:#{type}"
|
"#{RateLimiter.key_prefix}:#{@user && @user.id}:#{type}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(user, type, max, secs)
|
def initialize(user, type, max, secs, global: false)
|
||||||
@user = user
|
@user = user
|
||||||
@type = type
|
@type = type
|
||||||
@key = build_key(type)
|
@key = build_key(type)
|
||||||
@max = max
|
@max = max
|
||||||
@secs = secs
|
@secs = secs
|
||||||
|
@global = false
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear!
|
def clear!
|
||||||
$redis.del(@key)
|
redis.del(prefixed_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
def can_perform?
|
def can_perform?
|
||||||
rate_unlimited? || is_under_limit?
|
rate_unlimited? || is_under_limit?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# reloader friendly
|
||||||
|
unless defined? PERFORM_LUA
|
||||||
|
PERFORM_LUA = <<~LUA
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
local secs = tonumber(ARGV[2])
|
||||||
|
local max = tonumber(ARGV[3])
|
||||||
|
|
||||||
|
local key = KEYS[1]
|
||||||
|
|
||||||
|
|
||||||
|
if ((tonumber(redis.call("LLEN", key)) < max) or
|
||||||
|
(now - tonumber(redis.call("LRANGE", key, -1, -1)[1])) > secs) then
|
||||||
|
redis.call("LPUSH", key, now)
|
||||||
|
redis.call("LTRIM", key, 0, max - 1)
|
||||||
|
redis.call("EXPIRE", key, secs * 2)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
LUA
|
||||||
|
|
||||||
|
PERFORM_LUA_SHA = Digest::SHA1.hexdigest(PERFORM_LUA)
|
||||||
|
end
|
||||||
|
|
||||||
def performed!
|
def performed!
|
||||||
return if rate_unlimited?
|
return if rate_unlimited?
|
||||||
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
if is_under_limit?
|
if eval_lua(PERFORM_LUA, PERFORM_LUA_SHA, [prefixed_key], [now, @secs, @max]) == 0
|
||||||
# simple ring buffer.
|
|
||||||
$redis.lpush(@key, Time.now.to_i)
|
|
||||||
$redis.ltrim(@key, 0, @max - 1)
|
|
||||||
|
|
||||||
# let's ensure we expire this key at some point, otherwise we have leaks
|
|
||||||
$redis.expire(@key, @secs * 2)
|
|
||||||
else
|
|
||||||
raise RateLimiter::LimitExceeded.new(seconds_to_wait, @type)
|
raise RateLimiter::LimitExceeded.new(seconds_to_wait, @type)
|
||||||
end
|
end
|
||||||
|
rescue Redis::CommandError => e
|
||||||
|
if e.message =~ /READONLY/
|
||||||
|
# TODO,switch to in-memory rate limiter
|
||||||
|
else
|
||||||
|
raise
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def rollback!
|
def rollback!
|
||||||
return if RateLimiter.disabled?
|
return if RateLimiter.disabled?
|
||||||
$redis.lpop(@key)
|
redis.lpop(prefixed_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
def remaining
|
def remaining
|
||||||
return @max if @user && @user.staff?
|
return @max if @user && @user.staff?
|
||||||
|
|
||||||
arr = $redis.lrange(@key, 0, @max) || []
|
arr = redis.lrange(prefixed_key, 0, @max) || []
|
||||||
t0 = Time.now.to_i
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
arr.reject! { |a| (t0 - a.to_i) > @secs }
|
arr.reject! { |a| (t0 - a.to_i) > @secs }
|
||||||
@max - arr.size
|
@max - arr.size
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def prefixed_key
|
||||||
|
if @global
|
||||||
|
"GLOBAL::#{key}"
|
||||||
|
else
|
||||||
|
$redis.namespace_key(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
$redis.without_namespace
|
||||||
|
end
|
||||||
|
|
||||||
def seconds_to_wait
|
def seconds_to_wait
|
||||||
@secs - age_of_oldest
|
@secs - age_of_oldest
|
||||||
end
|
end
|
||||||
|
|
||||||
def age_of_oldest
|
def age_of_oldest
|
||||||
# age of oldest event in buffer, in seconds
|
# age of oldest event in buffer, in seconds
|
||||||
Time.now.to_i - $redis.lrange(@key, -1, -1).first.to_i
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) - redis.lrange(prefixed_key, -1, -1).first.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_under_limit?
|
def is_under_limit?
|
||||||
# number of events in buffer less than max allowed? OR
|
# number of events in buffer less than max allowed? OR
|
||||||
($redis.llen(@key) < @max) ||
|
(redis.llen(prefixed_key) < @max) ||
|
||||||
# age bigger than silding window size?
|
# age bigger than silding window size?
|
||||||
(age_of_oldest > @secs)
|
(age_of_oldest > @secs)
|
||||||
end
|
end
|
||||||
|
@ -97,4 +135,14 @@ class RateLimiter
|
||||||
def rate_unlimited?
|
def rate_unlimited?
|
||||||
!!(RateLimiter.disabled? || (@user && @user.staff?))
|
!!(RateLimiter.disabled? || (@user && @user.staff?))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def eval_lua(lua, sha, keys, args)
|
||||||
|
redis.evalsha(sha, keys, args)
|
||||||
|
rescue Redis::CommandError => e
|
||||||
|
if e.to_s =~ /^NOSCRIPT/
|
||||||
|
redis.eval(lua, keys, args)
|
||||||
|
else
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -172,38 +172,47 @@ describe Auth::DefaultCurrentUserProvider do
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "can only try 10 bad cookies a minute" do
|
context "rate limiting" do
|
||||||
user = Fabricate(:user)
|
|
||||||
token = UserAuthToken.generate!(user_id: user.id)
|
|
||||||
|
|
||||||
provider('/').log_on_user(user, {}, {})
|
before do
|
||||||
|
RateLimiter.enable
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
|
||||||
|
|
||||||
RateLimiter.new(nil, "cookie_auth_10.0.0.1", 10, 60).clear!
|
|
||||||
RateLimiter.new(nil, "cookie_auth_10.0.0.2", 10, 60).clear!
|
|
||||||
|
|
||||||
ip = "10.0.0.1"
|
|
||||||
env = { "HTTP_COOKIE" => "_t=#{SecureRandom.hex}", "REMOTE_ADDR" => ip }
|
|
||||||
|
|
||||||
10.times do
|
|
||||||
provider('/', env).current_user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
expect {
|
after do
|
||||||
provider('/', env).current_user
|
RateLimiter.disable
|
||||||
}.to raise_error(Discourse::InvalidAccess)
|
end
|
||||||
|
|
||||||
expect {
|
it "can only try 10 bad cookies a minute" do
|
||||||
env["HTTP_COOKIE"] = "_t=#{token.unhashed_auth_token}"
|
user = Fabricate(:user)
|
||||||
provider("/", env).current_user
|
token = UserAuthToken.generate!(user_id: user.id)
|
||||||
}.to raise_error(Discourse::InvalidAccess)
|
|
||||||
|
|
||||||
env["REMOTE_ADDR"] = "10.0.0.2"
|
provider('/').log_on_user(user, {}, {})
|
||||||
|
|
||||||
expect {
|
RateLimiter.new(nil, "cookie_auth_10.0.0.1", 10, 60).clear!
|
||||||
provider('/', env).current_user
|
RateLimiter.new(nil, "cookie_auth_10.0.0.2", 10, 60).clear!
|
||||||
}.not_to raise_error
|
|
||||||
|
ip = "10.0.0.1"
|
||||||
|
env = { "HTTP_COOKIE" => "_t=#{SecureRandom.hex}", "REMOTE_ADDR" => ip }
|
||||||
|
|
||||||
|
10.times do
|
||||||
|
provider('/', env).current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
expect {
|
||||||
|
provider('/', env).current_user
|
||||||
|
}.to raise_error(Discourse::InvalidAccess)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
env["HTTP_COOKIE"] = "_t=#{token.unhashed_auth_token}"
|
||||||
|
provider("/", env).current_user
|
||||||
|
}.to raise_error(Discourse::InvalidAccess)
|
||||||
|
|
||||||
|
env["REMOTE_ADDR"] = "10.0.0.2"
|
||||||
|
|
||||||
|
expect {
|
||||||
|
provider('/', env).current_user
|
||||||
|
}.not_to raise_error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "correctly removes invalid cookies" do
|
it "correctly removes invalid cookies" do
|
||||||
|
@ -295,44 +304,53 @@ describe Auth::DefaultCurrentUserProvider do
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "rate limits api usage" do
|
context "rate limiting" do
|
||||||
|
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
before do
|
||||||
limiter1 = RateLimiter.new(nil, "user_api_day_#{api_key.key}", 10, 60)
|
RateLimiter.enable
|
||||||
limiter2 = RateLimiter.new(nil, "user_api_min_#{api_key.key}", 10, 60)
|
|
||||||
limiter1.clear!
|
|
||||||
limiter2.clear!
|
|
||||||
|
|
||||||
SiteSetting.max_user_api_reqs_per_day = 3
|
|
||||||
SiteSetting.max_user_api_reqs_per_minute = 4
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"REQUEST_METHOD" => "GET",
|
|
||||||
"HTTP_USER_API_KEY" => api_key.key,
|
|
||||||
}
|
|
||||||
|
|
||||||
3.times do
|
|
||||||
provider("/", params).current_user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
expect {
|
after do
|
||||||
provider("/", params).current_user
|
RateLimiter.disable
|
||||||
}.to raise_error(RateLimiter::LimitExceeded)
|
|
||||||
|
|
||||||
SiteSetting.max_user_api_reqs_per_day = 4
|
|
||||||
SiteSetting.max_user_api_reqs_per_minute = 3
|
|
||||||
|
|
||||||
limiter1.clear!
|
|
||||||
limiter2.clear!
|
|
||||||
|
|
||||||
3.times do
|
|
||||||
provider("/", params).current_user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
expect {
|
it "rate limits api usage" do
|
||||||
provider("/", params).current_user
|
limiter1 = RateLimiter.new(nil, "user_api_day_#{api_key.key}", 10, 60)
|
||||||
}.to raise_error(RateLimiter::LimitExceeded)
|
limiter2 = RateLimiter.new(nil, "user_api_min_#{api_key.key}", 10, 60)
|
||||||
|
limiter1.clear!
|
||||||
|
limiter2.clear!
|
||||||
|
|
||||||
|
SiteSetting.max_user_api_reqs_per_day = 3
|
||||||
|
SiteSetting.max_user_api_reqs_per_minute = 4
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"REQUEST_METHOD" => "GET",
|
||||||
|
"HTTP_USER_API_KEY" => api_key.key,
|
||||||
|
}
|
||||||
|
|
||||||
|
3.times do
|
||||||
|
provider("/", params).current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
expect {
|
||||||
|
provider("/", params).current_user
|
||||||
|
}.to raise_error(RateLimiter::LimitExceeded)
|
||||||
|
|
||||||
|
SiteSetting.max_user_api_reqs_per_day = 4
|
||||||
|
SiteSetting.max_user_api_reqs_per_minute = 3
|
||||||
|
|
||||||
|
limiter1.clear!
|
||||||
|
limiter2.clear!
|
||||||
|
|
||||||
|
3.times do
|
||||||
|
provider("/", params).current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
expect {
|
||||||
|
provider("/", params).current_user
|
||||||
|
}.to raise_error(RateLimiter::LimitExceeded)
|
||||||
|
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,11 +8,14 @@ describe RateLimiter do
|
||||||
|
|
||||||
context 'disabled' do
|
context 'disabled' do
|
||||||
before do
|
before do
|
||||||
RateLimiter.stubs(:disabled?).returns(true)
|
|
||||||
rate_limiter.performed!
|
rate_limiter.performed!
|
||||||
rate_limiter.performed!
|
rate_limiter.performed!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "should be disabled" do
|
||||||
|
expect(RateLimiter.disabled?).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
it "returns true for can_perform?" do
|
it "returns true for can_perform?" do
|
||||||
expect(rate_limiter.can_perform?).to eq(true)
|
expect(rate_limiter.can_perform?).to eq(true)
|
||||||
end
|
end
|
||||||
|
@ -25,10 +28,41 @@ describe RateLimiter do
|
||||||
|
|
||||||
context 'enabled' do
|
context 'enabled' do
|
||||||
before do
|
before do
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
RateLimiter.enable
|
||||||
rate_limiter.clear!
|
rate_limiter.clear!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
RateLimiter.disable
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'global rate limiter' do
|
||||||
|
|
||||||
|
it 'can operate in global mode' do
|
||||||
|
limiter = RateLimiter.new(nil, "test", 2, 10, global: true)
|
||||||
|
limiter.clear!
|
||||||
|
|
||||||
|
limiter.performed!
|
||||||
|
limiter.performed!
|
||||||
|
expect { limiter.performed! }.to raise_error(RateLimiter::LimitExceeded)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'handles readonly' do
|
||||||
|
before do
|
||||||
|
$redis.without_namespace.slaveof '10.0.0.1', '99999'
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
$redis.without_namespace.slaveof 'no', 'one'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not explode' do
|
||||||
|
expect { rate_limiter.performed! }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'never done' do
|
context 'never done' do
|
||||||
it "should perform right away" do
|
it "should perform right away" do
|
||||||
expect(rate_limiter.can_perform?).to eq(true)
|
expect(rate_limiter.can_perform?).to eq(true)
|
||||||
|
|
|
@ -756,7 +756,7 @@ describe SessionController do
|
||||||
context 'rate limited' do
|
context 'rate limited' do
|
||||||
it 'rate limits login' do
|
it 'rate limits login' do
|
||||||
SiteSetting.max_logins_per_ip_per_hour = 2
|
SiteSetting.max_logins_per_ip_per_hour = 2
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
RateLimiter.enable
|
||||||
RateLimiter.clear_all!
|
RateLimiter.clear_all!
|
||||||
|
|
||||||
2.times do
|
2.times do
|
||||||
|
|
|
@ -20,7 +20,7 @@ describe PostAction do
|
||||||
|
|
||||||
it "limits redo/undo" do
|
it "limits redo/undo" do
|
||||||
|
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
RateLimiter.enable
|
||||||
|
|
||||||
PostAction.act(eviltrout, post, PostActionType.types[:like])
|
PostAction.act(eviltrout, post, PostActionType.types[:like])
|
||||||
PostAction.remove_act(eviltrout, post, PostActionType.types[:like])
|
PostAction.remove_act(eviltrout, post, PostActionType.types[:like])
|
||||||
|
|
|
@ -579,7 +579,7 @@ describe Topic do
|
||||||
|
|
||||||
it "rate limits topic invitations" do
|
it "rate limits topic invitations" do
|
||||||
SiteSetting.max_topic_invitations_per_day = 2
|
SiteSetting.max_topic_invitations_per_day = 2
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
RateLimiter.enable
|
||||||
RateLimiter.clear_all!
|
RateLimiter.clear_all!
|
||||||
|
|
||||||
start = Time.now.tomorrow.beginning_of_day
|
start = Time.now.tomorrow.beginning_of_day
|
||||||
|
@ -1694,7 +1694,7 @@ describe Topic do
|
||||||
SiteSetting.max_replies_in_first_day = 1
|
SiteSetting.max_replies_in_first_day = 1
|
||||||
SiteSetting.stubs(:client_settings_json).returns(SiteSetting.client_settings_json_uncached)
|
SiteSetting.stubs(:client_settings_json).returns(SiteSetting.client_settings_json_uncached)
|
||||||
RateLimiter.stubs(:rate_limit_create_topic).returns(100)
|
RateLimiter.stubs(:rate_limit_create_topic).returns(100)
|
||||||
RateLimiter.stubs(:disabled?).returns(false)
|
RateLimiter.enable
|
||||||
RateLimiter.clear_all!
|
RateLimiter.clear_all!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -106,7 +106,8 @@ RSpec.configure do |config|
|
||||||
# perf benefit seems low (shaves 20 secs off a 4 minute test suite)
|
# perf benefit seems low (shaves 20 secs off a 4 minute test suite)
|
||||||
#
|
#
|
||||||
# $redis = DiscourseMockRedis.new
|
# $redis = DiscourseMockRedis.new
|
||||||
#
|
|
||||||
|
RateLimiter.disable
|
||||||
PostActionNotifier.disable
|
PostActionNotifier.disable
|
||||||
SearchIndexer.disable
|
SearchIndexer.disable
|
||||||
UserActionCreator.disable
|
UserActionCreator.disable
|
||||||
|
@ -202,6 +203,7 @@ def freeze_time(now = Time.now)
|
||||||
Time.stubs(:now).returns(time)
|
Time.stubs(:now).returns(time)
|
||||||
Date.stubs(:today).returns(datetime.to_date)
|
Date.stubs(:today).returns(datetime.to_date)
|
||||||
TrackTimeStub.stubs(:stubbed).returns(true)
|
TrackTimeStub.stubs(:stubbed).returns(true)
|
||||||
|
Process.stubs(:clock_gettime).with(Process::CLOCK_MONOTONIC).returns(datetime.to_f)
|
||||||
|
|
||||||
if block_given?
|
if block_given?
|
||||||
begin
|
begin
|
||||||
|
@ -217,6 +219,7 @@ def unfreeze_time
|
||||||
Time.unstub(:now)
|
Time.unstub(:now)
|
||||||
Date.unstub(:today)
|
Date.unstub(:today)
|
||||||
TrackTimeStub.unstub(:stubbed)
|
TrackTimeStub.unstub(:stubbed)
|
||||||
|
Process.unstub(:clock_gettime)
|
||||||
end
|
end
|
||||||
|
|
||||||
def file_from_fixtures(filename, directory = "images")
|
def file_from_fixtures(filename, directory = "images")
|
||||||
|
|
Loading…
Reference in New Issue