Packages

Cluster-wide sliding-window rate limiter

Current section

Files

Jump to
Raw

README.md

[![mix test](https://github.com/jechol/global_rate_limiter/actions/workflows/mix_test.yml/badge.svg)](https://github.com/jechol/global_rate_limiter/actions/workflows/mix_test.yml)
[![Hex version badge](https://img.shields.io/hexpm/v/global_rate_limiter)](https://hex.pm/packages/global_rate_limiter)
[![License badge](https://img.shields.io/hexpm/l/global_rate_limiter)](https://github.com/jechol/global_rate_limiter/blob/main/LICENSE.md)
# 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`:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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`](https://hexdocs.pm/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.