Packages

Reusable infrastructure for building API proxy and cache services in Elixir.

Current section

Files

Jump to
api_toolkit lib api_toolkit rate_limiter.ex
Raw

lib/api_toolkit/rate_limiter.ex

defmodule ApiToolkit.RateLimiter do
@moduledoc """
Token bucket rate limiter GenServer.
Supports configurable rate limits per named instance.
## Usage
Add to your supervision tree:
children = [
{ApiToolkit.RateLimiter, name: MyApp.RateLimiter.ServiceA, rate: {1, :second}},
{ApiToolkit.RateLimiter, name: MyApp.RateLimiter.ServiceB, rate: {25, :day}}
]
Then acquire tokens before making requests:
ApiToolkit.RateLimiter.acquire(MyApp.RateLimiter.ServiceA)
"""
use GenServer
defstruct [:tokens, :max_tokens, :refill_ms, :waiting]
@type t :: %__MODULE__{
tokens: non_neg_integer(),
max_tokens: pos_integer(),
refill_ms: pos_integer(),
waiting: :queue.queue()
}
# Client API
@doc """
Returns a child specification for supervision.
Uses the `:name` option as the unique child id.
"""
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.fetch!(opts, :name)
%{
id: name,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent
}
end
@doc """
Starts a rate limiter with the given name and rate configuration.
## Options
- `:name` - Required. The GenServer name
- `:rate` - Required. Tuple of `{count, period}` where period is `:second`, `:minute`, or `:day`
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Acquires a token from the rate limiter.
Returns `:ok` immediately if a token is available.
Blocks until a token becomes available if the bucket is empty.
"""
@spec acquire(GenServer.server()) :: :ok
def acquire(server) do
GenServer.call(server, :acquire, :infinity)
end
@doc """
Returns the current status of the rate limiter without consuming a token.
"""
@spec status(GenServer.server()) :: map()
def status(server) do
GenServer.call(server, :status)
end
# Server callbacks
@impl true
def init(opts) do
{count, period} = Keyword.fetch!(opts, :rate)
refill_ms = period_to_ms(period)
state = %__MODULE__{
tokens: count,
max_tokens: count,
refill_ms: refill_ms,
waiting: :queue.new()
}
schedule_refill(refill_ms)
{:ok, state}
end
@impl true
def handle_call(:acquire, from, %__MODULE__{} = state) do
if state.tokens > 0 do
{:reply, :ok, %{state | tokens: state.tokens - 1}}
else
waiting = :queue.in(from, state.waiting)
{:noreply, %{state | waiting: waiting}}
end
end
@impl true
def handle_call(:status, _from, %__MODULE__{} = state) do
status = %{
tokens_available: state.tokens,
max_tokens: state.max_tokens,
queue_depth: :queue.len(state.waiting)
}
{:reply, status, state}
end
@impl true
def handle_info(:refill, %__MODULE__{} = state) do
state = refill_tokens(state)
schedule_refill(state.refill_ms)
{:noreply, state}
end
# Private functions
defp period_to_ms(:second), do: 1_000
defp period_to_ms(:minute), do: 60_000
defp period_to_ms(:day), do: 86_400_000
defp schedule_refill(refill_ms) do
Process.send_after(self(), :refill, refill_ms)
end
# Adds one token (up to max) and serves any waiting callers FIFO
defp refill_tokens(%__MODULE__{} = state) do
new_tokens = min(state.tokens + 1, state.max_tokens)
{state, new_tokens} = maybe_serve_waiting(state, new_tokens)
%{state | tokens: new_tokens}
end
# Replies to queued callers while tokens remain
defp maybe_serve_waiting(%__MODULE__{} = state, tokens) when tokens > 0 do
case :queue.out(state.waiting) do
{{:value, from}, waiting} ->
GenServer.reply(from, :ok)
maybe_serve_waiting(%{state | waiting: waiting}, tokens - 1)
{:empty, _} ->
{state, tokens}
end
end
defp maybe_serve_waiting(%__MODULE__{} = state, tokens), do: {state, tokens}
end