mix testHex version badgeLicense badge

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:

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.