Masatoshi Nishiguchi
Posted on April 13, 2021
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!
Posted on April 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.