defmodule MicrowavepropWeb.Api.RateLimiter do @moduledoc """ Tiny ETS-backed fixed-window rate limiter for `/api/v1`. The named ETS table is created at application boot (and re-created idempotently if the boot path is bypassed in tests). Each `{bucket, window}` pair holds a counter incremented atomically via `:ets.update_counter/4`. Default limits (overridable per plug invocation): * authenticated requests: 600 / minute, keyed by API token id * anonymous requests: 60 / minute, keyed by client IP The plug emits the RFC 9651 `RateLimit` headers and a 429 problem+json response when the bucket is exhausted. ## Lifecycle Without a sweeper the table grows one row per distinct `{bucket, window}` and never shrinks — every distinct IP / token over the pod's lifetime stays resident. The companion `Sweeper` GenServer prunes rows whose window has fully expired on a fixed cadence; see `MicrowavepropWeb.Api.RateLimiter.Sweeper`. """ @behaviour Plug import Plug.Conn alias MicrowavepropWeb.Api.ErrorJSON @table :microwaveprop_api_rate_limiter @default_window_ms 60_000 @default_anon_limit 60 @default_auth_limit 600 @doc "Returns the ETS table name backing the limiter." @spec table() :: atom() def table, do: @table @doc "Returns the default window size in milliseconds." @spec default_window_ms() :: pos_integer() def default_window_ms, do: @default_window_ms @doc """ Initializes the named ETS table. Idempotent — safe to call from application boot and from tests. The race between `whereis` and `:ets.new` is handled by rescuing `ArgumentError`, which is what `:ets.new` raises when the table already exists. """ @spec init_table() :: :ok def init_table do _ = if :ets.whereis(@table) == :undefined do try do :ets.new(@table, [:set, :public, :named_table, write_concurrency: true]) rescue ArgumentError -> :ok end end :ok end @doc "Resets the ETS table. Test helper." @spec reset() :: :ok def reset do init_table() :ets.delete_all_objects(@table) :ok end @impl true def init(opts), do: opts @impl true def call(conn, opts) do init_table() now = System.system_time(:millisecond) window_ms = Keyword.get(opts, :window_ms, @default_window_ms) {bucket, limit} = bucket_for(conn, opts) window = div(now, window_ms) key = {bucket, window} count = :ets.update_counter(@table, key, {2, 1}, {key, 0}) remaining = max(limit - count, 0) reset_in = div((window + 1) * window_ms - now + 999, 1000) conn = conn |> put_resp_header("ratelimit-limit", Integer.to_string(limit)) |> put_resp_header("ratelimit-remaining", Integer.to_string(remaining)) |> put_resp_header("ratelimit-reset", Integer.to_string(reset_in)) if count > limit do conn |> put_resp_header("retry-after", Integer.to_string(reset_in)) |> ErrorJSON.send_problem(429, "too_many_requests", "Rate limit exceeded; retry after #{reset_in}s.") else conn end end defp bucket_for(conn, opts) do case conn.assigns[:current_api_token] do %{id: id} when not is_nil(id) -> {{:token, id}, Keyword.get(opts, :auth_limit, @default_auth_limit)} _ -> ip = conn.remote_ip |> :inet.ntoa() |> to_string() {{:ip, ip}, Keyword.get(opts, :anon_limit, @default_anon_limit)} end end end