defmodule Req.RateLimiter do @moduledoc """ A Req request step that enforces minimum intervals between requests per API. Uses an ETS table to track the last request timestamp for each named API. When a request is made before the cooldown has elapsed, the calling process sleeps for the remaining time. ## Usage Req.new(...) |> Req.RateLimiter.attach(name: :music_brainz, cooldown: 500) When `cooldown` is 0, the step is a no-op. ## Clock Time operations are delegated to a clock module implementing `Req.RateLimiter.Clock`. Defaults to `Req.RateLimiter.SystemClock`. Pass `:clock` in `attach/2` opts to override (useful in tests). """ @table __MODULE__ @default_clock Req.RateLimiter.SystemClock @doc """ Creates the ETS table used to track request timestamps. Call once at application startup. """ @type attach_opts :: [name: atom(), cooldown: non_neg_integer(), clock: module()] @spec new() :: :ets.table() def new do :ets.new(@table, [:set, :public, :named_table]) end @doc """ Attaches the rate limiter as a request step on the given Req request. ## Options * `:name` - atom identifying the API (e.g. `:music_brainz`) * `:cooldown` - minimum milliseconds between requests * `:clock` - module implementing `Req.RateLimiter.Clock` (default: `Req.RateLimiter.SystemClock`) """ @spec attach(Req.Request.t(), attach_opts()) :: Req.Request.t() def attach(request, opts) do name = Keyword.fetch!(opts, :name) cooldown = Keyword.fetch!(opts, :cooldown) clock = Keyword.get(opts, :clock, @default_clock) request |> Req.Request.put_private(:rate_limiter_name, name) |> Req.Request.put_private(:rate_limiter_cooldown, cooldown) |> Req.Request.put_private(:rate_limiter_clock, clock) |> Req.Request.prepend_request_steps(rate_limiter: &throttle/1) end defp throttle(request) do cooldown = Req.Request.get_private(request, :rate_limiter_cooldown) if cooldown > 0 do name = Req.Request.get_private(request, :rate_limiter_name) clock = Req.Request.get_private(request, :rate_limiter_clock) now = clock.now() case :ets.lookup(@table, name) do [{^name, last_at}] -> elapsed = now - last_at remaining = cooldown - elapsed if remaining > 0 do :telemetry.execute( [:req, :rate_limiter, :throttle], %{sleep_ms: remaining}, %{name: name} ) clock.sleep(remaining) end [] -> :ok end :ets.insert(@table, {name, clock.now()}) end request end end