View Source PlugLimit (PlugLimit v0.1.0)

Rate limiting Plug module based on Redis Lua scripting.

architecture

Architecture

PlugLimit is a Plug behaviour module implementation using Redis Lua scripting to provide rate limiting functionality. Module plugs should implement two callback functions: Plug.init/1 and Plug.call/2. PlugLimit init/1 function is responsible for building plug configuration, please refer to the 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 Plug.call/2 callback invocation by using Redis 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 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 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

Usage

PlugLimit in most basic use case requires configuration of a function which will execute Redis commands:

# 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:

#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:

#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 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

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:

# 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:

plug(PlugLimit, limiter: :custom_limiter, opts: [20, 600, 5])

application-configuration-options

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 library as a client, :cmd command should be a Redix.command/3 wrapper. When using 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 Plug.call/2, so function like System.get_env/2 can be used here. All other configuration options are initialized with 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:

# 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:
      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. :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:
      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:
      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

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.

Link to this section Summary

Functions

Puts new rate limiting http response headers in the connection and halts the Plug pipeline if rate limit was exceeded.

Link to this section Types

@type eval_result() :: {:ok, list()} | {:error, any()} | any()
@type limiter() :: %{
  cmd: mfa(),
  key: mfa(),
  log_level: log_level(),
  luascript: atom(),
  response: mfa()
}
@type limiters() :: [{limiter_id :: atom(), limiter :: limiter()}]
@type log_level() :: false | Logger.level()
@type luascript() :: %{headers: [String.t()], opts: list(), script: mfa()}
@type luascripts() :: [{luascript_id :: atom(), luascript :: luascript()}]
@type t() :: %PlugLimit{
  cmd: mfa(),
  headers: [String.t()],
  key: mfa(),
  log_level: log_level(),
  opts: list(),
  response: mfa(),
  script: mfa(),
  script_id: atom()
}

Link to this section Functions

Link to this function

put_response(conn, conf, eval_result, args)

View Source
@spec put_response(
  conn :: Plug.Conn.t(),
  conf :: t(),
  eval_result :: eval_result(),
  args :: any()
) :: Plug.Conn.t()

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.