Tutorial
Installation
Add Hammer as a dependency in mix.exs
:
def deps do
[{:hammer, "~> 0.1.0"}]
end
Core Concepts
When we want to rate-limit some action, we want to ensure that the number of actions permitted is limited within a specified time-period. For example, a maximum of five times within on minute. Usually the limit is enforced per-user, per-client, or per some other unique-ish value, such as IP address. It’s much rarer, but not unheard-of, to limit the action globally without taking the identity of the user or client into account.
In the Hammer API, the maximum number of actions is the limit
, and the
timespan (in milliseconds) is the scale_ms
. The combination of the name of the
action with some unique identifier is the id
.
Hammer uses a Token Bucket algorithm to count the number of actions occurring in a “bucket”. If the count within the bucket is lower than the limit, then the action is allowed, otherwise it is denied.
Usage
To use Hammer, you need to do two things:
- Start a backend process
use
theHammer
module
In this example, we will use the ETS
backend, which stores data in an
in-memory ETS table.
Starting a Backend Process
Hammer backends are typically implemented as OTP GenServer modules. You just need to start the process as part of your application’s OTP supervision tree.
By convention, the Backend start_link
functions accept a Keyword list of
configuration options, at the minimum expiry_ms
and cleanup_interval_ms
.
Each backends may require additional, more specific configuration, such as
details of how to connect to a database.
Because the number of buckets stored will continue to grow while your
application is running it is essental to clean up old buckets regularly. The
expiry_ms
option determines how long an individual “bucket” should be kept in
storage before being cleaned up (deleted), while cleanup_interval_ms
determines the time between cleanup runs.
Starting the Hammer.Backend.ETS
process as a worker might look like this:
worker(Hammer.Backend.ETS, [[expiry_ms: 1000 * 60 * 60,
cleanup_rate_ms: 1000 * 60 * 10]]),
use
-ing The Hammer Module
To bring the functions of the Hammer
module into scope, use the use
macro (ahem),
and specify the :backend
module which should be used.
use Hammer, backend: Hammer.Backend.ETS
This will create four functions, all configured to use the specified backend:
check_rate(id::string, scale_ms::integer, limit::integer)
inspect_bucket(id::string, scale_ms::integer, limit::integer)
delete_buckets(id::string)
make_rate_checker(id_prefix, scale_ms, limit)
The most interesting is check_rate
, which checks if the rate-limit for the given id
has been exceeded in the specified time-scale
.
Ideally, the id
should be a combination of some action-specific, descriptive prefix
with some data which uniquely identifies the user or client performing the action.
Example:
# limit file uploads to 10 per minute per user
user_id = get_user_id_somehow()
case check_rate("upload_file:#{user_id}", 60_000, 10) do
{:allow, _count} ->
# upload the file, somehow
{:deny, _limit} ->
# deny the request
end
A Realistic Example
The example below shows a MyApp.RateLimiter
module, which acts as both a Supervisor to the
Hammer.Backend.ETS
process, and contains the rate-limiting API via use Hammer...
defmodule MyApp.RateLimiter do
use Supervisor
use Hammer, backend: Hammer.Backend.ETS
def start_link() do
Supervisor.start_link(__MODULE__, :ok)
end
def init(:ok) do
children = [
worker(Hammer.Backend.ETS, [[expiry_ms: 1000 * 60 * 60
cleanup_interval_ms: 1000 * 60 * 10]]),
]
supervise(children, strategy: :one_for_one, name: MyApp.RateLimiter)
end
end
Of course, the MyApp.RateLimiter
supervisor should be added to the application’s
supervision tree like so:
# probably somewhere in application.ex
children = [
...
supervisor(HammerTestbed.RateLimiter, [])
...
]
The rate-limiter is then used in the app by calling the check_rate
function:
defmodule MyApp.VideoUpload do
alias MyApp.RateLimiter
def upload(video_data, user_id) do
case RateLimiter.check_rate("upload_video:#{user_id}", 60_000, 5) do
{:allow, _count} ->
# upload the video, somehow
{:deny, _limit} ->
# deny the request
end
end
end
Switching to Redis
There may come a time when ETS just doesn’t cut it, for example if we end up load-balancing across many nodes and want to keep our rate-limiter state in one central store. Redis is ideal for this use-case, and fortunately Hammer supports a Redis backend.
To change our application to use the Redis backend, we need to do the following:
- Install and set up Redis (excercise for the reader)
- Add the
hammer_backend_redis
dependency - Start the
Hammer.Backend.Redis
process - Change the backend it the
use Hammer
macro
Here we go…
Add hammer_backend_redis
to your mix dependencies:
defp deps do
[
...
{:hammer, "~> 1.0.0"},
{:hammer_backend_redis, "~> 1.0.0"},
...
]
end
Change the MyApp.RateLimiter
module to use the Hammer.Backend.Redis
instead of Hammer.Backend.ETS
:
defmodule MyApp.RateLimiter do
use Supervisor
use Hammer, backend: Hammer.Backend.Redis
def start_link() do
Supervisor.start_link(__MODULE__, :ok)
end
def init(:ok) do
children = [
worker(Hammer.Backend.Redis, [[expiry_ms: 1000 * 60 * 60
redix_config: [host: "localhost"]]]),
]
supervise(children, strategy: :one_for_one, name: MyApp.RateLimiter)
end
end
Further Reading
See the docs for the Hammer module for full documentation on all the
functions created by use Hammer
.
See the Creating Backends for information on creating new backends for Hammer.