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!
Top comments (2)
@mnishiguchi Is there a way to rate limiting per user and count requests in an api with ExRated?
According to the documentation, you can have as many buckets as you need so I guess it is possible just including user id in bucket name.