Class: RateLimiter

Inherits:
Object
  • Object
show all
Defined in:
lib/rate_limiter.rb,
lib/rate_limiter/limit_exceeded.rb,
lib/rate_limiter/on_create_record.rb

Overview

A redis backed rate limiter.

Direct Known Subclasses

EditRateLimiter

Defined Under Namespace

Modules: OnCreateRecord Classes: LimitExceeded

Constant Summary collapse

PERFORM_LUA =

reloader friendly

DiscourseRedis::EvalHelper.new <<~LUA unless defined?(PERFORM_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
PERFORM_LUA_AGGRESSIVE =
DiscourseRedis::EvalHelper.new <<~LUA
  local now = tonumber(ARGV[1])
  local secs = tonumber(ARGV[2])
  local max = tonumber(ARGV[3])

  local key = KEYS[1]

  local return_val = 0

  if ((tonumber(redis.call("LLEN", key)) < max) or
      (now - tonumber(redis.call("LRANGE", key, -1, -1)[1])) >= secs) then
    return_val = 1
  else
    return_val = 0
  end

  redis.call("LPUSH", key, now)
  redis.call("LTRIM", key, 0, max - 1)
  redis.call("EXPIRE", key, secs * 2)

  return return_val
LUA

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(user, type, max, secs, global: false, aggressive: false, error_code: nil, apply_limit_to_staff: false, staff_limit: { max: nil, secs: nil }) ⇒ RateLimiter

Returns a new instance of RateLimiter.



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
# File 'lib/rate_limiter.rb', line 38

def initialize(
  user,
  type,
  max,
  secs,
  global: false,
  aggressive: false,
  error_code: nil,
  apply_limit_to_staff: false,
  staff_limit: { max: nil, secs: nil }
)
  @user = user
  @type = type
  @key = build_key(type)
  @max = max
  @secs = secs
  @global = global
  @aggressive = aggressive
  @error_code = error_code
  @apply_limit_to_staff = apply_limit_to_staff
  @staff_limit = staff_limit

  # override the default values if staff user, and staff specific max is passed
  if @user&.staff? && !@apply_limit_to_staff && @staff_limit[:max].present?
    @max = @staff_limit[:max]
    @secs = @staff_limit[:secs]
  end
end

Instance Attribute Details

#error_codeObject (readonly)

Returns the value of attribute error_code.



5
6
7
# File 'lib/rate_limiter.rb', line 5

def error_code
  @error_code
end

#keyObject (readonly)

Returns the value of attribute key.



5
6
7
# File 'lib/rate_limiter.rb', line 5

def key
  @key
end

#maxObject (readonly)

Returns the value of attribute max.



5
6
7
# File 'lib/rate_limiter.rb', line 5

def max
  @max
end

#secsObject (readonly)

Returns the value of attribute secs.



5
6
7
# File 'lib/rate_limiter.rb', line 5

def secs
  @secs
end

#userObject (readonly)

Returns the value of attribute user.



5
6
7
# File 'lib/rate_limiter.rb', line 5

def user
  @user
end

Class Method Details

.clear_all_global!Object



26
27
28
29
30
31
32
# File 'lib/rate_limiter.rb', line 26

def self.clear_all_global!
  Discourse
    .redis
    .without_namespace
    .keys("GLOBAL::#{key_prefix}*")
    .each { |k| Discourse.redis.without_namespace.del k }
end

.disableObject



11
12
13
# File 'lib/rate_limiter.rb', line 11

def self.disable
  @disabled = true
end

.disabled?Boolean

We don’t observe rate limits in test mode

Returns:

  • (Boolean)


22
23
24
# File 'lib/rate_limiter.rb', line 22

def self.disabled?
  @disabled
end

.enableObject



15
16
17
# File 'lib/rate_limiter.rb', line 15

def self.enable
  @disabled = false
end

.key_prefixObject



7
8
9
# File 'lib/rate_limiter.rb', line 7

def self.key_prefix
  "l-rate-limit3:"
end

.time_left(available_in) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
# File 'lib/rate_limiter/limit_exceeded.rb', line 4

def self.time_left(available_in)
  if available_in <= 3
    I18n.t("rate_limiter.short_time")
  elsif available_in < 1.minute.to_i
    I18n.t("rate_limiter.seconds", count: available_in)
  elsif available_in < 1.hour.to_i
    I18n.t("rate_limiter.minutes", count: (available_in / 1.minute.to_i))
  else
    I18n.t("rate_limiter.hours", count: (available_in / 1.hour.to_i))
  end
end

Instance Method Details

#build_key(type) ⇒ Object



34
35
36
# File 'lib/rate_limiter.rb', line 34

def build_key(type)
  "#{RateLimiter.key_prefix}:#{@user && @user.id}:#{type}"
end

#can_perform?Boolean

Returns:

  • (Boolean)


71
72
73
# File 'lib/rate_limiter.rb', line 71

def can_perform?
  rate_unlimited? || is_under_limit?
end

#clear!Object



67
68
69
# File 'lib/rate_limiter.rb', line 67

def clear!
  redis.del(prefixed_key)
end

#performed!(raise_error: true) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rate_limiter.rb', line 125

def performed!(raise_error: true)
  return true if rate_unlimited?
  now = Time.now.to_i
  if ((@max || 0) <= 0) || rate_limiter_allowed?(now)
    raise RateLimiter::LimitExceeded.new(seconds_to_wait(now), @type, @error_code) if raise_error
    false
  else
    true
  end
rescue Redis::CommandError => e
  if e.message =~ /READONLY/
    # TODO,switch to in-memory rate limiter
  else
    raise
  end
end

#remainingObject



153
154
155
156
157
158
159
160
# File 'lib/rate_limiter.rb', line 153

def remaining
  return @max if @user && @user.staff?

  arr = redis.lrange(prefixed_key, 0, @max) || []
  t0 = Time.now.to_i
  arr.reject! { |a| (t0 - a.to_i) > @secs }
  @max - arr.size
end

#rollback!Object



142
143
144
145
146
147
148
149
150
151
# File 'lib/rate_limiter.rb', line 142

def rollback!
  return if RateLimiter.disabled?
  redis.lpop(prefixed_key)
rescue Redis::CommandError => e
  if e.message =~ /READONLY/
    # TODO,switch to in-memory rate limiter
  else
    raise
  end
end

#seconds_to_wait(now = Time.now.to_i) ⇒ Object



75
76
77
# File 'lib/rate_limiter.rb', line 75

def seconds_to_wait(now = Time.now.to_i)
  @secs - age_of_oldest(now)
end