A Redis backend for the Hammer rate-limiter.
This backend is a thin Redix wrapper. A single connection is used per rate-limiter. It should be enough for most use-cases since packets for rate limiting requests are short (i.e. no head of line blocking) and Redis is OK with pipelining (i.e. we don't block awaiting replies). Consider benchmarking before introducing more connections since TCP performance might be unintuitive. For possible pooling approaches, see Redix docs on pooling and also PartitionSupervisor. Do not use poolboy or db_connection-like pools since they practically disable pipelining which leads to worse connection utilisation and worse performance.
The algorithm we are using is the first method described (called "bucketing") in Rate Limiting with Redis. In other sources it's sometimes called a "fixed window counter".
Redis 7.0 or later is required. The fixed window algorithm relies on the NX option of
PEXPIREAT, which was introduced in Redis 7.0.
On older Redis versions (which are end-of-life), this command fails, so counter keys never receive a TTL and are never cleaned up. Rate limiting appears to work correctly, but the keyspace grows unbounded until Redis runs out of memory.
Hammer-backend-redis
is available in Hex, the package
can be installed by adding hammer_backend_redis to your list of dependencies in mix.exs:
def deps do
[
{:hammer_backend_redis, "~> 7.0"}
]
endDefine the rate limiter:
defmodule MyApp.RateLimit do
use Hammer, backend: Hammer.Redis
endAnd add it to your app's supervision tree:
children = [
{MyApp.RateLimit, url: "redis://localhost:6379"}
]And that's it, calls to MyApp.RateLimit.hit/3 and so on will use Redis to store
the rate-limit counters. See the documentation for more details.
When one action is subject to more than one limit, such as 1 SMS per minute and
6 SMS per hour, use hit_many/1 instead of calling hit for each limit. It
checks every limit in a single atomic Redis round trip, and counts the hit
against all of them only if every one allows it:
defmodule MyApp.SmsLimit do
use Hammer, backend: Hammer.Redis, algorithm: :fix_window
end
case MyApp.SmsLimit.hit_many([
{"{user_123}:sms:minute", :timer.minutes(1), 1},
{"{user_123}:sms:hour", :timer.hours(1), 6}
]) do
{:allow, [_minute_count, _hour_count]} -> send_sms()
{:deny, retry_after_ms} -> {:error, :rate_limited, retry_after_ms}
end- On allow, you get one count (or bucket level) per limit, in the order given.
- On deny, nothing is counted, and
retry_after_msis the longest wait among the limits that denied, so retrying after it passes all of them. - A key may appear only once per call.
hit_many/1 is available for the :fix_window, :token_bucket and
:leaky_bucket algorithms. Each limit is a tuple with the same arguments as that
algorithm's hit:
| Algorithm | Tuple |
|---|---|
:fix_window |
{key, scale_ms, limit} or {key, scale_ms, limit, increment} |
:token_bucket |
{key, refill_rate, capacity} or {key, refill_rate, capacity, cost} |
:leaky_bucket |
{key, leak_rate, capacity} or {key, leak_rate, capacity, cost} |
With :fix_window, hit/3 counts a request even when it is denied, but a denied
hit_many/1 counts nothing. Otherwise a request rejected by the minute limit
would still use up the hourly one.
Redis Cluster: all keys in one hit_many/1 call must hash to the same slot.
Put the shared part of the key in a
hash tag,
like {user_123} above, or the call fails with a CROSSSLOT error.
Under the hood, Hammer.Redis uses Redix, which supports SSL connections. To configure SSL, pass the SSL options directly when starting the rate limiter. For example:
children = [
{MyApp.RateLimit,
[
host: "...",
port: 6379,
timeout: 5000,
ssl: true,
socket_opts: [
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
]},
# ...
]You need a running Redis instance. One can be started locally using docker compose up -d redis.
See the compose.yml for more details.
If you're having trouble, open an issue on this repo.