Runlimit
Runlimit is a framework-neutral Rust library for enforcing the same keyed, anchored fixed-window limits in one process or across PostgreSQL-backed replicas.
The 0.1 API is intentionally pre-stable. The first production target is authentication throttling in Identitypro.
Crates
| Crate | Responsibility |
|---|---|
runlimit-core |
Validated policies, HMAC-derived subject keys, checks, and structured decisions. |
runlimit-memory |
Sharded, hard-bounded process-local storage with bounded cleanup work. |
runlimit-postgres |
Replica-safe SQLx/PostgreSQL storage, migrations, and bounded maintenance. |
Choose the backend needed by the application:
[]
= "0.1.0"
= "0.1.0"
# Or, for a shared cross-replica quota:
# runlimit-postgres = "0.1.0"
During development from a source checkout, a sibling project can use path dependencies:
[]
= { = "../runlimit/crates/runlimit-core" }
= { = "../runlimit/crates/runlimit-memory" }
Use a pinned Git revision instead when builds do not share a filesystem.
Memory backend example
The following admission checks a client and a normalized identity atomically. If either quota is unavailable, neither counter is consumed.
use ;
use ;
use ;
A compile-checked copy lives in smoke/external-consumer. Check it as an
independent consumer with:
Fixed-window semantics
Runlimit implements anchored fixed windows. The first allowed check for a storage key starts its window; subsequent checks use that anchor until the entire duration elapses. Windows are not aligned to wall-clock boundaries such as calendar minutes.
- Denied checks do not consume quota.
- A batch is all-or-nothing and allowed decisions retain input order.
- Duplicate storage keys in a batch are rejected as caller errors.
- The policy identifier, scope, limit, and window are fingerprinted into the storage key. Changing any of them starts an independent counter instead of reinterpreting existing state.
- Core accepts limits through
runlimit_core::MAX_LIMITand exact whole-millisecond windows throughrunlimit_core::MAX_WINDOW. These shared portable bounds ensure every core-valid policy is representable by both storage backends. - Decisions expose durations measured by the backend. Memory durations are
exact at evaluation time. PostgreSQL measures elapsed evaluation time with
its authoritative database clock and can conservatively overstate the
remaining time at the caller by commit and transport latency. Use
retry_after_seconds()for an HTTPRetry-Aftervalue rounded up to the next whole second.
The memory and PostgreSQL backends intentionally implement these same semantics.
Subject keys and secret rotation
Applications normalize subjects before passing them to Runlimit. Construct
stored keys with KeyHasher, which uses domain-separated HMAC-SHA-256 and
requires a secret of at least 32 bytes. Raw emails, account IDs, session IDs,
and IP addresses should not enter storage, logs, metrics labels, or errors.
Use one stable secret across every replica sharing a backend. Rotating the secret starts fresh counters because every derived subject key changes. Deploy a rotation to all replicas together: replicas using old and new secrets at the same time can each admit traffic against different counters.
Memory backend behavior
MemoryStore is process-local and hard bounded:
- The store defaults to one shard so all of
max_keysis available regardless of key distribution. - Configuring multiple shards divides
max_keysinto fixed per-shard capacities. This increases lock concurrency, but an uneven key distribution can fill one shard and deny a new key while other shards still have unused capacity. - Active entries are never evicted to make room for new subjects.
- Each check removes at most the configured number of expired entries from its shard. An atomic batch receives that allowance once for each check, on the shard targeted by that check.
- A new key that cannot fit in its shard is denied with
Denial::StorageCapacity, optionally including the earliest known retry duration.
If application code panics while holding a shard lock, that shard remains
poisoned. Every later operation touching it returns
MemoryStoreError::PoisonedShard without resetting its counters. With the
default one-shard configuration, this makes the entire store unavailable.
Keep admissions failed closed, alert operators, and replace the store only as
an explicit recovery action; replacement resets all process-local counters.
Treat every MemoryStoreError as an admission failure; it is distinct from a
normal quota denial returned in a Decision or BatchDecision.
PostgreSQL backend
Use runlimit-postgres when a quota must be shared across replicas or survive
process restarts. PostgresLimiter uses PostgreSQL time as its authority,
locks batch keys in deterministic order, and commits an allowed batch in one
transaction. Atomic batches use set-based lock, preflight, and update phases,
so the number of SQL phases stays fixed as the bounded batch size grows.
Runlimit's pg_advisory_xact_lock(bigint) protocol uses a database-wide
namespace shared by every application and role connected to that database. An
unrelated session holding the same numeric lock can delay an affected counter
until its operation deadline. Use a trusted or dedicated database boundary, or
ensure unrelated roles cannot execute the advisory-lock functions.
Version 0.1 is not hard-cardinality-bounded in PostgreSQL. Arbitrary unique-key spray can grow the table until those windows expire and maintenance removes them. A production deployment must:
- keep a hard-bounded
MemoryStoreclient gate before body parsing and PostgreSQL acquisition; - schedule
cleanup_expired(maximum_rows)continuously; and - monitor table rows, index size, disk use, cleanup throughput, and denials at the memory gate.
Distributed database capacity enforcement is a tracked follow-up. Until it is implemented, do not expose PostgreSQL as the only defense against attacker-controlled key cardinality.
Before serving traffic, apply the bundled migration with
PostgresLimiter::migrate(). In a database where application and library
migrations share SQLx's _sqlx_migrations table, every participating migrator
must enable Migrator::set_ignore_missing(true) so each one tolerates versions
owned by the others. The exported raw MIGRATOR keeps SQLx's strict default
and is suitable only when Runlimit exclusively owns that migration history and
connection. An application that must retain a strict host migrator should
vendor the bundled Runlimit SQL as an application-owned migration and not run
either Runlimit migrator against the shared history.
Periodically call cleanup_expired(maximum_rows) to bound maintenance work;
expired rows do not affect correctness before cleanup.
No running database is needed to compile Runlimit or its smoke consumer. Database integration tests should use a disposable PostgreSQL instance.
Database errors must fail closed. runlimit_postgres::CheckError distinguishes
operations that definitely did not consume quota from commit outcomes that may
have consumed it. Inspect may_have_consumed_quota() for observability and do
not blindly retry an unknown commit as part of a non-idempotent operation.
MaintenanceError makes the corresponding distinction for cleanup; inspect
may_have_removed_rows() before deciding whether an unconfirmed cleanup needs
to be retried.
Production topology
The memory backend enforces a per-process quota, not a fleet-wide quota. In a multi-replica service, use PostgreSQL for authoritative shared identity, account, or global limits, while retaining a coarse hard-bounded memory policy as the first-line client/cardinality gate. Give the two gates distinct policy identifiers: the local gate protects parsing and database capacity, while the PostgreSQL policy enforces the shared quota.
All replicas sharing PostgreSQL must use the same policies, normalization rules, and HMAC secret. Readiness checks should verify the dependencies required by the chosen fail-closed path.
Application-owned boundaries
Runlimit owns counter mechanics and storage. Applications continue to own:
- trusted-proxy configuration and client-address extraction;
- IPv4/IPv6 aggregation and identity normalization;
- route policy selection and HTTP/gRPC response formats;
- concurrency bulkheads such as Tokio semaphores;
- durable password lockouts, resend cooldowns, and business quotas.
In particular, Runlimit does not inspect forwarding headers. Only the application knows which network peers are trusted to supply them. Concurrency limits also remain separate because they bound simultaneous work rather than work admitted during a time window.
Development
The minimum supported Rust version is 1.88. Before handing off changes, run:
Remaining work toward a stable release is tracked in ROADMAP.md.
Releasing
Prepare a release from a clean checkout:
RUNLIMIT_POSTGRES_TEST_DATABASE_URL=postgresql://... \
Review the release metadata and hand-written changelog entry, commit them, push
master, and wait for CI to pass on that exact commit. Then publish with:
The publish script requires local master to match origin/master. It
publishes runlimit-core, waits for crates.io to index it, publishes
runlimit-memory, waits again, and finally publishes runlimit-postgres.
After every crate is indexed, it creates and pushes the matching v0.1.0 tag.
If a publish stops partway through, resume explicitly with
RESUME_RELEASE=1 ./scripts/publish-release.sh 0.1.0; already-published crate
versions are verified before the script continues.
License
Licensed under either the Apache License, Version 2.0 or the MIT license, at your option.