Rate-Limiting Phoenix (with Redis too)

Rate-Limiting Phoenix (with Redis too)

Rate-Limiting Phoenix (with Redis too)

Most web-apps in production probably need some kind of rate-limiting, to prevent rough clients from taking down the app by flooding it with expensive http requests.

In this article we’ll see how to add rate-limiting to an Elixir/Phoenix application, using the Hammer package. We will also see how to switch the hammer backend so that it stores it’s data in Redis.

As an example, we’ll use a simple Phoenix application, with one http route (/timestamp), which will serve up a json response containing the current time. We will then demonstrate how easy it is to add rate-limiting to protect the application from greedy clients.

Why Hammer?

Hammer is a rate-limiter for Elixir, with a pluggable backend system, so the rate-limiting data can be stored wherever you want. Currently Hammer supports backends for ETS (in-memory) and Redis, with more in development.

While ETS is fine for small deployments, or when you’re only using one server anyway, it makes sense to move to Redis when you have more than one application server, so that the rate-limiting counters don’t get reset if a machine goes down.

The Phoenix project

Let’s create a new Phonenix project, called timely:

mix phx.new –no-ecto timely

Once that’s all set up, we’ll open the router.ex file and add a route for /timestamp:

scope „/“, Timely.Web do pipe_through :browser get „/“, PageController, :index get „/timestamp“, PageController, :get_timestamp end

Now, let’s add the get_timestamp function to page_controller.ex:

defmodule Timely.Web.PageController do use Timely.Web, :controller import Logger def index(conn, _params) do render conn, „index.html“ end def get_timestamp(conn, _params) do Logger.log(:info, „Generating timestamp“) now = DateTime.utc_now() conn |> json(%{timestamp: „#{now}“}) end end

If we start the server with mix phx.server, we should be able to hit that endpoint and get the current time:

curl http://localhost:4000/timestamp # => {„timestamp“:“2017-10-07 17:08:46.546596Z“}

Now let’s add the Hammer package to the dependency list in mix.exs:

defp deps do # … {:hammer, „~> 2.0.0“}, # … end

And run mix deps.get to install the new dependency.

Once that’s done, we can update page_controller.ex to add rate-limiting to the get_timestamp route:

defmodule Timely.Web.PageController do use Timely.Web, :controller import Logger def index(conn, _params) do render conn, „index.html“ end def get_timestamp(conn, _params) do # Get a (semi) unique identifier for this user ip = conn.remote_ip |> Tuple.to_list |> Enum.join(„.“) # Do the rate-limit check, permit five hits per minute case Hammer.check_rate(„get_timestamp:#{ip}“, 60_000, 5) do # Action is allowed {:allow, _count} -> Logger.log(:info, „Rate-Limit ok, generating timestamp“) now = DateTime.utc_now() conn |> json(%{timestamp: „#{now}“}) # Action is denied, because the rate-limit has been exceeded # for this time-period {:deny, _} -> Logger.log(:info, „Rate-Limit exceeded, denying request“) conn |> send_resp(429, „Too many requests“) end end end

Here we use Hammer.check_rate/3 to check if this ip address has gone over the limit (five hits per minute). If they’re under the limit then we generate the timestamp and send the response as before, but if they’re over the limit then we send back a 429 response instead.

We can test this out in the shell by hitting the /timestamp route a bunch of times in quick succession:

for i in {1..10} do curl -s http://localhost:4000/timestamp && echo “ done # => {„timestamp“:“2017-10-07 17:19:20.943411Z“} # => {„timestamp“:“2017-10-07 17:19:20.992869Z“} # => {„timestamp“:“2017-10-07 17:19:21.036328Z“} # => {„timestamp“:“2017-10-07 17:19:21.088144Z“} # => {„timestamp“:“2017-10-07 17:19:21.136055Z“} # => Too many requests # => Too many requests # => Too many requests # => Too many requests # => Too many requests

This is exactly what we want to happen. If we wait for a minute before trying again we’ll see another batch of timestamps come through.

Switching to Redis

Because of Hammer’s pluggable backend system, switching to Redis is easy. We’ll presume you have a Redis server running locally on the usual port.

First, let’s add the hammer_backend_redis package to mix.exs:

defp deps do # … {:hammer, „~> 2.0.0“}, {:hammer_backend_redis, „~> 2.0.0“}, # … end

And run mix deps.get again.

Now, open config/dev.exs, and add the following lines:

config :hammer, backend: {Hammer.Backend.Redis, [expiry_ms: 60_000 * 10, redix_config: []]}

This tells hammer to use the Redis backend (which we just installed), with a specific expiry time configured, and default options for the redis connection.

Don’t worry too much about those for now, you can always read the documentation later.

Now, restart the server, you should see a log line that says:

[info] Starting Hammer with backend ‚Elixir.Hammer.Backend.Redis‘

… which indicates we’re using Redis for storage.

Try hitting the /timestamp route again, and everything should still work.

curl http://localhost:4000/timestamp # => {„timestamp“:“2017-10-07 17:08:46.546596Z“}

To verify that our data is being stored in redis, use redis-cli to list all the keys starting with Hammer:

redis-cli keys ‚Hammer*‘ 1) „Hammer:Redis:Buckets:get_timestamp:127.0.0.1“ 2) „Hammer:Redis:get_timestamp:127.0.0.1:25123300“

And that’s it! We’ve now got fully functional rate-limiting in our Phoenix application.

Further reading