ExternalService.RateLimiter behaviour (ExternalService v2.3.0)

Copy Markdown View Source

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
end

Then 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}}
end

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

Backend-private state, produced by init/2 and passed to every other callback.

Returned by call/2 when the wait budget was exhausted before the call could be admitted, carrying the milliseconds still remaining.

t()

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

config()

@type config() :: term()

Backend-private state, produced by init/2 and passed to every other callback.

rate_limited()

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

service()

@type service() :: ExternalService.service()

t()

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

wait()

@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

check(service, config)

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

init(service, options)

@callback init(service(), options :: keyword()) :: {:ok, config()}

Prepares the rate limiter for service.

Receives the validated :rate_limit options (:limit and :per) with any backend-specific options merged in.

peek(service, config)

@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

peek(service)

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

request(service)

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.