GlobalRateLimiter
An in-memory sliding-window rate limiter shared by connected Erlang nodes,
with no external storage. Each node counts its own consumption, and a
:global lock coordinates enforcement so the cluster-wide limit holds
atomically.
It is built for low-traffic keys where correctness and operational simplicity matter more than throughput: login attempts, password challenges, calls to a metered third-party API.
It requires Elixir 1.18 or later and Erlang/OTP 25 or later.
Installation
Add global_rate_limiter to the dependencies in mix.exs:
def deps do
[
{:global_rate_limiter, "~> 0.2.0"}
]
end
Defining a limit
A GlobalRateLimiter.Limit combines a resource with a sliding window in
milliseconds and a maximum count. This limit allows three operations per
client IP in any one-hour sliding window:
limit =
GlobalRateLimiter.Limit.new({:password_challenge, client_ip},
window: :timer.hours(1),
limit: 3
)
The resource can be any Erlang term. Include every dimension that should be limited independently — the action, a user id, a tenant, an API key.
The whole limit value is the identity of what gets tracked: the same resource with a different window or limit is counted independently, and changing a limit's window or limit value starts a fresh consumption history.
Consuming immediately
try_consume/2 either consumes the requested count right away or tells you
why it cannot:
case GlobalRateLimiter.try_consume(limit, 1) do
{:ok, remaining} ->
# Consumed. `remaining` is the capacity left right after this call.
perform_operation()
{:error, {:rate_limited, retry_after_ms}} ->
# Temporarily out of capacity. `retry_after_ms` estimates when enough
# prior consumption will fall out of the window.
reject_with_retry_hint(retry_after_ms)
{:error, :count_exceeds_limit} ->
# The count is larger than the limit itself and can never succeed.
reject_permanently()
end
retry_after_ms is a hint computed from the current cluster snapshot, not a
reservation — another caller may take the capacity first.
Waiting for capacity
consume/3 blocks until it can atomically consume the requested count, up to
a timeout in milliseconds:
case GlobalRateLimiter.consume(limit, 1, 5_000) do
{:ok, remaining} -> run_limited_operation()
{:error, :timeout} -> retry_later()
{:error, :count_exceeds_limit} -> reject_request()
end
There is no default timeout; pass :infinity explicitly to wait without a
deadline. A timeout of 0 succeeds only when capacity is available
immediately.
Waiting is safe to abandon: the waiting process is monitored, so a caller that exits is removed from the queue without consuming anything, and a timed-out call leaves no reservation behind.
Waiters for the same limit are served first-in-first-out on each node.
try_consume/2 calls and waiters on other nodes are not part of that
ordering, so the library does not promise cluster-wide fairness.
Observing
remaining/1 returns the capacity currently left for a limit:
GlobalRateLimiter.remaining(limit)
#=> 2
It is an observational snapshot. Do not build check-then-consume logic on top
of it — try_consume/2 and consume/3 are the atomic operations.
How it works
Consumption for a limit is recorded per node, in a counter process that joins
a :pg group keyed by the limit. To consume, a caller takes a :global lock
for that limit, reads every node's counter, and increments its own node's
counter only if the cluster-wide total still fits — so enforcement is atomic
across connected nodes. Waiting callers queue in a waiter process per limit
on their own node.
All of these processes manage their own lifecycle: a counter stops once its last record has aged out of the window, and a waiter stops as soon as its queue is empty. An idle cluster runs no per-limit processes at all.
The model has a few operational consequences:
- Nodes must be connected as an Erlang cluster.
- State lives only in memory. Restarting a node discards that node's share of the counts.
- During a network partition each side enforces its own view of the limit, so combined traffic can exceed the configured limit until the cluster reconnects.
- The distributed lock favors simplicity over throughput. Do not use GlobalRateLimiter for hot rate-limit keys.
Usage rules for coding agents
The Hex package includes usage-rules.md. Projects using
usage_rules can include
:global_rate_limiter (or use :all) in their usage_rules configuration and run
mix usage_rules.sync to import the library-specific guidance.