Class: Pecorino::Throttle

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

Overview

Provides a throttle with a block based on the LeakyBucket. Once a bucket fills up, a block will be installed and an exception will be raised. Once a block is set, no checks will be done on the leaky bucket - any further requests will be refused until the block is lifted. The block time can be arbitrarily higher or lower than the amount of time it takes for the leaky bucket to leak out

Defined Under Namespace

Classes: State, Throttled

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key:, block_for: nil, adapter: Pecorino.adapter, **leaky_bucket_options) ⇒ Throttle

Returns a new instance of Throttle.

Parameters:

  • key (String)

    the key for both the block record and the leaky bucket

  • block_for (Numeric) (defaults to: nil)

    the number of seconds to block any further requests for. Defaults to time it takes the bucket to leak out to the level of 0

  • adapter (Pecorino::Adapters::BaseAdapter) (defaults to: Pecorino.adapter)

    a compatible adapter

  • leaky_bucket_options

    Options for LeakyBucket.new

See Also:

  • LeakyBucket.new


105
106
107
108
109
110
111
# File 'lib/pecorino/throttle.rb', line 105

def initialize(key:, block_for: nil, adapter: Pecorino.adapter, **leaky_bucket_options)
  @adapter = adapter
  leaky_bucket_options.delete(:adapter)
  @bucket = Pecorino::LeakyBucket.new(key: key, adapter: @adapter, **leaky_bucket_options)
  @key = key.to_s
  @block_for = block_for ? block_for.to_f : (@bucket.capacity / @bucket.leak_rate)
end

Instance Attribute Details

#keyString (readonly)

The key for that throttle. Each key defines a unique throttle based on either a given name or discriminators. If there is a component you want to key your throttle by, include it in the key keyword argument to the constructor, like "t-ip-#{your_rails_request.ip}"

Returns:

  • (String)


97
98
99
# File 'lib/pecorino/throttle.rb', line 97

def key
  @key
end

Instance Method Details

#able_to_accept?(n_tokens = 1) ⇒ boolean

Tells whether the throttle will let this number of requests pass without raising a Throttled. Note that this is not race-safe. Another request could overflow the bucket after you call able_to_accept? but before you call throttle!. So before performing the action you still need to call throttle!. You may still use able_to_accept? to provide better UX to your users before they cause an action that would otherwise throttle.

Parameters:

  • n_tokens (Float) (defaults to: 1)

Returns:

  • (boolean)


121
122
123
# File 'lib/pecorino/throttle.rb', line 121

def able_to_accept?(n_tokens = 1)
  @adapter.blocked_until(key: @key).nil? && @bucket.able_to_accept?(n_tokens)
end

#request(n = 1) ⇒ State

Register that a request is being performed. Will not raise any exceptions but return the time at which the block will be lifted if a block resulted from this request or was already in effect. Can be used for registering actions which already took place, but should result in subsequent actions being blocked.

Examples:

if t.able_to_accept?
  Entry.create!(entry_params)
  t.request
end

Parameters:

  • n (Numeric) (defaults to: 1)

    how many tokens to place into the bucket or remove from the bucket. May be fractional or negative.

Returns:

  • (State)

    the state of the throttle after the attempt to fill up the leaky bucket



161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/pecorino/throttle.rb', line 161

def request(n = 1)
  existing_blocked_until = Pecorino::Block.blocked_until(key: @key, adapter: @adapter)
  return State.new(existing_blocked_until.utc) if existing_blocked_until

  # Topup the leaky bucket, and if the topup gets rejected - block the caller
  fillup = @bucket.fillup_conditionally(n)
  if fillup.accepted?
    State.new(nil)
  else
    # and set the block if the fillup was rejected
    fresh_blocked_until = Pecorino::Block.set!(key: @key, block_for: @block_for, adapter: @adapter)
    State.new(fresh_blocked_until.utc)
  end
end

#request!(n = 1) ⇒ State

Register that a request is being performed. Will raise Throttled if there is a block in place for that throttle, or if the bucket cannot accept this fillup and the block has just been installed as a result of this particular request.

The exception can be rescued later to provide a 429 response. This method is better to use before performing the unit of work that the throttle is guarding:

If the method call returns it means that the request is not getting throttled.

Examples:

begin
  t.request!
  Note.create!(note_params)
rescue Pecorino::Throttle::Throttled => e
  [429, {"Retry-After" => e.retry_after.to_s}, []]
end

Parameters:

  • n (Numeric) (defaults to: 1)

    how many tokens to place into the bucket or remove from the bucket. May be fractional or negative.

Returns:

  • (State)

    the state of the throttle after filling up the leaky bucket / trying to pass the block



143
144
145
146
147
# File 'lib/pecorino/throttle.rb', line 143

def request!(n = 1)
  request(n).tap do |state_after|
    raise Throttled.new(self, state_after) if state_after.blocked?
  end
end

#throttled(&blk) ⇒ Object

Fillup the throttle with 1 request and then perform the passed block. This is useful to perform actions which should be rate-limited - alerts, calls to external services and the like. If the call is allowed to proceed, the passed block will be executed. If the throttle is in the blocked state or if the call puts the throttle in the blocked state the block will not be executed

Examples:

t.throttled { Slack.alert("Things are going wrong") }

Parameters:

  • blk

    The block to run. Will only run if the throttle accepts the call.

Returns:

  • (Object)

    the return value of the block if the block gets executed, or nil if the call got throttled



186
187
188
189
# File 'lib/pecorino/throttle.rb', line 186

def throttled(&blk)
  return if request(1).blocked?
  yield
end