Skip to content

Repository files navigation

Hammer.Redis

Build Status Hex.pm Documentation Total Download License

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".

Requirements

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.

Installation

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"}
  ]
end

Usage

Define the rate limiter:

defmodule MyApp.RateLimit do
  use Hammer, backend: Hammer.Redis
end

And 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.

Checking several limits at once

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_ms is 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.

Configuring SSL

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)
       ]
     ]
   ]},
  # ...
]

Run tests locally

You need a running Redis instance. One can be started locally using docker compose up -d redis. See the compose.yml for more details.

Getting Help

If you're having trouble, open an issue on this repo.

Releases

Used by

Contributors

Languages