DpExchange.Core.DefaultRateLimiter (DpExchangeCore v0.1.1)

Copy Markdown View Source

An in-process rate limiter, and the default implementation of DpExchange.Core.RateLimitBehaviour.

A venue package needs a ceiling whether or not its consumer has one. This is that ceiling: no external store, no shared infrastructure, one GenServer per limiter, started by whoever supervises the venue.

Written from the contract, not ported

The implementation this was derived from had four defects, each of which is a test here. They are worth stating because three of the four are silent, and silence is what made them survive:

  1. A weight-N acquire was N sequential calls. It looped, taking one token at a time, ignoring the underlying limiter's native cost argument. That is N round trips where one would do; each carrying a 30-second timeout, so a weight-10 acquire could block for 300 seconds where the design intended 30; the group was not atomic, so two concurrent weight-N acquires interleaved; and a failure partway had already consumed tokens for a request that never happened, which nothing released. Here one acquire is one atomic reservation.
  2. check/3 discarded weight. It answered :ok when there was room for exactly one, whatever was asked. Here every entry point honours weight.
  3. acquire/3 failed closed and check/3 failed open, undocumented, on the same condition. That was half the contract's fault — check/3 had no way to say "I could not tell" — so the contract was widened rather than the workaround copied. Here both fail closed, and an unknown answer is {:error, reason}.
  4. 1..weight is descending when weight is 0. On Elixir 1.18.4, Enum.to_list(1..0) == [1, 0], so record(provider, 0, opts) recorded two requests — inflating the exact meter it exists to keep honest. The compile-time warning does not fire because the bound is a variable, and pos_integer() in a typespec is not a runtime check. Here weight is validated at the boundary.

It holds no venue knowledge

There is no table of venues in this module and there must never be one. Limits are configuration a consumer supplies at start_link/1; a venue's own ceiling is the venue package's business. Baking venue facts into shared code is the pattern this whole family exists to remove, and reproducing it inside Core would be worse than leaving it in the host.

The algorithm

A virtual-scheduling token bucket (GCRA). One integer per provider — the time at which its next request may proceed — advanced by weight × emission_interval on every reservation, and allowed to run ahead of now by a burst tolerance. Reserving weight is a single addition, which is what makes it atomic without a lock.

acquire/3 computes the wait inside the server, commits the reservation, and sleeps in the caller. The server never blocks, so one slow caller cannot stall the limiter for everyone else.

Not started is not "no limit"

Every callback returns {:error, :not_started} when the named limiter is not running. Failing open would meter nothing while reporting success, which is exactly the failure the ceiling exists to prevent: a venue answering with HTTP 429 while the budget panel reads comfortable.

Summary

Types

limit requests per per_ms, tolerating burst above the smooth rate.

Per-provider limits, or the :default used for any provider not listed.

Functions

Reserves weight tokens, waiting until they are available.

Answers whether weight tokens are available right now, without reserving them.

Returns a specification to start this module under a supervisor.

The limiter's current view of a provider, for tests and diagnostics.

Records that weight requests were actually sent.

Starts a limiter.

Types

limit()

@type limit() :: %{
  limit: pos_integer(),
  per_ms: pos_integer(),
  burst: non_neg_integer()
}

limit requests per per_ms, tolerating burst above the smooth rate.

limits()

@type limits() :: %{optional(atom() | String.t() | :default) => limit()}

Per-provider limits, or the :default used for any provider not listed.

Functions

acquire(provider, weight, opts \\ [])

@spec acquire(atom() | String.t(), pos_integer(), keyword()) ::
  :ok | {:error, :rate_limit_timeout} | {:error, term()}

Reserves weight tokens, waiting until they are available.

Returns :ok once the reservation is honoured, {:error, :rate_limit_timeout} if the wait would exceed :timeout, or {:error, reason} if the answer is unknown.

One reservation, not weight of them. The wait is served in the calling process, so a caller waiting does not stall the limiter for anyone else. A reservation that cannot be honoured within the timeout is not committed — the tokens are not consumed for a request that will never be made.

check(provider, weight, opts \\ [])

@spec check(atom() | String.t(), pos_integer(), keyword()) ::
  :ok | {:rate_limited, non_neg_integer()} | {:error, term()}

Answers whether weight tokens are available right now, without reserving them.

{:error, reason} when the limiter is not running or the weight is invalid — a caller must treat that as "do not proceed", because not knowing is not the same as having capacity.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

inspect_provider(provider, opts \\ [])

@spec inspect_provider(
  atom() | String.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}

The limiter's current view of a provider, for tests and diagnostics.

Returns %{next_allowed_in_ms: non_neg_integer()} — zero when the provider may proceed immediately.

record(provider, weight, opts \\ [])

@spec record(atom() | String.t(), pos_integer(), keyword()) :: :ok

Records that weight requests were actually sent.

Fills the bucket that acquire/3 and check/3 measure against. A venue package issuing its own HTTP calls — rather than going through a client that records on its behalf — must call this, or its ceiling meters against a bucket nothing writes to and every check passes.

Cannot fail. Metering must never be the reason a market-data call does not happen; a missed record costs accuracy in the ceiling, not the request. An invalid weight is ignored rather than raising, and — unlike the implementation this replaces — a weight of 0 records nothing rather than two.

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts a limiter.

Options

  • :name — the registered name. Defaults to this module, which is what the callbacks use when opts names no limiter.
  • :limits — a map of provider to limit/0, plus an optional :default. Anything not listed uses :default, and an unconfigured :default uses 10 requests per second with a burst of 10.

A consumer supervises this. Nothing starts it implicitly — a package that opened a limiter on load would be starting processes behind the application that depends on it.