🎓 A1Learner
🔍
0 of 46 lessons completed

Elixir Project: Rate Limited API

A rate limited API is an API that allows only a fixed number of calls per key. Calls past that number are refused until the count is reset.

The fourth call past a limit of three is refused

One key makes four calls against a limit of three. The first three succeed and the fourth is refused.

defmodule RateLimiter do
  use GenServer
  def start_link(limit), do: GenServer.start_link(__MODULE__, %{limit: limit, counts: %{}}, [])
  def allow(pid, key), do: GenServer.call(pid, {:allow, key})
  def remaining(pid, key), do: GenServer.call(pid, {:remaining, key})
  def init(state), do: {:ok, state}
  def handle_call({:allow, key}, _from, state) do
    count = Map.get(state.counts, key, 0)
    if count < state.limit do
      {:reply, :ok, %{state | counts: Map.put(state.counts, key, count + 1)}}
    else
      {:reply, {:error, :limited}, state}
    end
  end
  def handle_call({:remaining, key}, _from, state), do: {:reply, state.limit - Map.get(state.counts, key, 0), state}
end
{:ok, limiter} = RateLimiter.start_link(3)
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.remaining(limiter, :api))
GenServer.stop(limiter)

Each key has its own count

Two keys share one limiter but count separately. One key reaching its limit leaves the other key free.

defmodule RateLimiter do
  use GenServer
  def start_link(limit), do: GenServer.start_link(__MODULE__, %{limit: limit, counts: %{}}, [])
  def allow(pid, key), do: GenServer.call(pid, {:allow, key})
  def init(state), do: {:ok, state}
  def handle_call({:allow, key}, _from, state) do
    count = Map.get(state.counts, key, 0)
    if count < state.limit do
      {:reply, :ok, %{state | counts: Map.put(state.counts, key, count + 1)}}
    else
      {:reply, {:error, :limited}, state}
    end
  end
end
{:ok, limiter} = RateLimiter.start_link(2)
IO.inspect(RateLimiter.allow(limiter, :a))
IO.inspect(RateLimiter.allow(limiter, :a))
IO.inspect(RateLimiter.allow(limiter, :b))
IO.inspect(RateLimiter.allow(limiter, :a))
IO.inspect(RateLimiter.allow(limiter, :b))
GenServer.stop(limiter)

Reset restores a key after refusal

A key hits its limit and is refused. After a reset the same key is allowed again.

defmodule RateLimiter do
  use GenServer
  def start_link(limit), do: GenServer.start_link(__MODULE__, %{limit: limit, counts: %{}}, [])
  def allow(pid, key), do: GenServer.call(pid, {:allow, key})
  def reset(pid, key), do: GenServer.call(pid, {:reset, key})
  def remaining(pid, key), do: GenServer.call(pid, {:remaining, key})
  def init(state), do: {:ok, state}
  def handle_call({:allow, key}, _from, state) do
    count = Map.get(state.counts, key, 0)
    if count < state.limit do
      {:reply, :ok, %{state | counts: Map.put(state.counts, key, count + 1)}}
    else
      {:reply, {:error, :limited}, state}
    end
  end
  def handle_call({:reset, key}, _from, state), do: {:reply, :ok, %{state | counts: Map.delete(state.counts, key)}}
  def handle_call({:remaining, key}, _from, state), do: {:reply, state.limit - Map.get(state.counts, key, 0), state}
end
{:ok, limiter} = RateLimiter.start_link(2)
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.reset(limiter, :api))
IO.inspect(RateLimiter.allow(limiter, :api))
IO.inspect(RateLimiter.remaining(limiter, :api))
GenServer.stop(limiter)

Example

defmodule RateLimiter do
  use GenServer
  def start_link(limit), do: GenServer.start_link(__MODULE__, %{limit: limit, counts: %{}}, [])
  def allow(pid, key), do: GenServer.call(pid, {:allow, key})
  def remaining(pid, key), do: GenServer.call(pid, {:remaining, key})
  def init(state), do: {:ok, state}
  def handle_call({:allow, key}, _from, state) do
    count = Map.get(state.counts, key, 0)
    if count < state.limit do
      {:reply, :ok, %{state | counts: Map.put(state.counts, key, count + 1)}}
    else
      {:reply, {:error, :limited}, state}
    end
  end
  def handle_call({:remaining, key}, _from, state), do: {:reply, state.limit - Map.get(state.counts, key, 0), state}
end
{:ok, limiter} = RateLimiter.start_link(2)
handler = fn key ->
  case RateLimiter.allow(limiter, key) do
    :ok -> "data"
    {:error, :limited} -> "limited"
  end
end
results = [handler.("user1"), handler.("user1"), handler.("user1")]
IO.inspect(results)
IO.inspect(handler.("user2"))
IO.inspect(RateLimiter.remaining(limiter, "user1"))
GenServer.stop(limiter)

Key points

  • Limit: the fixed number of calls allowed per key.
  • Key: the caller identity that owns its own count.
  • Allow: a call that is inside the limit and succeeds.
  • Refusal: a call past the limit that returns a limited error.
  • Remaining: how many calls a key still has left.
  • Reset: clearing a key count so its calls are allowed again.

In this example, a handler checks the limiter before serving data. The third call from the same user is limited, while a new user is still served.

Note

one key hitting its limit never blocks a different key.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

A limiter allows 2 calls per key. Key A makes 2 calls and key B makes 1 call. What happens to the next call from key B?