Subscribe to access all episodes. View plans →

#172: Rate-limiting a Phoenix API with Hammer

Published October 9, 2023

Phoenix 1.7

Follow along with the episode starter on GitHub


In episode 170 we built a JSON API with Phoenix and in episode 171 we added authentication to that API with plug. If you’re new to Phoenix or unfamiliar with building a JSON API - I’d recommend pausing this video - and watching those episodes first.

Here I’ve have that API open in another tab currently our API has no limit to the number of requests a user can make. As they have a valid API key, they can return album data.

In this episode, let’s change that and update our application to rate-limit our API. And to do that we’ll use the Elixir rate-limiter package, Hammer. Let’s copy the Hammer config from Hex. Then let’s open our Mixfile - and add hammer to our list of dependencies.

mix.exs

...

defp deps do
  ...
  {:hammer, "~> 6.1"},
  ...
end

...

We’ll go to the command line and install Hammer.

$ mix deps.get
...
New:
  hammer 6.1.0
  poolboy 1.5.2

Now we need to configure Hammer. Hammer has pluggable backends we can use. Currently, there are backends for ETS, Redis, and one in beta for Mnesia. Let’s configure our application to use ETS. We’ll open our config.exs and paste it into the configuration code for our application.

The backend we’re using is ETS, which isn’t the best option for production, but is great for development. expiry_ms is the time in milliseconds before a bucket is auto-deleted. This value should be longer than the life of your longest bucket. Otherwise, the bucket could be deleted while it’s still counting up hits for its time period. cleanup_interval_ms is the time in milliseconds between cleanup runs. Now even if we don’t configure the Hammer application here, it will start with some defaults including using ETS as the backend.

config/config.exs

...

config :hammer,
  backend: {Hammer.Backend.ETS,
                    [expiry_ms: 1000 * 60 * 4,
                     cleanup_interval_ms: 60_000 * 10]}

...

Now that we’ve configured Hammer, let’s integrate it into our API to limit requests. We’ll go to the api/album_controller.ex that we built in episodes 170 and 171. And let’s rate-limit requests to the show function here. To track which user made the request to the API we’ll need to get the api_user from the connection’s assigns. If you’re unsure about how to update the conn.assigns with a user, pause this video and watch episodes 170 and 171, where we build up this API.

Now we need to use Hammer to check if our user is over their request limit. To do that we can use the Hammer.check_rate function - which will check if the action is within the bounds of the rate-limit.

It will take 3 different arguments. The first is the ID. Hammer uses a token bucket algorithm to count the number of actions occurring in a “bucket”. This ID will be the ID of the bucket. Let’s call ours “album_show:#{api_user.id}”. Then the scale in milliseconds - we’ll use 60,000 which is equal to 1 minute. And finally the limit - let’s set ours to 5. So in summary here, a user can request 5 albums in 1 minute. If they exceed that, Hammer will deny the request. If the request is under the limit for the given bucket, Hammer.check_rate will return an :allow tuple, with the “count”, which we’ll ignore. And we’ll continue to return the album. If the request is over its limit for the bucket, Hammer.check_rate will return a :deny tuple along with the limit … which we can ignore since we won’t use it for this example. When this happens, let’s return a tuple with {:error, :over_limit}. We’ll need to handle this in our fallback controller.

lib/teacher_web/controllers/api/album_controller.ex

...

def show(conn, %{"id" => id}) do
  api_user = conn.assigns.api_user
  case Hammer.check_rate("album_show:#{api_user.id}", 60_000, 5) do
    {:allow, _count} ->
      with {:ok, album} <- get_album(id) do
        render(conn, :show, album: album)
      end

    {:deny, _limit} ->
      {:error, :over_limit}

  end

end

...

So let’s open that and add another call function to handle when a request is over the limit, pattern matching on the {:error, :over_limit} tuple. Inside the function, we’ll take the conn and pipe it into put_status with the :too_many_requests. Then into put_view with json: TeacherWeb.ErrorJSON and finally we’ll pipe that into render with a 429 template.

lib/teacher_web/controllers/api/fallback_controller.ex

...

def call(conn, {:error, :over_limit}) do
  conn
  |> put_status(:too_many_requests)
  |> put_view(json: TeacherWeb.ErrorJSON)
  |> render(:"429")
end

...

With that let’s go to the command line and start the server.

$ mix phx.server
...

If we make 5 requests to our API they work, but on the 6th request - we get an error “Too Many Requests”. Great - Hammer worked and limited the number of successful requests for our user to 5. Now we need to wait for our specified time or scale of 1 minute to pass. Let’s try to make a request after 1 minute has passed - perfect - our request was successful.

Now let’s use Hammer to create another API endpoint that a user can use to see some of their request data, like how many requests they’ve made, and how long until their next bucket.

To do that we’ll use Hammer.inspect_bucket. Let’s go back to our api/album_controller.ex and we’ll add a new function called summary. Inside the function we’ll need to get the api_user, then we’ll want to render the “summary” template, which will include the bucket summary data. To return that data, let’s create a private function summary_data that will take a user’s ID. And inside it, we’ll call Hammer.inspect_bucket passing in the bucket ID that we want to return data for. The same scale in milliseconds we used and the same limit.

It will return an :ok tuple with bucket data, which we’ll pattern match on to get the count, count_remaining, ms_to_next_bucket, created_at, and updated_at. Then let’s return the bucket data as a map. Let’s go back to our summary function and call summary_data to return the bucket summary data and have it included in the assigns.

lib/teacher_web/controllers/api/album_controller.ex

...

defp summary_data(user_id) do
  {:ok, {count, count_remaining, ms_to_next_bucket, created_at, updated_at}} =
    Hammer.inspect_bucket("album_show:#{user_id}", 60_000, 5)

  %{
    count: count,
    count_remaining: count_remaining,
    ms_to_next_bucket: ms_to_next_bucket,
    created_at: created_at,
    updated_at: updated_at
  }
end

def summary(conn, _params) do
  api_user = conn.assigns.api_user
  render(conn, :summary, summary_data: summary_data(api_user.id))
end

...

Then let’s open the album_json.ex and add another function for the summary and inside it, we’ll return “summary_data” from the assigns.

lib/teacher_web/controllers/api/album_json.ex

...

def summary(assigns) do
  assigns[:summary_data]
end

...

Now we’ll need to go to our router.ex and add a new route for our “/summary” endpoint.

lib/teacher_web/router.ex

...

scope "/api", TeacherWeb.Api do
  pipe_through :api

  get "/summary", AlbumController, :summary
  ...
end

...


With that let’s go to http://localhost:4000/api/summary endpoint in the browser. And we can see the data about our “album_show” bucket is returned. Currently the “count” is 0 and the “count_remaining” is 5. So let’s make a request. And then when we go back to our summary endpoint we see that the data is updated to reflect the current state of the bucket. We can make more requests and the summary endpoint will continue to show the current state of the bucket. Then once we make too many requests, our buckets summary shows that the “count_remaining” is 0. Perfect, our application is now set up to use Hammer to help rate-limit our API.

Hammer also provides additional functions for things like, Hammer.delete_buckets, which effectively lets you reset the rate limit and Hammer.check_rate_inc which gives you more control over how much increment the count of a bucket. You can read more about it in Hammer’s docs.

© 2024 HEXMONSTER LLC