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

Backend-private state, produced by init/2 and passed to check/2.

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.

Types

config()

@type config() :: term()

Backend-private state, produced by init/2 and passed to check/2.

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.