defmodule SudregEx.RateLimiter do
@moduledoc """
Optional client-side sliding-window rate limiter.
Use it to stay within the public `detalji_subjekta` limit of 6 requests/minute:
{:ok, _} = SudregEx.RateLimiter.start_link(name: :sudreg_limiter)
:ok = SudregEx.RateLimiter.acquire(:sudreg_limiter)
SudregEx.Api.detalji_subjekta(client, tip_identifikatora: "oib", identifikator: oib)
Not started by default — start one yourself (e.g. in your supervision tree).
`acquire/1` blocks the *caller* until a slot frees; the GenServer itself never
sleeps, so admission decisions stay responsive.
Under many concurrent callers, waiters wake together and re-contend; admission
stays correct (never more than `limit` per window) but a retry storm is
possible — a non-issue for the single-consumer `detalji_subjekta` case.
"""
use GenServer
@default_limit 6
@default_window_ms 60_000
@doc """
Starts the limiter. Options: `:name` (default `#{inspect(__MODULE__)}`),
`:limit` (default 6), `:window_ms` (default 60_000).
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc "Blocks until a request slot is free within the window, then returns `:ok`."
@spec acquire(GenServer.server()) :: :ok
def acquire(server \\ __MODULE__) do
case GenServer.call(server, :acquire) do
:ok ->
:ok
{:wait, ms} ->
Process.sleep(ms)
acquire(server)
end
end
@impl true
def init(opts) do
{:ok,
%{
limit: Keyword.get(opts, :limit, @default_limit),
window_ms: Keyword.get(opts, :window_ms, @default_window_ms),
hits: []
}}
end
@impl true
def handle_call(:acquire, _from, %{limit: limit, window_ms: window_ms, hits: hits} = state) do
now = System.monotonic_time(:millisecond)
recent = Enum.filter(hits, &(&1 > now - window_ms))
if length(recent) < limit do
{:reply, :ok, %{state | hits: [now | recent]}}
else
# Wait until the oldest in-window hit ages out. Do NOT record a hit here,
# otherwise the window would never drain. floor at 1ms to avoid sleep(0).
wait = max(Enum.min(recent) + window_ms - now, 1)
{:reply, {:wait, wait}, %{state | hits: recent}}
end
end
end