Class: SimpleThrottle

Inherits:
Object
  • Object
show all
Defined in:
lib/simple_throttle.rb

Overview

Create a simple throttle that can be used to limit the number of request for a resouce per time period. These objects are thread safe.

Constant Summary collapse

LUA_SCRIPT =

Server side Lua script that maintains the throttle in redis. The throttle is stored as a list of timestamps in milliseconds. When the script is invoked it will scan the oldest entries removing any that should be expired from the list. If the list is below the specified limit then the current entry will be added. The list is marked to expire with the oldest entry so there's no need to cleanup the lists.

<<~LUA
  redis.replicate_commands()

  local list_key = KEYS[1]
  local limit = tonumber(ARGV[1])
  local ttl = tonumber(ARGV[2])
  local pause_to_recover = tonumber(ARGV[3])
  local amount = tonumber(ARGV[4])
  local cleanup = tonumber(ARGV[5])

  -- Use the Redis server clock so timestamps are consistent and monotonic
  -- across all clients regardless of individual machine clock skew.
  local time = redis.call('time')
  local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)

  local size = redis.call('llen', list_key)
  if size >= limit or (cleanup > 0 and size > 0) then
    local expired = now - ttl
    while size > 0 do
      local t = redis.call('lpop', list_key)
      if tonumber(t) > expired then
        redis.call('lpush', list_key, t)
        break
      end
      size = size - 1
    end
  end

  if pause_to_recover > 0 then
    limit = limit + 1
  end

  if size + amount > limit then
    amount = (limit - size) + 1
  end

  if size < limit then
    for i = 1, amount do
      redis.call('rpush', list_key, now)
    end
    redis.call('pexpire', list_key, ttl)
  end

  return size + amount
LUA

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, ttl:, limit:, pause_to_recover: false, redis: nil) ⇒ SimpleThrottle

Create a new throttle.

Parameters:

  • name (String)

    unique name for the throttle

  • ttl (Numeric)

    number of seconds that the throttle will remain active

  • limit (Integer)

    number of allowed requests within the throttle ttl

  • pause_to_recover (Boolean) (defaults to: false)

    require processes calling the throttle to pause at least temporarily before freeing up the throttle. If this is true, then a throttle called constantly with no pauses will never free up.

  • redis (Redis, Proc) (defaults to: nil)

    Redis instance to use or a Proc that yields a Redis instance



156
157
158
159
160
161
162
# File 'lib/simple_throttle.rb', line 156

def initialize(name, ttl:, limit:, pause_to_recover: false, redis: nil)
  @name = name.to_s.dup.freeze
  @limit = limit.to_i
  @ttl = ttl.to_f
  @pause_to_recover = !!pause_to_recover
  @redis = redis
end

Instance Attribute Details

#limitObject (readonly)

Returns the value of attribute limit.



145
146
147
# File 'lib/simple_throttle.rb', line 145

def limit
  @limit
end

#nameObject (readonly)

Returns the value of attribute name.



145
146
147
# File 'lib/simple_throttle.rb', line 145

def name
  @name
end

#ttlObject (readonly)

Returns the value of attribute ttl.



145
146
147
# File 'lib/simple_throttle.rb', line 145

def ttl
  @ttl
end

Class Method Details

.[](name) ⇒ SimpleThrottle

Returns a globally defined throttle with the specfied name.

Parameters:

  • name (String, Symbol)

    name of the throttle

Returns:



91
92
93
94
95
# File 'lib/simple_throttle.rb', line 91

def [](name)
  # Assign then read for thread safety.
  throttles = @throttles
  throttles[name.to_s] if throttles
end

.add(name, ttl:, limit:, pause_to_recover: false, redis: nil) ⇒ void

This method returns an undefined value.

Add a global throttle that can be referenced later with the [] method. This can be used to configure global throttles that you want to setup once and then use in multiple places.

Parameters:

  • name (String)

    unique name for the throttle

  • ttl (Numeric)

    number of seconds that the throttle will remain active

  • limit (Integer)

    number of allowed requests within the throttle ttl

  • pause_to_recover (Boolean) (defaults to: false)

    require processes calling the throttle to pause at least temporarily before freeing up the throttle. If this is true, then a throttle called constantly with no pauses will never free up.

  • redis (Redis, Proc) (defaults to: nil)

    Redis instance to use or a Proc that yields a Redis instance



77
78
79
80
81
82
83
84
85
# File 'lib/simple_throttle.rb', line 77

def add(name, ttl:, limit:, pause_to_recover: false, redis: nil)
  @lock.synchronize do
    # Copy-on-write so that lock-free readers in `[]` always see a
    # fully-populated, immutable hash and never a partially rehashed one.
    throttles = @throttles.dup
    throttles[name.to_s] = new(name, limit: limit, ttl: ttl, pause_to_recover: pause_to_recover, redis: redis)
    @throttles = throttles.freeze
  end
end

.redisRedis

Return the Redis instance where the throttles are stored.

Returns:

  • (Redis)


113
114
115
116
117
118
119
120
121
# File 'lib/simple_throttle.rb', line 113

def redis
  @lock.synchronize { @redis_client ||= Redis.new } unless @redis_client
  client = @redis_client
  if client.is_a?(Proc)
    client.call
  else
    client
  end
end

.set_redis(client = nil, &block) ⇒ void

This method returns an undefined value.

Set the Redis instance to use for maintaining the throttle. This can either be set with a hard coded value or by the value yielded by a block. If the block form is used it will be invoked at runtime to get the instance. Use this method if your Redis instance isn't constant (for example if you're in a forking environment and re-initialize connections on fork)

Parameters:

  • client (Redis, Proc) (defaults to: nil)

Yield Returns:

  • (Redis)


106
107
108
# File 'lib/simple_throttle.rb', line 106

def set_redis(client = nil, &block)
  @redis_client = (client || block) # rubocop:disable Style/RedundantParentheses
end

Instance Method Details

#allowed!Boolean

Returns true if the limit for the throttle has not been reached yet. This method will also track the throttled resource as having been invoked on each call.

Returns:

  • (Boolean)


168
169
170
171
# File 'lib/simple_throttle.rb', line 168

def allowed!
  size = add_request(1, false)
  size <= limit
end

#increment!(amount = 1) ⇒ Integer

Increment the throttle by the specified and return the current size. Because how the throttle is implemented in Redis, the return value will always max out at the throttle limit + 1 or, if the pause to recover option is set, limit + 2.

Parameters:

  • amount (Integer) (defaults to: 1)

    amount to increment the throttle by (must be positive)

Returns:

  • (Integer)

Raises:

  • (ArgumentError)


179
180
181
182
183
184
# File 'lib/simple_throttle.rb', line 179

def increment!(amount = 1)
  amount = amount.to_i
  raise ArgumentError, "amount must be a positive integer" if amount < 1

  add_request(amount, true)
end

#peekInteger

Peek at the current number for throttled calls being tracked.

Returns:

  • (Integer)


196
197
198
199
200
# File 'lib/simple_throttle.rb', line 196

def peek
  timestamps, now = timestamps_with_server_time
  min_timestamp = ((now - ttl) * 1000).ceil
  timestamps.count { |t| t > min_timestamp }
end

#reset!void

This method returns an undefined value.

Reset a throttle back to zero.



189
190
191
# File 'lib/simple_throttle.rb', line 189

def reset!
  redis_client.del(redis_key)
end

#wait_timeFloat

Returns when the next resource call should be allowed. Note that this doesn't guarantee that calling allow! will return true if the wait time is zero since other processes or threads can claim the resource.

Returns:

  • (Float)


207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/simple_throttle.rb', line 207

def wait_time
  timestamps, now = timestamps_with_server_time
  min_timestamp = ((now - ttl) * 1000).ceil
  if timestamps.count { |t| t > min_timestamp } < limit
    0.0
  else
    # The entry that frees up a slot is the limit-th newest (index -limit),
    # not the head of the list, since the list can legitimately hold more
    # than `limit` entries (increment! and pause_to_recover both add extras).
    oldest = timestamps[-limit]
    return 0.0 if oldest.nil?
    first = oldest.to_f / 1000.0
    wait = ttl - (now - first)
    wait.clamp(0.0, ttl)
  end
end