Hammer Rate-Limiting Library for Elixir

repository·master·Indexed 21 days ago

https://github.com/exhammer/hammer

A rate-limiting library for Elixir providing pluggable storage backends including ETS, Redis, Mnesia, and Atomic. It supports multiple algorithms such as Fixed Window, Leaky Bucket, Token Bucket, and Sliding Window to control action frequency. Hammer allows for both single-node high-performance limiting and distributed setups, including an eventually consistent implementation using ETS and Phoenix.PubSub.

Tokens
4.4K
Snippets
13
Records
21
Agent score
75%

What's inside Hammer

  1. Compare Hammer rate limiting algorithms

    master

    Choose an algorithm based on your requirements for precision, burst tolerance, and overhead:

    • Fixed Window: Simplest, lowest overhead. Good for basic limits where occasional bursts at window boundaries are acceptable.
    • Fixed Window Per Key: Low overhead. Each key's window is anchored to its first hit rather than a global clock, preventing deterministic exploitation of boundaries.
    • Leaky Bucket: Provides a smooth, consistent request rate. Best for traffic shaping and steady throughput.
    • Token Bucket: Allows controlled bursts while maintaining an average rate. Best for APIs needing burst tolerance.
    • Sliding Window: Most precise rate limiting with no boundary issues, but has higher overhead. Best for strict enforcement in critical systems.
  2. How Hammer rate limiting works

    master

    Hammer uses a fixed window counter approach. It divides time into fixed-size windows based on a scale (the time period). It counts the number of requests within each window and blocks any requests that exceed a specified limit.

    Key concepts:

    • limit: The maximum number of allowed occurrences.
    • scale: The time period (in milliseconds) for that limit.
    • key: A unique identifier for the rate limit (e.g., "login_attempt:#{user_id}").
  3. Configure and Start a Rate Limiter in your Application Supervisor

    master

    Hammer v7 moves configuration from global config files to the rate limiter module itself, which should be started as a child of your application's supervisor.

    1. Remove global config: Delete config lines for Hammer from your config/*.exs files.
    2. Set cleanup interval: Identify your desired cleanup_interval_ms.
    3. Add to Supervisor: In your application.ex, add your rate limiter module to the children list, passing the clean_period (in milliseconds) in the arguments.

    Example:

    def start(_type, _args) do
      children = [
        # ... other children
        {MyApp.RateLimit, [clean_period: 60_000]}
      ]
    
      Supervisor.start_link(children, strategy: :one_for_one)
    end
    def start(_type, _args) do
    
      children = [
        ...
        {MyApp.RateLimit, [clean_period: 60_000]}
        ...
      ]
    
      Supervisor.start_link(children, strategy: :one_for_one)
    end
  4. Use Hammer as a Phoenix Plug

    master

    You can implement rate limiting in Phoenix controllers or endpoints by calling your rate limiter module within a plug function. When a limit is exceeded ({:deny, retry_after}), it is common practice to return a 429 Too Many Requests status and a retry-after header. Note that retry_after is returned in milliseconds, so you should convert it to seconds for the header.

    defp rate_limit(conn, _opts) do
      key = "web_requests:#{:inet.ntoa(conn.remote_ip)}"
      scale = :timer.minutes(1)
      limit = 1000
    
      case MyApp.RateLimit.hit(key, scale, limit) do
        {:allow, _count} ->
          conn
    
        {:deny, retry_after} ->
          retry_after_seconds = div(retry_after, 1000)
    
          conn
          |> put_resp_header("retry-after", Integer.to_string(retry_after_seconds))
          |> send_resp(429, [])
          |> halt()
      end
    end
    defp rate_limit(conn, _opts) do
      key = "web_requests:#{conn.remote_ip}"
      scale = :timer.minutes(1)
      limit = 1000
    
      case MyApp.RateLimit.hit(key, scale, limit) do
        {:allow, _count} ->
          conn
    
        {:deny, retry_after} ->
          retry_after_seconds = div(retry_after, 1000)
    
          conn
          |> put_resp_header("retry-after", Integer.to_string(retry_after_seconds))
          |> send_resp(429, [])
          |> halt()
      end
    end
  5. Run Hammer benchmarks

    master

    To run the performance benchmarks for Hammer's various algorithms and compare them against other libraries, follow these steps:

    1. Install the necessary dependencies:

      mix deps.get
    2. Execute the benchmark suite using mix run with the bench/base.exs file. You can tune the performance using environment variables such as LIMIT, SCALE, RANGE, and PARALLEL.

    Example command:

    MIX_ENV=bench LIMIT=1 SCALE=5000 RANGE=200000 PARALLEL=600 mix run bench/base.exs
  6. Implement a distributed rate limiter with ETS and Phoenix.PubSub

    master

    To implement a distributed, eventually consistent rate limiter across a cluster, you can combine Hammer with an ETS backend and use Phoenix.PubSub to broadcast hits.

    Architecture

    1. Local Counting: Use a module that calls use Hammer, backend: :ets to manage counters in local memory via ETS.
    2. Broadcasting: When a hit occurs, broadcast an :inc message containing the key, scale, and increment value to a shared PubSub topic.
    3. Synchronization: Run a GenServer (Listener) on every node that subscribes to the PubSub topic. When it receives an :inc message, it calls the local Hammer module to increment the counter, ensuring all nodes eventually reflect the same hit count.
    4. Supervision: Wrap both the local Hammer module and the Listener process in a supervisor to manage their lifecycles.
    defmodule MyApp.RateLimit do
      # 1. The local Hammer module using ETS
      defmodule Local do
        use Hammer, backend: :ets
      end
    
      # 2. The Listener that synchronizes remote hits
      defmodule Listener do
        use GenServer
    
        def start_link(opts) do
          pubsub = Keyword.fetch!(opts, :pubsub)
          topic = Keyword.fetch!(opts, :topic)
          GenServer.start_link(__MODULE__, {pubsub, topic})
        end
    
        def init({pubsub, topic}) do
          :ok = Phoenix.PubSub.subscribe(pubsub, topic)
          {:ok, []}
        end
    
        def handle_info({:inc, key, scale, increment}, state) do      
          _count = Local.inc(key, scale, increment)
          {:noreply, state}
        end
      end
    
      # 3. The main interface
      def hit(key, scale, limit, increment \ 1) do
        :ok = broadcast({:inc, key, scale, increment})
        Local.hit(key, scale, limit, increment)
      end
    
      # 4. Supervision setup
      def start_link(opts) do
        children = [{Local, opts}, {Listener, pubsub: @pubsub, topic: @topic}]
        Supervisor.start_link(children, strategy: :one_for_one)
      end
    end
  7. Use Hammer for rate limiting

    master

    To implement rate limiting, define a module that uses Hammer with a specific backend, start the process, and then call hit/3 to check if an action is allowed.

    Core Concepts:

    • Limit: Maximum number of actions allowed in a window.
    • Scale: Duration of the time window (e.g., using :timer.minutes(1)).
    • Key: A unique identifier (like a user ID) to scope the limit.

    Return Values for hit/3:

    • {:allow, count}: The action is permitted. count is the current number of hits in the window.
    • {:deny, retry_after}: The action is denied. retry_after is the time in milliseconds to wait before retrying.
    defmodule MyApp.RateLimit do
      use Hammer, backend: :ets
    end
    
    # Start the rate limiter process
    MyApp.RateLimit.start_link()
    
    user_id = 42
    key = "upload_video:#{user_id}"
    scale = :timer.minutes(1)
    limit = 3
    
    case MyApp.RateLimit.hit(key, scale, limit) do
      {:allow, _count} ->
        # upload the video
        :ok
    
      {:deny, retry_after} ->
        # deny the request
        {:error, :rate_limit, "try again in #{retry_after}ms"}
    end
  8. Define and start a Rate Limiter module

    master

    To use Hammer, you must define a module that uses the Hammer macro and specifies a backend. You then add this module to your application's supervision tree using start_link/1.

    Using the ETS backend (In-memory)

    For in-memory storage, use the :ets backend. You can provide a :clean_period to specify how often expired buckets are cleaned from the ETS table.

    # Define
    defmodule MyApp.RateLimit do
      use Hammer, backend: :ets
    end
    
    # Start in supervision tree
    MyApp.RateLimit.start_link(clean_period: :timer.minutes(1))

    Using the Redis backend (Distributed)

    To persist data across multiple nodes, use the Hammer.Redis backend. Configuration options are the same as Redix, excluding :name which is derived from your module name.

    # Define
    defmodule MyApp.RateLimit do
      use Hammer, backend: Hammer.Redis
    end
    
    # Start with Redis host
    MyApp.RateLimit.start_link(host: "redix.myapp.com")
    defmodule MyApp.RateLimit do
      use Hammer, backend: :ets
    end
    
    MyApp.RateLimit.start_link(clean_period: :timer.minutes(1))