Skip to main content

lib/sudreg_ex/rate_limiter.ex

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