ExternalService.RateLimiter behaviour (ExternalService v2.6.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.

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

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() | 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

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.

reset(service, config)

@callback reset(service(), config()) :: :ok

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

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.

reset(service)

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