runlimit-core 0.1.0

Framework-neutral rate-limit policies, keys, and decisions
Documentation

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:

[dependencies]
runlimit-core = "0.1.0"
runlimit-memory = "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:

[dependencies]
runlimit-core = { path = "../runlimit/crates/runlimit-core" }
runlimit-memory = { path = "../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 std::{env, error::Error, time::Duration};

use runlimit_core::{BatchDecision, Check, FixedWindowPolicy, KeyHasher, PolicyId, ScopeId};
use runlimit_memory::{MemoryStore, MemoryStoreConfig};

fn main() -> Result<(), Box<dyn Error>> {
    let client_policy = FixedWindowPolicy::new(
        PolicyId::new("auth.login")?,
        ScopeId::new("client")?,
        40,
        Duration::from_secs(60),
    )?;
    let identity_policy = FixedWindowPolicy::new(
        PolicyId::new("auth.login")?,
        ScopeId::new("identity")?,
        8,
        Duration::from_secs(60),
    )?;

    // Load one stable, random secret (at least 32 bytes) from secret storage.
    let secret = env::var("RUNLIMIT_KEY_SECRET")?;
    let key_hasher = KeyHasher::new(secret.as_bytes())?;

    // Address extraction and subject normalization remain application-owned.
    let client = key_hasher.hash_for(&client_policy, b"client-network:192.0.2.4");
    let identity = key_hasher.hash_for(&identity_policy, b"user@example.test");

    let config = MemoryStoreConfig::new(50_000)?.with_shard_count(64)?;
    let limiter = MemoryStore::new(config);
    let checks = [
        Check::new(&client_policy, client),
        Check::new(&identity_policy, identity),
    ];

    match limiter.check_all(&checks)? {
        BatchDecision::Allowed(decisions) => {
            assert_eq!(decisions.len(), checks.len());
            println!("request admitted");
        }
        BatchDecision::Denied { index, denial } => match denial.retry_after_seconds() {
            Some(seconds) => {
                println!("check {index} denied; retry after {seconds} seconds");
            }
            None => println!("check {index} denied; retry time unavailable"),
        },
    }

    Ok(())
}

A compile-checked copy lives in smoke/external-consumer. Check it as an independent consumer with:

cargo check \
  --manifest-path smoke/external-consumer/Cargo.toml \
  --locked \
  --target-dir target/external-consumer

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_LIMIT and exact whole-millisecond windows through runlimit_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 HTTP Retry-After value 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_keys is available regardless of key distribution.
  • Configuring multiple shards divides max_keys into 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 MemoryStore client 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:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo check \
  --manifest-path smoke/external-consumer/Cargo.toml \
  --locked \
  --target-dir target/external-consumer

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://... \
  ./scripts/prepare-release.sh 0.1.0

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:

./scripts/publish-release.sh 0.1.0

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.