prop/lib/microwaveprop_web/api/rate_limiter.ex
Graham McIntire 4d3b61c740
fix remaining bugs and quality improvements
Bug fixes:
- path_compute: use midpoint longitude (src+dest)/2 for time-of-day score
- rate_limiter: guard nil token id from burning auth rate limit bucket

Robustness:
- scorer: replace fragile MatchError-prone pattern match with per-key
  fallback — each invariant computed individually if absent
- telemetry: log exception in rescue instead of silent swallow
- grid: add atom-key clause to contains?() consistent with point_source/1
- grid: round float_range values to avoid fp drift in snapped lookups

Cleanup:
- path_compute: remove unused alias Geo (import-only module)
2026-05-24 11:56:17 -05:00

112 lines
3.4 KiB
Elixir

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 is_binary(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