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.
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.
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.
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.