Last August I implemented a rate limiter to prevent users of the sister app of Breek.gr, Breek.gr Managers from abusing my API access to the Greek business registry lookup by the Tax ID by using the website to perform too many lookups (since the public API is limited to a certain number of request per month).
I didn’t want to use Hammer
yet, so I did it from scratch by using an Agent. Here’s my code, unchanged, in case anyone wants to see an alternative approach. I am running a cache with Cachex
.
defmodule Managers.Directory.RateLimiter do
use Agent
require Logger
alias Managers.Directory.TinChecker
@cache :tincheck
# the format of each rate window tuple is:
# {last x minutes this limit applies to, limit of number of requests in this window }
@windows [
{1, 2},
{2, 3},
{4, 3},
{7, 3},
{15, 4},
{30, 4},
{60, 5}
]
def start_link(_opts \\ nil) do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
def get_all do
Agent.get(__MODULE__, fn state -> state end)
end
def get_for_user(username) do
Agent.get(__MODULE__, fn state -> Map.get(state, username) end)
end
def reset_for_user(username) do
Agent.update(__MODULE__, fn state -> %{state | username => []} end)
end
def gsis(tin, username) do
maybe_initialize_for_user(username)
if permitted?(username) do
do_gsis(tin, username)
else
{:error, :rate_limited}
end
end
def do_gsis(tin, username) do
with {:cache, {:ok, nil}} <- {:cache, Cachex.get(@cache, tin)},
{:tin_format, true} <- {:tin_format, Regex.match?(~r/^\d{9}$/, String.trim(tin || ""))},
{:vies, {:ok, m}} when is_map(m) <- {:vies, TinChecker.vies(tin)},
{:gsis, {:ok, _} = fresh} <- {:gsis, TinChecker.gsis(tin)} do
log_request_for(username)
fresh
else
{:cache, {:ok, cached}} ->
Logger.info("Cache hit for Tax ID #{tin}")
cached
{:tin_format, false} ->
{:error, :invalid_tin_format}
{:vies, {:error, _} = vies_with_error} ->
Cachex.put(@cache, tin, vies_with_error)
vies_with_error
{_, {:error, reason}} ->
{:error, reason}
end
end
def log_request_for(username) do
so_far = get_for_user(username)
horizon = @windows |> Map.new() |> Map.keys() |> Enum.max()
expired_removed =
[DateTime.utc_now() | so_far]
|> filter_in_last(horizon)
Agent.update(__MODULE__, fn state -> %{state | username => expired_removed} end)
end
def maybe_initialize_for_user(username) do
if is_nil(get_for_user(username)) do
Agent.update(__MODULE__, fn state -> Map.put(state, username, []) end)
end
end
def get_in_last(username, minutes) do
username
|> get_for_user()
|> filter_in_last(minutes)
end
def filter_in_last(hits, minutes, t_ref \\ DateTime.utc_now())
when is_list(hits) and is_integer(minutes) do
hits
|> Enum.filter(fn t ->
DateTime.diff(t_ref, t, :minute) < minutes
end)
end
def permitted?(username) do
maybe_initialize_for_user(username)
limits = Map.new(@windows)
keys = Map.keys(limits) |> Enum.sort() |> Enum.reverse()
now = DateTime.utc_now()
initial = get_in_last(username, Enum.max(keys))
Enum.reduce(
keys,
{true, initial},
fn window, acc ->
{ok?, filtered} = acc
filtered = filter_in_last(filtered, window, now)
ok? = ok? and length(filtered) <= limits[window]
{ok?, filtered}
end
)
|> elem(0)
end
def windows, do: @windows
end