bucket_hydra v0.1.1 BucketHydra behaviour

A bucket-based rate limit algorithm with support for clusters.

Usage

Create a module that acts as the rate limiter:

defmodule MyApiRateLimit do
  use BucketHydra,
    pubsub: MyAppWeb.Endpoint,
    size: 50,
    time_scale: :timer.minutes(5)
end

Start your rate limiter in each node of the cluster:

children = [
  # ...
  MyApiRateLimit,
  # ...
]

Consume space in a bucket with consume/2, for instance in a plug that guards your API:

def call(conn, opts) do
  # ...
  if MyApiRateLimit.consume(conn.assigns.api_key) do
    conn
  else
    conn
    |> send_resp(429, "Rate limit exceeded")
    |> halt()
  end
end

Clustering

BucketHydra was specifically built to act as a rate limiter in a multi- node cluster. It syncronizes the usages across all instances of itself using Phoenix.PubSub broadcasts.

If you're not using phoenix, you can still use the phoenix_pubsub library and simply start a pubsub (a good starting point is Phoenix.PubSub.PG2) instance in your application supervisor.

Caveats

When a bucket only has one space left and 2 requests for the same bucket arrive at the same time, both might be allowed to happen.

In cases of a net split, the resulting clusters will work on their own and might allow more incoming requests in total. After the net split heals, all nodes are synchronized again.

Link to this section Summary

Types

:size a positive integer of how big the bucket is, default 120

The key for a bucket can be anything but we recommend using primitive IDs such as UUIDs, integers or atoms.

Functions

Generated record functions to interact with buckets returned by get_bucket/2.

Callbacks

Checks if the bucket for the given key still has enough space without consuming that space.

Checks if the bucket for the given key still has enough space, and then consumes that space.

Consumes space in the bucket without checking for capacity

Gets the bucket for the given key, time_scale and time (via bucket_opts).

Calculates the time bucket for the given scale that a request for the given time would fall into.

Link to this section Types

Link to this type

bucket()

bucket() :: {:bucket, key :: key_record(), consumed :: pos_integer()}
Link to this type

bucket_opt()

bucket_opt() ::
  {:size, non_neg_integer()}
  | {:time_scale, non_neg_integer()}
  | {:cost, non_neg_integer()}

:size a positive integer of how big the bucket is, default 120

:time_scale a positive integer in milliseconds how long a bucket lasts until it is emptied, default 60_000 ms

:cost if consuming from a bucket should cost more than 1 use, specify the increased cost here, default 1

:for_time default System.system_time(:millisecond) - a different time (in milliseconds) can be passed so a request might fall into an older or newer bucket.

Link to this type

bucket_opts()

bucket_opts() :: [bucket_opt()]
Link to this type

consumption()

consumption() :: {:consumption, key :: key(), cost :: pos_integer()}
Link to this type

key()

key() :: term()

The key for a bucket can be anything but we recommend using primitive IDs such as UUIDs, integers or atoms.

Link to this type

key_record()

key_record() ::
  {:key, key :: key(), time_scale :: pos_integer(),
   time_bucket :: pos_integer()}

Link to this section Functions

Link to this macro

bucket(args \\ [])

(macro)

Generated record functions to interact with buckets returned by get_bucket/2.

Link to this macro

bucket(record, args)

(macro)
Link to this macro

consumption(record, args)

(macro)
Link to this macro

key(record, args)

(macro)

Link to this section Callbacks

Link to this callback

can_consume?(key, bucket_opts)

can_consume?(key(), bucket_opts()) :: boolean()

Checks if the bucket for the given key still has enough space without consuming that space.

Link to this callback

consume(key, bucket_opts)

consume(key(), bucket_opts()) :: boolean()

Checks if the bucket for the given key still has enough space, and then consumes that space.

Returns true if there was space left and consumed.

Link to this callback

do_consume(key, bucket_opts)

do_consume(key(), bucket_opts()) :: :ok

Consumes space in the bucket without checking for capacity

Link to this callback

get_bucket(key, bucket_opts)

get_bucket(key(), bucket_opts()) :: {:ok, bucket()}

Gets the bucket for the given key, time_scale and time (via bucket_opts).

Link to this callback

time_bucket(time_scale, for_time)

time_bucket(time_scale :: pos_integer(), for_time :: pos_integer()) ::
  pos_integer()

Calculates the time bucket for the given scale that a request for the given time would fall into.