defmodule PlugLimit do
@moduledoc """
Rate limiting Plug module based on Redis Lua scripting.
## Architecture
`PlugLimit` is a `Plug` behaviour module implementation using
[Redis Lua scripting](https://redis.io/docs/manual/programmability/eval-intro/) to provide
rate limiting functionality.
Module plugs should implement two callback functions: `c:Plug.init/1` and `c:Plug.call/2`.
`PlugLimit` `init/1` function is responsible for building plug configuration, please refer to the
[Configuration](#module-configuration) section below for details.
`PlugLimit` `call/2` function has following responsibilities:
1. Establish if given request should be allowed or denied in accordance with selected rate
limiting strategy.
2. Determine values of the rate limiting http headers.
3. Set rate limiting http headers for the response.
4. Halt request processing pipeline if rate limit was exceeded and send response with
appropriate status code, usually `429 - Too Many Requests`.
Tasks 1 and 2 are performed by evaluation of the Redis Lua script implementing rate limiting
algorithm.
Tasks 3 and 4 are executed by built-in `put_response/4` function or user provided equivalent
callback function leveraging Redis Lua script evaluation results.
PlugLimit Redis Lua scripts are loaded to the Redis scripts cache on the first `c:Plug.call/2`
callback invocation by using Redis [`SCRIPT LOAD`](https://redis.io/commands/script-load/)
command.
Generated by `SCRIPT LOAD` SHA1 script hash is cached locally as a `:persistent_term` key.
Lua script SHA1 hash is later retrieved from `:persistent_term` local cache and used with
Redis [`EVALSHA`](https://redis.io/commands/evalsha/) command on subsequent rate-limiter
Lua script evaluations.
Implemented SHA1 Lua script caching mechanism is resilient to Redis script cache resets by Redis
instance reboots or Redis [`SCRIPT FLUSH`](https://redis.io/commands/script-flush/) command use.
Redis Lua script evaluation is an atomic operation resilient to the race conditions in distributed
environments.
In normal circumstances latency introduced by Redis `EVALSHA` command should be close to a single
Redis request/response round trip time, usually less than ~1 ms.
Redis Lua script execution blocks single-threaded Redis server, so it is advised to use a separate
standalone Redis instance for PlugLimit rate-limiters, especially when using custom untested
Lua script implementations.
Data stored by Redis rate-limiters in most cases can be considered as strongly interim so
using highly available Redis Sentinel instances for rate-limiters data might be unnecessary.
For high traffic volume cases, sharding can be easily achieved by distributing independent Phoenix
router pipelines or scopes rate-limiters between dedicated Redis instances.
## Usage
PlugLimit in most basic use case requires configuration of a function which will execute Redis
commands:
```elixir
# config/config.exs
config :plug_limit,
enabled?: true,
cmd: {MyApp.Redis, :command, []}
```
When working with Phoenix Framework `PlugLimit` plug call can be placed at the endpoint, router
or controller depending on requirements.
Example of minimal `PlugLimit` call used in `:high_cost_pipeline` router pipeline:
```elixir
#lib/my_app_web/router.ex
pipeline :high_cost_pipeline do
plug(PlugLimit, opts: [10, 60], key: {MyApp.RateLimiter, :user_id_key, [:high_cost_pipeline]})
# remaining pipeline plugs...
end
```
Example above will evaluate request rate-limiting parameters using built-in default Lua script
implementing fixed window algorithm with rate limiter algorithm options given in the `:opts` list.
First list element specifies request limit, set to 10 here, second item specifies limiting time
window length in seconds, set to 60.
Redis rate-limiter bucket name will be evaluated with function defined by the `:key` MFA tuple.
Example Redis key bucket name function result for `user_id=12345`:
`{:ok, ["high_cost_pipeline_limiter:12345"]}`.
Example `PlugLimit` configuration for built-in token bucket rate-limiter:
```elixir
#lib/my_app_web/router.ex
pipeline :high_cost_pipeline do
plug(PlugLimit,
limiter: :token_bucket,
opts: [20, 600, 5],
key: {MyApp.RateLimiter, :user_id_key, ["high_cost_pipeline"]}
)
# remaining pipeline plugs...
end
```
Configuration options details are described in the [Configuration](#module-configuration)
section below.
Instead of using generic `PlugLimit` module you can use provided convenience wrappers:
`PlugLimit.FixedWindow` or `PlugLimit.TokenBucket`.
Built-in rate-limiting algorithms are described in the "Redis Lua script rate limiters"
LIMITERS.md file.
Unit testing for user applications using PlugLimit library is described in "Unit testing"
TESTING.md file.
## Configuration
PlugLimit configuration is built from following sources:
* global `:plug_limit` configuration parameters from application configuration file, usually
`config/*.exs`,
* parameters overwriting global configuration passed as arguments to the PlugLimit plug
call in the application router or controller,
* hard-coded default values.
`PlugLimit` is using a concept of rate-limiters to organize individual limiters configurations.
Rate-limiters configurations are declared in application configuration file using `:limiters`
key. Each rate-limiter must be associated with a valid Lua script. Lua scripts are configured
with `:luascripts` key.
Full example configuration defining `:custom_limiter` limiter using `:custom_bucket` Lua script:
```elixir
# config/config.exs
config :plug_limit,
limiters: [
custom_limiter: %{
cmd: {MyApp.Redis, :command, ["redis://10.10.10.2:6379/"]},
key: {MyApp.RateLimiter, :user_id_key, []},
log_level: :error,
luascript: :custom_bucket,
response: {MyApp.RateLimiter, :respond, []}
}
],
luascripts: [
custom_bucket: %{
script: {File, :read, ["./lua/custom_bucket.lua"]},
headers: [
"x-ratelimit-limit",
"x-ratelimit-reset",
"x-ratelimit-remaining",
"x-acme-custom-header"
]
}
]
```
Above defined `:custom_limiter` should be referred as follows:
```elixir
plug(PlugLimit, limiter: :custom_limiter, opts: [20, 600, 5])
```
### Application configuration options
Primary part of `PlugLimit` configuration is located in the application configuration file,
usually `config/config.exs`, under `:plug_limit` key. Some of the options defined in application
configuration can be overwritten with `plug(PlugLimit, [options...])` call.
Available configuration options:
* `:enabled?` - when set to Boolean `false` or string `"false"` `PlugLimit` is disabled and
`plug(PlugLimit, opts: [...])` call immediately returns unmodified `conn` struct.
To enable `PlugLimit`, `:enabled?` key must be set to Boolean `true` or string `"true"`.
Default: `false`.
* `:cmd` - MFA tuple pointing at the user defined two arity function executing Redis commands.
As a first argument function will receive a Redis command as a list, for example:
`["SET", "mykey", "foo"]`.
As a second parameter, static argument defined in the MFA tuple will be passed.
Redis command function should return `{:ok, redis_response}` on success and `{:error, reason}`
on error.
When using [Redix](https://hex.pm/packages/redix) library as a client, `:cmd` command should be
a `Redix.command/3` wrapper.
When using [eredis](https://hex.pm/packages/eredis) library, wrapper for the `:eredis.q/2,3`
should be implemented.
Redis command function defined here will be used as a default function for limiters that
do not have their own `:cmd` specified.
Optional if each limiter has its own `:cmd` defined, required otherwise.
* `:log_level` - specifies log level for rate-limiters that do not set their own `:log_level`.
Only library errors are logged - with `put_response/4` function.
Boolean value `false` disables logging. Please refer to the `Logger` documentation
for valid log levels. Default: `:error`.
* `:response` - MFA tuple pointing at the user defined 4 arity function providing request
response depending on rate-limiter Lua script evaluation results.
Please refer to the built-in response function `put_response/4` description for details.
Can be overwritten for individual limiters.
Default: `put_response/4`.
* `:limiters` - keyword list with user provided rate-limiters. See below for details. Optional.
* `:luascripts` - keyword list defining Lua scripts for rate-limiters defined with `:limiters`.
See below for details. Optional.
PlugLimit `:enabled?` option is the only option evaluated at run-time with `c:Plug.call/2`,
so function like `System.get_env/2` can be used here.
All other configuration options are initialized with `c:Plug.init/1`, which usually takes place at
compile time for production or release environments and run-time for testing and development.
In a production environment, `PlugLimit` `:enabled` might be controlled using environmental
variable, for example:
```elixir
# config/config.exs
config :plug_limit, cmd: {MyApp.Redis, :command, []}
# config/releases.exs
config :plug_limit, enabled?: System.get_env("PLUG_LIMIT_ENABLED", "false")
# config/dev.exs
config :plug_limit, enabled?: true
# config/test.exs
config :plug_limit, enabled?: true
```
Custom user rate-limiters are configured as a `:limiters` keyword list.
Rate-limiters are declared as maps with following keys:
* `:cmd` - overwrites `:cmd` global key for a given rate-limiter. Optional.
* `:key` - MFA tuple pointing at the user defined two arity function providing Redis keys names
that will be passed later to the Redis Lua script.
Function receives request `Plug.Conn.t()` struct as a first argument and static argument from
the MFA tuple as a second argument.
Function should return `{:ok, [key :: String.t()]}` when successful and `{:error, reason}`
on error. Function should return especially name of the key that Redis Lua script will use to
create a unique bucket for a given rate-limiter and requests group.
Please refer to "Redis Lua script rate limiters" LIMITERS.md file for further discussion.
Example `:key` function implementation:
```elixir
def user_key(%Plug.Conn{assigns: %{user_id: user_id}}, prefix),
do: {:ok, [to_string(prefix) <> ":" <> to_string(user_id)]}
def user_key(_conn, _prefix), do: {:error, "Missing user_id"}
```
Redis keys names should follow
[Redis keys naming conventions](https://redis.io/docs/manual/data-types/data-types-tutorial/#keys).
`:key` value can be overwritten in a plug call configuration.
Optional if `:key` is specified for each `PlugLimit` plug call, required otherwise.
* `:log_level` - overwrites global `:log_level` for a given rate-limiter. Optional.
* `:luascript` - atom defining Lua script for a given rate-limiter. Lua scripts are defined with
`:luascripts` keyword list, see below for details. Required.
* `:response` - overwrites global `:response` for a given rate-limiter. Optional.
PlugLimit provides two built-in rate-limiters: `:fixed_window` and `:token_bucket`, please refer
to "Redis Lua script rate limiters" LIMITERS.md file for details.
Each rate-limiter is associated with Redis Lua script checking if given request should
be allowed or denied and evaluating rate limiting http headers. Redis Lua scripts are configured
as `:luascripts` keyword list. Each script is declared as a map with following keys:
* `:script` - MFA tuple pointing at one arity function returning
`{:ok, limiter_script :: String.t()}` on success and `{:error, reason}` on error.
Function receives as an argument static argument from the MFA tuple.
Example implementations:
```elixir
def get_lua_script_by_path(path), do: File.read(path)
def my_lua_script(_arg), do: {:ok, "-- Lua script body"}
```
Required.
* `:headers` - list of rate limiting headers keys to be used with headers values returned by
given Redis Lua script to build request response http headers.
Example headers list:
```elixir
headers: [
"x-ratelimit-limit",
"x-ratelimit-reset",
"x-ratelimit-remaining"
]
```
Please refer to "Redis Lua script rate limiters" LIMITERS.md file for more detailed discussion
on rate limiting headers. Required.
### Plug call configuration options
* `:key` - MFA tuple, overwrites value given in limiter's application configuration.
Required if not provided in the limiter configuration.
* `:limiter` - atom selecting rate limiter. List of built-in limiters is provided in LIMITERS.md
file. Default: `:fixed_window`.
* `:opts` - list with rate limiting options like requests limit, time window length or burst rate.
List is passed as an argument to the Redis Lua script, see LIMITERS.md for built-in limiters
options. Required.
"""
@behaviour Plug
import Plug.Conn
require Logger
@default_limiter_id :fixed_window
@default_limiters [
fixed_window: %{
luascript: :fixed_window
},
token_bucket: %{
luascript: :token_bucket
}
]
@default_luascripts [
fixed_window: %{
script: {__MODULE__, :get_script, [:fixed_window]},
headers: [
"x-ratelimit-limit",
"x-ratelimit-reset",
"x-ratelimit-remaining"
]
},
token_bucket: %{
script: {__MODULE__, :get_script, [:token_bucket]},
headers: [
"x-ratelimit-limit",
"x-ratelimit-reset",
"x-ratelimit-remaining",
"retry-after"
]
}
]
@default_log_level :error
@default_response {__MODULE__, :put_response, []}
@default_script_fixed_window File.read("./lua/fixed_window.lua")
@default_script_token_bucket File.read("./lua/token_bucket.lua")
@limit_status 429
@limit_body "Too Many Requests"
@no_script_msg ["NOSCRIPT No matching script. Please use EVAL."]
defstruct [
:cmd,
:headers,
:key,
:log_level,
:opts,
:response,
:script,
:script_id
]
@type t() :: %__MODULE__{
cmd: mfa(),
headers: [String.t()],
key: mfa(),
log_level: log_level(),
opts: list(),
response: mfa(),
script: mfa(),
script_id: atom()
}
@type limiters :: [
{limiter_id :: atom, limiter :: limiter()}
]
@type limiter :: %{
cmd: mfa(),
key: mfa(),
log_level: log_level(),
luascript: atom(),
response: mfa()
}
@type luascripts :: [
{luascript_id :: atom(), luascript :: luascript()}
]
@type luascript :: %{
headers: [String.t()],
opts: list(),
script: mfa()
}
@type log_level :: false | Logger.level()
@type eval_result :: {:ok, list()} | {:error, any()} | any()
@impl true
@doc false
@spec init(opts :: Plug.opts()) :: PlugLimit.t()
def init(opts) do
limiters = Application.get_env(:plug_limit, :limiters, @default_limiters)
luascripts = Application.get_env(:plug_limit, :luascripts, @default_luascripts)
limiter_id = Keyword.get(opts, :limiter, @default_limiter_id)
limiter = Keyword.get(limiters, limiter_id) || Keyword.fetch!(@default_limiters, limiter_id)
script_id = Map.fetch!(limiter, :luascript)
key = Keyword.get(opts, :key) || Map.fetch!(limiter, :key)
luascript =
Keyword.get(luascripts, script_id) || Keyword.fetch!(@default_luascripts, script_id)
script = Map.fetch!(luascript, :script)
headers = Map.fetch!(luascript, :headers)
l_opts = Keyword.fetch!(opts, :opts)
cmd = Map.get(limiter, :cmd) || Application.fetch_env!(:plug_limit, :cmd)
log_level =
Map.get(limiter, :log_level) ||
Application.get_env(:plug_limit, :log_level, @default_log_level)
response =
Map.get(limiter, :response) ||
Application.get_env(:plug_limit, :response, @default_response)
opts = %{
cmd: cmd,
headers: headers,
key: key,
log_level: log_level,
opts: l_opts,
response: response,
script: script,
script_id: script_id
}
struct!(__MODULE__, opts)
end
@impl true
@doc false
@spec call(conn :: Plug.Conn.t(), conf :: Plug.opts()) :: Plug.Conn.t()
def call(conn, conf) do
case Application.get_env(:plug_limit, :enabled?, false) do
res when res in ["true", true] ->
eval_result = eval_limit(conn, conf)
apply_response(conn, conf, eval_result)
res when res in ["false", false] ->
conn
end
end
@doc """
Puts new rate limiting http response headers in the connection and halts the Plug pipeline if
rate limit was exceeded.
`put_response/4` is a default `PlugLimit` function preparing an http response accordingly
with Redis Lua script evaluation results.
Custom response function can be selected by setting `:response` global or given limiter
configuration keys.
Function accepts following arguments:
1. `Plug.Conn.t()` connection.
2. Rate-limiter configuration as a `PlugLimit.t()` struct.
3. Redis Lua script evaluation result as a `PlugLimit.eval_result()` type.
4. Static argument given in the `:response` MFA tuple.
Function returns `Plug.Conn.t()` struct with rate limiting headers.
If rate limit is exceeded function halts Plug pipeline and sends response with `429` status code
and plain-text body `"Too Many Requests"`.
If Redis Lua script evaluation or any other rate-limiting processing function fails,
`put_response/4` function will log resulting error with Logger level set by `:log_level`
configuration setting and return unmodified connection struct.
Custom response functions and custom Redis Lua scripts are described in more details in
"Redis Lua script rate limiters" LIMITERS.md file.
"""
@spec put_response(
conn :: Plug.Conn.t(),
conf :: PlugLimit.t(),
eval_result :: eval_result(),
args :: any()
) :: Plug.Conn.t()
def put_response(conn, %__MODULE__{} = conf, eval_result, _args) do
case eval_result do
{:ok, [action, headers | _other]} ->
conn = put_headers(conn, headers, conf.headers)
if action == "allow" do
conn
else
conn
|> put_resp_content_type("text/plain")
|> send_resp(@limit_status, @limit_body)
|> halt()
end
err ->
log_level = Map.fetch!(conf, :log_level)
if log_level, do: Logger.log(log_level, "[#{__MODULE__}] #{inspect(err)}")
conn
end
end
@doc false
def get_script(:fixed_window), do: @default_script_fixed_window
def get_script(:token_bucket), do: @default_script_token_bucket
@spec eval_limit(Plug.Conn.t(), PlugLimit.t()) :: eval_result()
defp eval_limit(conn, %__MODULE__{opts: lua_opts} = conf) do
with {:ok, sha} <- get_sha(conf),
{:ok, key} <- get_key(conn, conf),
command <- ["EVALSHA", sha, length(key)] ++ key ++ lua_opts,
{:ok, result} <- apply_command(conf, [command]) do
{:ok, result}
else
{:error, msg} ->
with true <- noscript_error?(msg),
{:ok, sha} <- load_sha(conf),
{:ok, key} <- get_key(conn, conf) do
command = ["EVALSHA", sha, length(key)] ++ key ++ lua_opts
apply_command(conf, [command])
else
false -> {:error, msg}
err -> err
end
err ->
err
end
end
defp put_headers(conn, [[header_k, header_v] | headers_t], [_d_h_k | d_h_t]) do
conn
|> put_resp_header(header_k, header_v)
|> put_headers(headers_t, d_h_t)
end
defp put_headers(conn, [header_v | headers_t], [d_h_k | d_h_t]) do
conn
|> put_resp_header(d_h_k, header_v)
|> put_headers(headers_t, d_h_t)
end
defp put_headers(conn, [[header_k, header_v] | headers_t], []) do
conn
|> put_resp_header(header_k, header_v)
|> put_headers(headers_t, [])
end
defp put_headers(conn, [], _default_headers), do: conn
defp get_sha(%__MODULE__{script_id: script_id} = conf) do
case :persistent_term.get({__MODULE__, :sha, script_id}, nil) do
nil -> load_sha(conf)
sha -> {:ok, sha}
end
end
defp load_sha(%__MODULE__{script: {m, f, a}, script_id: id} = conf) do
with {:ok, script} <- apply(m, f, a),
command <- ["SCRIPT", "LOAD", script],
{:ok, sha} <- apply_command(conf, [command]),
:ok <- :persistent_term.put({__MODULE__, :sha, id}, sha) do
{:ok, sha}
else
err ->
:persistent_term.erase(id)
err
end
end
defp get_key(conn, %__MODULE__{key: {m, f, a}}), do: apply(m, f, [conn, a])
defp apply_command(%__MODULE__{cmd: {m, f, a}}, command), do: apply(m, f, command ++ a)
defp apply_response(conn, %__MODULE__{response: {m, f, a}} = conf, eval_result),
do: apply(m, f, [conn, conf, eval_result, a])
defp noscript_error?(msg) when is_map(msg) or is_struct(msg),
do: msg |> Map.get(:message, "") |> noscript_error?()
defp noscript_error?(msg), do: msg in @no_script_msg
end