The behaviour implemented by rate limiter backends.
A service's limiter is chosen with the :backend rate limit option, and
defaults to ExternalService.RateLimiter.Local. ExternalService.RateLimiter.Hammer
meters against a Hammer module, which is the
supported route to a limit shared across a cluster.
Writing a backend
A backend answers exactly one question: may a call proceed right now, and if
not, how long until it may? Everything else — sleeping, honoring the :wait
budget, telemetry, logging — is handled for you, so that every backend behaves
consistently.
defmodule MyApp.RateLimiter do
@behaviour ExternalService.RateLimiter
@impl true
def init(service, options) do
{:ok, %{key: service, limit: options[:limit], window: options[:per]}}
end
@impl true
def check(_service, config) do
case MyStore.increment(config.key, config.window, config.limit) do
{:ok, _count} -> :ok
{:throttled, milliseconds} -> {:wait, milliseconds}
end
end
endThen point a service at it:
use ExternalService,
rate_limit: [limit: 100, per: 1_000, backend: {MyApp.RateLimiter, some: :option}]Backends are stateless modules. init/2 returns an opaque config term
that is stored with the rest of the service state and handed back to every
check/2 call, so a backend needs no process, supervisor, or registry of its
own. Anything mutable it needs — an :atomics reference, a connection pool
name, a remote key — travels in that term.
Report a real time-to-next-window from check/2 where you can. Callers sleep
for exactly as long as you say, so an accurate answer paces calls precisely and
makes the :wait budget meaningful.
Summary
Types
Returned by call/2 when the wait budget was exhausted before the call could
be admitted, carrying the milliseconds still remaining.
A configured rate limiter.
How long a throttled call may wait before giving up.
Types
@type config() :: term()
Backend-private state, produced by init/2 and passed to check/2.
@type rate_limited() :: {ExternalService.RateLimiter, :rate_limited, non_neg_integer()}
Returned by call/2 when the wait budget was exhausted before the call could
be admitted, carrying the milliseconds still remaining.
@type service() :: ExternalService.service()
@type t() :: %ExternalService.RateLimiter{ backend: term(), config: term(), service: term(), sleep: term(), wait: term() } | nil
A configured rate limiter.
nil means the service is not rate limited, in which case calls pass straight
through.
@type wait() :: :infinity | false | non_neg_integer()
How long a throttled call may wait before giving up.
:infinity waits as long as the limiter requires, false never waits, and an
integer is a millisecond budget for the whole call.
Callbacks
@callback check(service(), config()) :: :ok | {:wait, non_neg_integer()}
Reports whether a call may proceed now.
Returns :ok when the call is within the limit, or {:wait, milliseconds}
when it is not. Backends that can compute a real time-to-next-window should do
so, so that callers sleep for the right amount of time rather than an estimate.
Prepares the rate limiter for service.
Receives the validated :rate_limit options (:limit and :per) with any
backend-specific options merged in.