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:
- 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.
check/3discardedweight. It answered:okwhen there was room for exactly one, whatever was asked. Here every entry point honoursweight.acquire/3failed closed andcheck/3failed open, undocumented, on the same condition. That was half the contract's fault —check/3had 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}.1..weightis descending when weight is 0. On Elixir 1.18.4,Enum.to_list(1..0) == [1, 0], sorecord(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, andpos_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
@type limit() :: %{ limit: pos_integer(), per_ms: pos_integer(), burst: non_neg_integer() }
limit requests per per_ms, tolerating burst above the smooth rate.
Per-provider limits, or the :default used for any provider not listed.
Functions
@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.
@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.
Returns a specification to start this module under a supervisor.
See Supervisor.
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.
@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.
@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 whenoptsnames no limiter.:limits— a map of provider tolimit/0, plus an optional:default. Anything not listed uses:default, and an unconfigured:defaultuses 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.