Rate limiter for Phoenix app

I wanted to throttle incoming requests to my Phoenix application. This is my note about how to set up a rate limiter in a Phoenix app.

As I google, there is a nice library ExRated for setting up the rake limiter. The library does all the heavy lifting and abstract them away. All I need was to implement a plug.

日本語版

Get started

 defmodule Mnishiguchi.MixProject do
   use Mix.Project

   ...
   def application do
     [
       mod: {Mnishiguchi.Application, []},
-      extra_applications: [:logger, :runtime_tools]
+      extra_applications: [:logger, :runtime_tools, :ex_rated]
     ]
   end

   ...

   defp deps do
     [
       ...
+      {:ex_rated, "~> 2.0"}
     ]
   end

Implement a plug

ExRated recommends reading this blog post Rate Limiting a Phoenix API by danielberkompas.

The article is a bit old but I was able to get the sense of how it works and what I should do. Here is what my RateLimitPlug ended up with.

For those unfamiliar with Plug, Phoenix has a nice documentation about it.

defmodule MnishiguchiWeb.API.RateLimitPlug do
  @moduledoc false

  import Plug.Conn, only: [put_status: 2, halt: 1]
  import Phoenix.Controller, only: [render: 2, put_view: 2]
  require Logger

  @doc """
  A function plug that does the rate limiting.

  ## Examples

      # In a controller
      import MnishiguchiWeb.API.RateLimitPlug, only: [rate_limit: 2]
      plug :rate_limit, max_requests: 5, interval_seconds: 10

  """
  def rate_limit(conn, opts \\ []) do
    case check_rate(conn, opts) do
      {:ok, _count} ->
        conn

      error ->
        Logger.info(rate_limit: error)
        render_error(conn)
    end
  end

  defp check_rate(conn, opts) do
    interval_ms = Keyword.fetch!(opts, :interval_seconds) * 1000
    max_requests = Keyword.fetch!(opts, :max_requests)
    ExRated.check_rate(bucket_name(conn), interval_ms, max_requests)
  end

  # Bucket name should be a combination of IP address and request path.
  defp bucket_name(conn) do
    path = Enum.join(conn.path_info, "/")
    ip = conn.remote_ip |> Tuple.to_list() |> Enum.join(".")

    # E.g., "127.0.0.1:/api/v1/example"
    "#{ip}:#{path}"
  end

  defp render_error(conn) do
    # Using 503 because it may make attacker think that they have successfully DOSed the site.
    conn
    |> put_status(:service_unavailable)
    |> put_view(MnishiguchiWeb.ErrorView)
    |> render(:"503")
    # Stop any downstream transformations.
    |> halt()
  end
end

I decided to respond with 503 service unavailable when I read this in a Ruby library Rack Attack's README and thought it a good idea.

I use it in a controller like below. Since my API accepts data from a sensor every second, I set my rate limit to 10 requests for 10 seconds.

defmodule MnishiguchiWeb.ExampleController do
  use MnishiguchiWeb, :controller

  import MnishiguchiWeb.API.RateLimitPlug, only: [rate_limit: 2]

  ...

  plug :rate_limit, max_requests: 10, interval_seconds: 10

  ...

Writing test

When the rate limit is one time for one minute, first request is good but second one immediately after that would be an error. After every test case, we will erase data in ExRated gen server.

defmodule MnishiguchiWeb.API.RateLimitPlugTest do
  use MnishiguchiWeb.ConnCase, async: true

  alias MnishiguchiWeb.API.RateLimitPlug

  @path "/"
  @rate_limit_options [max_requests: 1, interval_seconds: 60]

  setup do
    bucket_name = "127.0.0.1:" <> @path

    on_exit(fn ->
      ExRated.delete_bucket(bucket_name)
    end)
  end

  describe "rate_limit" do
    test "503 Service Unavailable when beyond limit", %{conn: _conn} do
      conn1 =
        build_conn()
        |> bypass_through(MnishiguchiWeb.Router, :api)
        |> get(@path)
        |> RateLimitPlug.rate_limit(@rate_limit_options)

      refute conn1.halted

      conn2 =
        build_conn()
        |> bypass_through(MnishiguchiWeb.Router, :api)
        |> get(@path)
        |> RateLimitPlug.rate_limit(@rate_limit_options)

      assert conn2.halted
      assert json_response(conn2, 503) == "Service Unavailable"
    end
  end
end

That's it!