DEV Community

Masatoshi Nishiguchi
Masatoshi Nishiguchi

Posted on • Edited on

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 ... 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

That's it!

Top comments (2)

Collapse
 
allansduarte profile image
Allan Soares Duarte

@mnishiguchi Is there a way to rate limiting per user and count requests in an api with ExRated?

Collapse
 
mnishiguchi profile image
Masatoshi Nishiguchi

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.