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
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.
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.
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.
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).
pub type Hit = {at_ms: Int, cost: Int} derive Eq, Show, Json
One allowed request in a sliding log.
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.
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.
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.
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.
fn window(limit: Int where _ >= 1 and _ <= 1000000000, window_ms: Int where _ >= 1 and _ <= 1000000000000) -> Window
limit per window_ms, for the window policies.
fn per_second(n: Int where _ >= 1 and _ <= 1000000000) -> Policy
n requests per second, with a burst of n, as a token bucket.
fn per_minute(n: Int where _ >= 1 and _ <= 1000000000) -> Policy
n requests per minute, with a burst of n, as a token bucket.
fn per_hour(n: Int where _ >= 1 and _ <= 1000000000) -> Policy
n requests per hour, with a burst of n, as a token bucket.
fn quota(p: Policy) -> Int
The quota of a policy: the burst of a bucket or GCRA, the limit of a window.
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).
fn fresh(p: Policy, now_ms: Int) -> State
The state of a key that has not been seen, at now_ms.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
fn as_gcra(b: TokenBucket) -> Gcra
The GCRA with the same numbers as a token bucket; both allow the same requests.
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.
fn peek(t: Table, p: Policy, key: Str, now_ms: Int) -> Decision ! conc
The status of key without taking anything (a cost of 0).
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.
fn used(t: Table) -> Int ! conc
How many slots hold a key.
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.
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.
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.
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.
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.
fn sql_reset(s: SqlStore, key: Str) -> Bool ! db, fail[DbErr]
Forgets key: whether it had a row.
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).
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.
pub type Cidr = {ip: Ip, bits: Int} derive Eq, Show
A network: an address and a prefix length.
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.
fn normalize(ip: Ip) -> Ip
An IPv4-mapped IPv6 address (::ffff:192.0.2.1) as the IPv4 address it maps; others unchanged.
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 ::).
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.
fn cidr_contains(c: Cidr, ip: Ip) -> Bool
Whether ip is in the network c. IPv4-mapped IPv6 addresses match IPv4 networks.
fn parse_cidrs(xs: List[Str]) -> Opt[List[Cidr]]
Parses a list of networks, skipping none: none if any entry is not a network.
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.
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.
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.
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.
fn forwarded(req: Request) -> List[Forwarded]
The elements of every Forwarded header of req, in order (nearest proxy last).
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.
fn ip_key(ip: Ip) -> Str
The key for one address: ip: and its canonical text (IPv4-mapped addresses as IPv4).
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).
fn route_key(req: Request) -> Str
The key of a route: the method and the path without the query.
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.
fn valid_policy_name(s: Str) -> Bool
Whether s can name a policy in the RateLimit headers: 1 to 64 printable ASCII characters.
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.
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.
fn retry_after(d: Decision) -> Opt[(Str, Str)]
The Retry-After field (RFC 9110 10.2.3, delay-seconds) of a denied decision.
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.
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.
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.
fn skipped(g: Guard, req: Request, key: Str) -> Bool
Whether g leaves the request with key key alone.
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.
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.
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.