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 one question, in two forms: may a call proceed right now,
and if not, how long until it may? check/2 answers it and consumes the
call; peek/2 answers it and consumes nothing. 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
other callback, 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.
Driving a limiter directly
Most of the time the limiter is driven for you: ExternalService.call/3 checks
it before running your function. The functions in this module are for the cases
that fall outside a guarded call.
peek/1 asks whether a call would be admitted without consuming anything,
which is what makes it safe to call speculatively:
case ExternalService.RateLimiter.peek(:payments) do
:ok -> start_expensive_work()
{:wait, ms} -> {:error, {:busy, ms}}
endExternalService.rate_limited?/1 is the boolean form, symmetric with
ExternalService.available?/1.
request/1 is the write side: it consumes one call's worth of the budget
without running anything, for traffic that reaches the service by some path
other than call/3.
# A batch endpoint that costs three calls against the quota.
Enum.each(1..3, fn _ -> ExternalService.RateLimiter.request(:payments) end)Note that request/1 blocks according to the service's :wait setting, just
as a guarded call would.
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.
Callbacks
Reports whether a call may proceed now.
Prepares the rate limiter for service.
Reports whether a call would be admitted right now, without consuming anything.
Discards the limiter's recorded usage, returning it to a full budget.
Functions
Reports whether a call to service would be admitted right now, without
consuming any of its budget.
Consumes one call's worth of service's rate limit without running anything.
Discards service's recorded rate limit usage, returning it to a full budget.
Types
@type config() :: term()
Backend-private state, produced by init/2 and passed to every other callback.
@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() | nil
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.
nil means the service never set :wait. It behaves exactly like :infinity,
but ExternalService.start/2 warns about it — see
Bounding the wait.
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.
@callback peek(service(), config()) :: :ok | {:wait, non_neg_integer()}
Reports whether a call would be admitted right now, without consuming anything.
Returns the same values as check/2, but must leave the limiter's state
untouched so that callers can ask speculatively. Where a backend can only
answer approximately, prefer erring toward {:wait, _} — a caller that skips
work it could have done is cheaper than one that floods a service it should
have waited for.
Discards the limiter's recorded usage, returning it to a full budget.
The counterpart to ExternalService.CircuitBreaker.reset/2. Mostly useful
between tests, where a bucket drained by one test would otherwise throttle the
next, but also for clearing a limiter after an operational intervention.
Functions
@spec peek(service()) :: :ok | {:wait, non_neg_integer()}
Reports whether a call to service would be admitted right now, without
consuming any of its budget.
A service with no rate limit configured — including one that was never started
— answers :ok, since nothing is holding calls back. Use
ExternalService.available?/1 if you need to know whether a service is ready
to use.
@spec request(service()) :: :ok | {:error, ExternalService.RateLimited.t() | ExternalService.ServiceNotStarted.t()}
Consumes one call's worth of service's rate limit without running anything.
For traffic that reaches the service by some path other than
ExternalService.call/3 and should still count against the budget. Blocks
according to the service's :wait setting, exactly as a guarded call would,
and returns ExternalService.RateLimited if that budget runs out.
@spec reset(service()) :: :ok | {:error, :not_found}
Discards service's recorded rate limit usage, returning it to a full budget.
Symmetric with ExternalService.CircuitBreaker.reset/1. A service with no rate
limit configured answers :ok, since there is nothing to reset; one that was
never started answers {:error, :not_found}.
Chiefly useful between tests — a bucket drained by one test throttles the next,
and nothing clears it automatically. ExternalService.reset_all/1 resets the
breaker and the limiter together, which is usually what a setup block wants.