writeonce/docs/examples/porch/middleware/limiter.wo
shoney.arickathil 491c2f42b4 feat(porch-store): limiter delegates all counting to the key pool
- delete all RateLimitCounter access from limiter.wo: query, increment,
  delete-then-insert, and the swallowing catch (e) nil -- the pool is now
  the only writer, so its serialization guarantee actually holds
- Limiter gains pool/limit/trust_proxy fields; before() calls pool_count
  and acts on the Verdict; make_limiter takes a pool
- key selection: req.principal first, else trust_proxy ? client_ip(req)
  : net.peer(req.conn); delete the dead req.ctx["verified_proxy"] branch
- 429 on a spent window (Retry-After, X-RateLimit-*); 503 + Retry-After
  on a caught actor trap (saturated pool), request never let through
- add Limiter.after(), registered alongside before() as both Mw and Aw
  (Cors's own shape) so the allowed path's X-RateLimit-* headers reach
  the response, not just req.ctx
- scripts/web-app-accept.sh: three new gate legs -- threshold (N pass,
  N+1th 429), SIGTERM+restart (still limited from the WAL), and N
  genuinely-parallel curl clients on one key with an exact-count assertion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a653dd0aa64711c43461126d32b4632cc8f64c7a)
2026-09-15 01:15:30 +02:00

100 lines
4.1 KiB
Text

-- porch/middleware/limiter.wo — rate limiter middleware. All counting is
-- delegated to the key pool (keypool.wo): this file never reads or writes
-- RateLimitCounter and holds no window arithmetic of its own. That is what
-- makes the pool's per-key serialization guarantee actually apply — a
-- store call here would be a second, uncoordinated writer.
-- Iteration 1 of the porch track, porch-store task 3.
use time
use http
use net
-- Limiter counts requests per key per window by calling into the shared
-- pool and acting on the Verdict it returns.
-- On limit exceeded: 429 with Retry-After and X-RateLimit-* headers.
-- On a saturated pool (the key's actor mailbox is full under load): 503
-- with Retry-After. The request is refused, never let through — a limiter
-- that stops limiting under load is worse than no limiter, since
-- saturating the pool would otherwise be the bypass.
-- Key selection: req.principal wins when non-empty. Otherwise, trust_proxy
-- false (default) keys on net.peer(req.conn), which cannot be forged;
-- trust_proxy true keys on client_ip(req) (the left-most X-Forwarded-For
-- entry) — the app author's assertion that a proxy they control overwrites
-- that header.
-- The refused/saturated paths stamp their own headers directly on the Resp
-- they return. The allowed path has no Resp yet to stamp — before() stashes
-- the numbers on req.ctx, and `after` (same shape as Cors: register the one
-- value as both Mw and Aw) copies them onto whatever response the chain
-- eventually produces.
pub class Limiter {
pool: Pool
limit: Int
window: Int -- window size in µs (e.g., 60_000_000 = 60s)
trust_proxy: Bool = false
fn before(mut req: Req) -> ?Resp {
let key = limiter_key(self, req);
let v = try pool_count(self.pool, key, self.limit, self.window) catch (e) nil;
if v == nil {
let r = Resp { status: 503, headers: {}, body: "{\"error\":\"rate limiter saturated\"}" };
set_header(r, "content-type", "application/json");
set_header(r, "retry-after", "1");
return r;
}
let limit_hdr = "${v.limit}";
let remaining = v.limit - v.count;
if remaining < 0 { remaining = 0; }
let reset_hdr = "${v.reset_at / 1000}"; -- wall-clock ms -> Unix seconds
if v.allowed == false {
let retry_sec = ((v.reset_at - time.now()) / 1000) + 1;
let r = Resp { status: 429, headers: {}, body: "{\"error\":\"rate limit exceeded\"}" };
set_header(r, "content-type", "application/json");
set_header(r, "x-ratelimit-limit", limit_hdr);
set_header(r, "x-ratelimit-remaining", "0");
set_header(r, "x-ratelimit-reset", reset_hdr);
set_header(r, "retry-after", "${retry_sec}");
return r;
}
-- Allowed: attach headers to request for after-chain to stamp on response
req.ctx["ratelimit_limit"] = limit_hdr;
req.ctx["ratelimit_remaining"] = "${remaining}";
req.ctx["ratelimit_reset"] = reset_hdr;
return nil;
}
-- Stamps the allowed-path numbers before() stashed. A refused/saturated
-- request never reaches here with anything to stamp (before() only
-- writes ctx on the allowed path), so this is a no-op for those.
fn after(req: Req, mut r: Resp) {
let limit_hdr = req.ctx["ratelimit_limit"];
let remaining_hdr = req.ctx["ratelimit_remaining"];
let reset_hdr = req.ctx["ratelimit_reset"];
if limit_hdr == nil { return; }
if remaining_hdr == nil { return; }
if reset_hdr == nil { return; }
r.headers["x-ratelimit-limit"] = limit_hdr;
r.headers["x-ratelimit-remaining"] = remaining_hdr;
r.headers["x-ratelimit-reset"] = reset_hdr;
}
}
-- Key selection: identity first, then the trust_proxy-gated peer address.
pub fn limiter_key(self: Limiter, req: Req) -> Text {
if req.principal != "" { return "principal:${req.principal}"; }
if self.trust_proxy {
return "ip:${client_ip(req)}";
}
let peer = net.peer(req.conn);
if peer != "" { return "ip:${peer}"; }
return "unknown";
}
-- Helper to build a Limiter with defaults
pub fn make_limiter(pool: Pool, limit: Int, window_sec: Int) -> Limiter {
return Limiter { pool: pool, limit: limit, window: window_sec * 1_000_000 };
}