Package

ratelimit

Rate limiting for HTTP services: token bucket, GCRA, fixed and sliding windows, a lock-free in-memory table, a SQLite store, RateLimit headers and a handler wrapper

0.1.0 MIT db

Install

sspur add ratelimit@^0.1.0

or in sspur.toml:

ratelimit = { version = "^0.1.0" }

README

ratelimit

Rate limiting for SSPUR HTTP services. Five algorithms (token bucket, GCRA, fixed window, sliding log, sliding window counter) are pure state transitions: each takes the key's state, the time and the cost of a request, and returns a Decision and the next state, so they are deterministic and easy to test. Around them are the pieces a service needs: a lock-free in-memory table that concurrent http.serve handlers share, a SQLite store for several processes, client keys from trusted proxies (X-Forwarded-For, RFC 7239 Forwarded), the RateLimit-Policy and RateLimit fields of draft-ietf-httpapi-ratelimit-headers, the legacy X-RateLimit-* fields, Retry-After, 429 responses (optionally RFC 9457 problem+json), and handler wrappers.

Install

$ sspur add ratelimit

Example

use ratelimit.{Decision}

fn api(req: Request) -> Response
= http.text(200, "hello")

// The client: the nearest X-Forwarded-For address that isn't one of our proxies.
fn client(req: Request) -> Str
= ratelimit.ip_from_xff(req, ratelimit.private_networks()).map(ratelimit.ip_key).or("unknown")

fn show(d: Decision) -> Str
= if d.allowed then "allowed, {d.remaining} left" else "denied, retry in {d.retry_after_ms} ms"

fn main() -> Unit ! log, conc, div, time
= do
  p = ratelimit.per_second(2)
  var s = none
  for t in [0, 10, 20, 600]
    (d, next) = ratelimit.decide(p, s, t, 1)
    s := some(next)
    log("t={t}ms: {show(d)}")
  t = ratelimit.table(4096, 4)
  g = ratelimit.guard("per-ip", ratelimit.per_minute(2))
  req = Request{method: "GET", url: "/", headers: [("x-forwarded-for", "198.51.100.4, 10.0.0.3")], body: "", timeout_ms: 0, retries: 0, redirects: 0}
  for i in 0..3
    r = ratelimit.limit_memory(t, g, client, api)(req)
    log("{r.status} {r.header("ratelimit").or("")} {r.header("retry-after").or("")}")
$ sspur run main.ssp
t=0ms: allowed, 1 left
t=10ms: allowed, 0 left
t=20ms: denied, retry in 480 ms
t=600ms: allowed, 0 left
200 "per-ip";r=1;t=30
200 "per-ip";r=0;t=60
429 "per-ip";r=0;t=60 30

In a server, build the wrapper inside the handler, since handlers may not share function values (E_PAR_SHARE); the table itself (atomics) and the guard (data) are shared:

fn main() -> Unit ! net, log, conc, div, time
= do
  t = ratelimit.table(65536, 4)
  g = ratelimit.guard("per-ip", ratelimit.per_minute(60)) with style := ratelimit.BothHeaders, skip_paths := ["/healthz"]
  http.serve_with(http.server(8080), req => ratelimit.limit_memory(t, g, client, api)(req))

tests/it/main.ssp is such a server; the package's integration tests start it in both tiers and check the statuses and fields real clients see.

API

Policies

  • TokenBucket, token_bucket(rate, period_ms, burst): rate tokens per period_ms, at most burst (x/time/rate's model).
  • Gcra, gcra(limit, period_ms, burst): the generic cell rate algorithm (redis-cell); one number of state, same decisions as a token bucket.
  • Window, window(limit, window_ms): a quota per window for the window policies.
  • Policy: BucketPolicy, GcraPolicy, FixedWindow, SlidingLog, SlidingCounter.
  • per_second(n), per_minute(n), per_hour(n): token buckets with a burst of n.
  • quota(p), window_ms(p): the quota and the time over which it is granted.

Deciding

  • Decision: allowed, limit, remaining, reset_ms, retry_after_ms (0 allowed, -1 never possible).
  • State, Hit: per-key state (JSON-encodable); fresh(p, now_ms) is a new key's.
  • decide(p, state, now_ms, cost): the decision and the next state; cost 0 only reports.
  • simulate(p, requests): decisions for a sequence of (now_ms, cost) from a fresh state.
  • expires_at(p, state): when a state is the same as a fresh one (for stores to forget keys).

Token bucket reservations

  • Reservation, reserve(b, s, now_ms, cost, max_wait_ms): take tokens ahead, into debt, and wait delay_ms.
  • cancel(b, s, r, now_ms): give back a reservation that has not acted yet.
  • tokens(b, s, now_ms), wait_ms(b, s, now_ms, cost): the level, and the wait for cost tokens.

In-memory table (one process)

  • Table, table(slots, probes): atomic slots shared by concurrent handlers.
  • memory_supported(p): token buckets, GCRA and fixed windows (state fits in one word).
  • take(t, p, key, now_ms, cost), peek(t, p, key, now_ms), reset(t, key), used(t).
  • as_gcra(b): the GCRA a token bucket runs as in the table.

Each slot is a key fingerprint and a packed state, both Atomic[Int], updated by compare-and-swap, so decisions are exact under contention (the tests hammer one key from 8 tasks). A key probes probes slots from its hash, claiming an empty slot or one whose key has fully recovered. When all are busy with other keys it shares its home slot: sharing only ever denies earlier, never allows more. Size the table at about twice the keys active in one window.

SQLite store (several processes)

  • SqlStore, sql_store(db, table), valid_table_name(s): one row per key, JSON state, an expiry column.
  • sql_take, sql_peek, sql_reset, sql_purge(s, now_ms), sql_count.

Every decision is a transaction (BEGIN IMMEDIATE), so processes sharing the file never lose an update. All five policies work.

Client addresses and keys

  • Ip (V4, V6), parse_ip, ip_str (RFC 5952 text), normalize (IPv4-mapped to IPv4).
  • Cidr, parse_cidr, parse_cidrs, cidr_contains, private_networks().
  • ip_from_xff(req, trusted), ip_from_forwarded(req, trusted): the rightmost address that isn't a trusted proxy.
  • ip_from_hops(req, n): a fixed number of proxies; ip_from_header(req, name): X-Real-IP and friends.
  • Forwarded, forwarded(req): RFC 7239 elements, with quoted strings.
  • ip_key, network_key(ip, v4_bits, v6_bits) (limit a whole /64), route_key, compose_key.

Headers and responses

  • policy_header, policies_header, ratelimit_header, with_partition, valid_policy_name.
  • legacy_headers(d, now_ms): X-RateLimit-Limit, -Remaining, -Reset (Unix seconds).
  • retry_after(d), retry_after_secs(d).
  • HeaderStyle (DraftHeaders, LegacyHeaders, BothHeaders, NoHeaders), headers(...).
  • too_many(...): a 429 with Retry-After, the fields, no-store, text or problem+json.

Handler wrappers

  • Guard, guard(name, policy): cost, header style, skipped paths, methods (OPTIONS by default) and keys, problem+json, fail open or closed.
  • skipped(g, req, key), enforce(g, d, now_ms, req, next): the shared core, for limiters of your own.
  • limit_memory(t, g, key, next), limit_sql(s, g, key, next): wrap a handler.

Security notes

  • Client addresses: sspur's server does not expose the peer address, so per-IP limiting needs a proxy in front that appends to X-Forwarded-For or Forwarded. Use the rightmost-untrusted rule (ip_from_xff with your proxies' networks), never the leftmost entry, which any client can forge. An entry that isn't an address stops the walk and gives none; key such requests to a shared "unknown" bucket rather than letting them through.
  • IPv4 parts with leading zeros are rejected (some parsers read them as octal), IPv4-mapped IPv6 addresses count as their IPv4 address, and network_key lets you limit a whole IPv6 /64.
  • Limits fail closed by default: a store error answers 503 with Retry-After.
  • Arithmetic saturates or uses big integers, so no configuration or clock value traps; a clock that goes backwards never grants extra requests.

Out of scope

  • Distributed stores other than SQLite (Redis, memcached): there is no TCP client in sspur.
  • Peer-address limiting without a proxy, which needs the server to expose the peer.
  • Concurrency limits (in-flight request caps) and adaptive limits.
  • Sliding window algorithms in the in-memory table: their state doesn't fit in one atomic word.
  • Exact x/time/rate cancellation bookkeeping: cancel restores the reserved tokens, up to full.

License

MIT, see LICENSE.

API

TokenBucket type

pub type TokenBucket = {rate: Int where _ >= 1 and _ <= 1000000000, period_ms: Int where _ >= 1 and _ <= 1000000000000, burst: Int where _ >= 1 and _ <= 1000000000} derive Eq, Show

A token bucket that holds at most burst tokens and refills continuously at rate tokens per period_ms (as golang.org/x/time/rate with a limit of rate / period and a burst). A request of cost n takes n tokens. Build it with token_bucket, which checks that the arithmetic is exact.

Gcra type

pub type Gcra = {limit: Int where _ >= 1 and _ <= 1000000000, period_ms: Int where _ >= 1 and _ <= 1000000000000, burst: Int where _ >= 1 and _ <= 1000000000} derive Eq, Show

The generic cell rate algorithm (as redis-cell): limit requests per period_ms, at most burst of them at once. Its state is one number, the theoretical arrival time, kept in microseconds. It allows the same requests as a token bucket with the same numbers. Build it with gcra.

Window type

pub type Window = {limit: Int where _ >= 1 and _ <= 1000000000, window_ms: Int where _ >= 1 and _ <= 1000000000000} derive Eq, Show

A window of window_ms milliseconds in which at most limit cost units are allowed, for the fixed and sliding window algorithms.

Policy type

pub type Policy = BucketPolicy{bucket: TokenBucket} | GcraPolicy{gcra: Gcra} | FixedWindow{window: Window} | SlidingLog{window: Window} | SlidingCounter{window: Window} derive Eq, Show

The algorithm a limiter runs. FixedWindow counts per calendar window (cheap, but allows up to twice the limit across a boundary), SlidingLog keeps every request in the window (exact, memory grows with the limit), SlidingCounter weighs the previous window's count by how much of it still overlaps (Cloudflare's approximation, two numbers of state; it assumes the previous window's requests were spread evenly, rounds the weighted count up, and can allow more than the limit in a sliding window only when they clustered at its end).

Hit type

pub type Hit = {at_ms: Int, cost: Int} derive Eq, Show, Json

One allowed request in a sliding log.

State type

pub type State = BucketState{level: Int, at_ms: Int} | GcraState{tat_us: Int} | WindowState{start_ms: Int, count: Int} | LogState{hits: List[Hit]} | CounterState{start_ms: Int, prev: Int, curr: Int} derive Eq, Show, Json

The state a policy keeps per key. fresh makes the initial one; a state of another policy's shape counts as fresh, so changing a key's policy starts it over.

Decision type

pub type Decision = {allowed: Bool, limit: Int, remaining: Int, reset_ms: Int, retry_after_ms: Int} derive Eq, Show

The answer for one request. limit is the quota (the burst of a bucket), remaining what is left of it after this request, reset_ms the time until the quota is whole again, retry_after_ms 0 when allowed, the wait before a request of the same cost can succeed when denied, or -1 when the cost is larger than the quota and can never succeed.

token_bucket fn

fn token_bucket(rate: Int where _ >= 1 and _ <= 1000000000, period_ms: Int where _ >= 1 and _ <= 1000000000000, burst: Int where _ >= 1 and _ <= 1000000000) -> TokenBucket

A token bucket after the x/time/rate convention: rate tokens per period_ms, burst at most.

gcra fn

fn gcra(limit: Int where _ >= 1 and _ <= 1000000000, period_ms: Int where _ >= 1 and _ <= 1000000000000, burst: Int where _ >= 1 and _ <= 1000000000) -> Gcra

A GCRA policy: limit requests per period_ms, burst at once. The emission interval must be at least a microsecond and the burst tolerance must fit in 2^62 microseconds.

window fn

fn window(limit: Int where _ >= 1 and _ <= 1000000000, window_ms: Int where _ >= 1 and _ <= 1000000000000) -> Window

limit per window_ms, for the window policies.

per_second fn

fn per_second(n: Int where _ >= 1 and _ <= 1000000000) -> Policy

n requests per second, with a burst of n, as a token bucket.

per_minute fn

fn per_minute(n: Int where _ >= 1 and _ <= 1000000000) -> Policy

n requests per minute, with a burst of n, as a token bucket.

per_hour fn

fn per_hour(n: Int where _ >= 1 and _ <= 1000000000) -> Policy

n requests per hour, with a burst of n, as a token bucket.

quota fn

fn quota(p: Policy) -> Int

The quota of a policy: the burst of a bucket or GCRA, the limit of a window.

window_ms fn

fn window_ms(p: Policy) -> Int

The time in milliseconds over which the whole quota is granted: the window, or the time a bucket takes to refill from empty (rounded up).

fresh fn

fn fresh(p: Policy, now_ms: Int) -> State

The state of a key that has not been seen, at now_ms.

decide fn

fn decide(p: Policy, s: Opt[State], now_ms: Int, cost: Int where _ >= 0) -> (Decision, State)

Decides a request of cost units for a key in state s (none for a new key) at now_ms, and returns the decision and the key's next state. A cost of 0 only reports the current status. A clock that goes backwards never grants more: the state's own time is used instead.

simulate fn

fn simulate(p: Policy, requests: List[(Int, Int)]) -> List[Decision]

Decides a whole sequence of requests (now_ms, cost) for one key from a fresh state; handy in tests and simulations.

expires_at fn

fn expires_at(p: Policy, s: State) -> Int

The absolute time in milliseconds after which s is the same as a fresh state, so a store may forget the key.

Reservation type

pub type Reservation = {ok: Bool, cost: Int, delay_ms: Int, act_at_ms: Int} derive Eq, Show

A reservation of cost tokens: ok when it was granted, and then the request may act delay_ms later, at act_at_ms.

tokens fn

fn tokens(b: TokenBucket, s: Opt[State], now_ms: Int) -> Int

The tokens in the bucket at now_ms (negative while reservations owe tokens), rounded down.

wait_ms fn

fn wait_ms(b: TokenBucket, s: Opt[State], now_ms: Int, cost: Int where _ >= 0) -> Int

The wait in milliseconds until cost tokens are there, without taking them: 0 now, -1 if cost is more than the burst.

reserve fn

fn reserve(b: TokenBucket, s: Opt[State], now_ms: Int, cost: Int where _ >= 0, max_wait_ms: Int where _ >= 0) -> (Reservation, State)

Reserves cost tokens, letting the bucket go into debt, when they will be there within max_wait_ms; the caller waits delay_ms and then acts. Otherwise nothing is reserved. As x/time/rate's ReserveN with a deadline.

cancel fn

fn cancel(b: TokenBucket, s: State, r: Reservation, now_ms: Int) -> State

Gives back the tokens of a granted reservation that has not acted yet (its time is after now_ms), up to a full bucket. Unlike x/time/rate it does not subtract tokens later reservations took, so a cancel may restore slightly more than a perfect ledger would.

Table type

pub type Table = new Slots

A fixed-size, lock-free table for the policies memory_supported accepts, shared by the handlers of one process (they may share only atomics). Each slot is a pair of Atomic[Int]: the key's 64-bit fingerprint and its packed state, updated with compare-and-swap, so a decision is exact under any contention. A key is looked up in probes consecutive slots from the one its hash names, claiming an empty one or one whose state has fully recovered (an idle key, whose state equals a fresh one). When every probed slot is busy with other keys, the key shares its home slot with them: sharing only ever denies earlier, never allows more. Size it at about twice the number of keys active within one window; used shows the occupancy. It is opaque and has no Eq or Show, because it holds atomics.

table fn

fn table(slots: Int where _ >= 1 and _ <= 16777216, probes: Int where _ >= 1 and _ <= 64) -> Table ! conc

A table of slots slots probing probes of them per key.

memory_supported fn

fn memory_supported(p: Policy) -> Bool

Whether the in-memory table can run p: token buckets and GCRA (as GCRA) and fixed windows, whose state fits in one 64-bit word. Sliding windows need the SQLite store.

as_gcra fn

fn as_gcra(b: TokenBucket) -> Gcra

The GCRA with the same numbers as a token bucket; both allow the same requests.

take fn

fn take(t: Table, p: Policy, key: Str, now_ms: Int, cost: Int where _ >= 0) -> Decision ! conc, div

Decides a request of cost for key in the table at now_ms. Exact under concurrent calls: the update is a compare-and-swap loop on the key's slot.

peek fn

fn peek(t: Table, p: Policy, key: Str, now_ms: Int) -> Decision ! conc

The status of key without taking anything (a cost of 0).

reset fn

fn reset(t: Table, key: Str) -> Bool ! conc

Forgets key's state, as if it had never been seen: whether it was in the table.

used fn

fn used(t: Table) -> Int ! conc

How many slots hold a key.

SqlStore type

pub type SqlStore = {db: Db, table: Str} derive Eq, Show

A SQLite table of states, one row per key with its JSON state and the time it expires. Every decision is a transaction (BEGIN IMMEDIATE), so processes sharing the database file, and the handlers of one process sharing the Db, never lose an update. Works with every policy.

valid_table_name fn

fn valid_table_name(s: Str) -> Bool

Whether s can name the store's table: a letter or underscore, then letters, digits and underscores, at most 63 characters.

sql_store fn db

fn sql_store(c: Db, table: Str where valid_table_name(_)) -> SqlStore ! db, fail[DbErr]

Opens the store in c, creating the table table (and its expiry index) when missing.

sql_take fn db

fn sql_take(s: SqlStore, p: Policy, key: Str, now_ms: Int, cost: Int where _ >= 0) -> Decision ! db, fail[DbErr]

Decides a request of cost for key at now_ms in one transaction, and stores the new state when the request is allowed.

sql_peek fn db

fn sql_peek(s: SqlStore, p: Policy, key: Str, now_ms: Int) -> Decision ! db, fail[DbErr]

The status of key without taking anything; reads only.

sql_reset fn db

fn sql_reset(s: SqlStore, key: Str) -> Bool ! db, fail[DbErr]

Forgets key: whether it had a row.

sql_purge fn db

fn sql_purge(s: SqlStore, now_ms: Int) -> Int ! db, fail[DbErr]

Deletes the rows of keys whose state has fully recovered by now_ms; the number deleted. Run it now and then (each decision only touches its own key).

sql_count fn db

fn sql_count(s: SqlStore) -> Int ! db, fail[DbErr]

How many keys have a row.

Ip type

pub type Ip = V4{n: Int} | V6{hi: Int, lo: Int} derive Eq, Show

An IP address: IPv4 as a 32-bit number, IPv6 as two 64-bit halves.

Cidr type

pub type Cidr = {ip: Ip, bits: Int} derive Eq, Show

A network: an address and a prefix length.

parse_ip fn

fn parse_ip(s: Str) -> Opt[Ip]

Parses an IPv4 address in dotted decimal (no leading zeros, which some parsers read as octal) or an IPv6 address in any RFC 4291 text form, including :: and a dotted IPv4 tail. Zone ids and brackets are not accepted.

normalize fn

fn normalize(ip: Ip) -> Ip

An IPv4-mapped IPv6 address (::ffff:192.0.2.1) as the IPv4 address it maps; others unchanged.

ip_str fn

fn ip_str(ip: Ip) -> Str

The text of an address: dotted decimal, or the RFC 5952 form of IPv6 (lower case, no leading zeros, the longest run of zero groups as ::).

parse_cidr fn

fn parse_cidr(s: Str) -> Opt[Cidr]

Parses addr/bits, or a bare address as a single-address network. Host bits may be set; they are ignored when matching.

cidr_contains fn

fn cidr_contains(c: Cidr, ip: Ip) -> Bool

Whether ip is in the network c. IPv4-mapped IPv6 addresses match IPv4 networks.

parse_cidrs fn

fn parse_cidrs(xs: List[Str]) -> Opt[List[Cidr]]

Parses a list of networks, skipping none: none if any entry is not a network.

private_networks fn

fn private_networks() -> List[Cidr]

Loopback, private (RFC 1918, RFC 4193), link-local and carrier-grade NAT networks: the usual addresses of proxies inside a deployment, to pass as trusted.

ip_from_xff fn

fn ip_from_xff(req: Request, trusted: List[Cidr]) -> Opt[Ip]

The client address from X-Forwarded-For by the rightmost-untrusted rule: the nearest address that isn't one of the trusted proxies. Only meaningful behind a proxy that appends to the header; sspur's server does not expose the peer address, so the nearest hop must be a proxy.

ip_from_hops fn

fn ip_from_hops(req: Request, hops: Int where _ >= 1) -> Opt[Ip]

The client address hops entries from the right of X-Forwarded-For, for a fixed number of proxies (express's trust proxy = n); the leftmost entry when there are fewer.

ip_from_header fn

fn ip_from_header(req: Request, name: Str) -> Opt[Ip]

The address in a single-valued header such as X-Real-IP, CF-Connecting-IP or Fly-Client-IP. Trust it only when the proxy in front always overwrites it.

Forwarded type

pub type Forwarded = {for_node: Opt[Str], by_node: Opt[Str], host: Opt[Str], proto: Opt[Str]} derive Eq, Show

One element of a Forwarded header (RFC 7239): the for, by, host and proto parameters, unquoted.

forwarded fn

fn forwarded(req: Request) -> List[Forwarded]

The elements of every Forwarded header of req, in order (nearest proxy last).

ip_from_forwarded fn

fn ip_from_forwarded(req: Request, trusted: List[Cidr]) -> Opt[Ip]

The client address from Forwarded for= values by the rightmost-untrusted rule. unknown and obfuscated identifiers (_hidden) are not addresses and stop the walk.

ip_key fn

fn ip_key(ip: Ip) -> Str

The key for one address: ip: and its canonical text (IPv4-mapped addresses as IPv4).

network_key fn

fn network_key(ip: Ip, v4_bits: Int where _ >= 0 and _ <= 32, v6_bits: Int where _ >= 0 and _ <= 128) -> Str

The key for the network of an address, so that one client can't escape the limit by rotating addresses within its allocation: the first v4_bits of IPv4, v6_bits of IPv6 (often 64).

route_key fn

fn route_key(req: Request) -> Str

The key of a route: the method and the path without the query.

compose_key fn

fn compose_key(parts: List[Str]) -> Str

One key from several parts (a user, a route, an address), unambiguously: each part is prefixed with its length, so ["a|b", "c"] and ["a", "b|c"] differ.

valid_policy_name fn

fn valid_policy_name(s: Str) -> Bool

Whether s can name a policy in the RateLimit headers: 1 to 64 printable ASCII characters.

policy_header fn

fn policy_header(name: Str where valid_policy_name(_), p: Policy) -> (Str, Str)

The RateLimit-Policy field of draft-ietf-httpapi-ratelimit-headers for one policy: its name, q the quota and w the window in seconds.

policies_header fn

fn policies_header(ps: List[(Str, Policy)]) -> (Str, Str)

The RateLimit-Policy field listing several policies that apply to a request.

ratelimit_header fn

fn ratelimit_header(name: Str where valid_policy_name(_), d: Decision) -> (Str, Str)

The RateLimit field for a decision under the policy name: r the remaining quota and t the seconds until it resets.

with_partition fn

fn with_partition(h: (Str, Str), key: Str) -> (Str, Str)

Adds the partition key parameter pk (an sf-binary of the key's bytes) to a RateLimit or RateLimit-Policy field, for clients that share a quota with others.

legacy_headers fn

fn legacy_headers(d: Decision, now_ms: Int) -> List[(Str, Str)]

The legacy fields many clients read (GitHub, express-rate-limit): X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, the last as Unix seconds of the reset.

retry_after_secs fn

fn retry_after_secs(d: Decision) -> Int

The seconds a denied client should wait, rounded up and at least 1; 0 when allowed or when the request can never succeed.

retry_after fn

fn retry_after(d: Decision) -> Opt[(Str, Str)]

The Retry-After field (RFC 9110 10.2.3, delay-seconds) of a denied decision.

HeaderStyle type

pub type HeaderStyle = DraftHeaders | LegacyHeaders | BothHeaders | NoHeaders derive Eq, Show

Which rate limit fields a response carries.

headers fn

fn headers(style: HeaderStyle, name: Str where valid_policy_name(_), p: Policy, d: Decision, now_ms: Int) -> List[(Str, Str)]

The fields for a decision in a style: the draft's RateLimit-Policy and RateLimit, the legacy X-RateLimit fields, both, or none; Retry-After is added by too_many.

too_many fn

fn too_many(style: HeaderStyle, name: Str where valid_policy_name(_), p: Policy, d: Decision, now_ms: Int, problem: Bool) -> Response

A 429 Too Many Requests response (RFC 6585) with Retry-After, the rate limit fields of style, cache-control: no-store, and a text body, or with problem an RFC 9457 application/problem+json body.

Guard type

pub type Guard = {name: Str where valid_policy_name(_), policy: Policy, cost: Int where _ >= 1, style: HeaderStyle, skip_paths: List[Str], skip_methods: List[Str], allow_keys: List[Str], problem: Bool, fail_open: Bool} derive Eq, Show

How a wrapper limits requests: the policy and its name in the headers, the cost of a request, the header style, requests that are not limited (paths starting with one of skip_paths, methods in skip_methods, keys in allow_keys), whether 429s are problem+json, and whether a store failure lets the request through (fail_open) or answers 503.

guard fn

fn guard(name: Str where valid_policy_name(_), p: Policy) -> Guard

A guard with the defaults: cost 1, draft headers, CORS preflights (OPTIONS) not limited, text 429s, and fail closed.

skipped fn

fn skipped(g: Guard, req: Request, key: Str) -> Bool

Whether g leaves the request with key key alone.

enforce fn

fn enforce[e](g: Guard, d: Decision, now_ms: Int, req: Request, next: Request -> Response ! e) -> Response ! e

Applies a decision: an allowed request goes to next and its response gets the rate limit fields; a denied one gets too_many. This is the core the wrappers share, for limiters of your own.

limit_memory fn

fn limit_memory[e](t: Table, g: Guard, key: Request -> Str, next: Request -> Response ! e) -> Request -> Response ! e, conc, div, time

Wraps next with a limit kept in the in-memory table t, per key that key gives for a request. Build it inside the handler (req => ratelimit.limit_memory(t, g, ratelimit.route_key, api)(req)): handlers may not share function values.

limit_sql fn

fn limit_sql[e](s: SqlStore, g: Guard, key: Request -> Str, next: Request -> Response ! e) -> Request -> Response ! e, db, time

Wraps next with a limit kept in the SQLite store s. A database error answers 503, or with fail_open passes the request through unlimited.

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-16711e3355cbsnapshotdb