writeonce/docs/examples/porch/middleware/limiter.wo
shoney.arickathil e159b5a754 feat(porch-store): Limiter middleware (Phase B)
(cherry picked from commit 5b1e82ab4bf3bad39bb6762a52b2dfaafd18a110)
2026-09-15 01:15:30 +02:00

96 lines
No EOL
3.4 KiB
Text

-- porch/middleware/limiter.wo — rate limiter middleware backed by @table.
-- Fixed-window counter with hybrid key (net.peer default, client_ip when verified proxy).
-- Iteration 1 of the porch track.
use time
use http
use net
-- Limiter counts requests per key per window.
-- On limit exceeded: returns 429 with Retry-After and X-RateLimit headers.
-- Key selection: if req.ctx["verified_proxy"] == "true" → client_ip(req) (XFF left-most),
-- else → net.peer(req.conn) (unforgeable "ip:port" or "unix").
-- Falls back to "principal:<req.principal>" when authenticated.
pub class Limiter {
max: Int
window: Int -- window size in µs (e.g., 60_000_000 = 60s)
fn before(mut req: Req) -> ?Resp {
let key = limiter_key(req);
let now = time.ticks();
-- Read existing counter
let hits = from c in RateLimitCounter where c.key == key take 1 select c;
let counter = RateLimitCounter { key: key, count: 0, window: now };
let is_new_window = false;
if len(hits) == 0 {
is_new_window = true;
} else {
counter = hits[0];
-- Check if window has elapsed
if now - counter.window > self.window {
counter.count = 0;
counter.window = now;
is_new_window = true;
}
}
-- Increment and check limit
counter.count = counter.count + 1;
-- Headers for both allowed and limited responses
let limit_hdr = "${self.max}";
let remaining = self.max - counter.count;
if remaining < 0 { remaining = 0; }
let remaining_hdr = "${remaining}";
let reset_sec = (counter.window + self.window) / 1_000_000;
let reset_hdr = "${reset_sec}";
-- Store the counter (insert new or delete+insert for update)
if is_new_window {
try insert RateLimitCounter { key: counter.key, count: counter.count, window: counter.window } catch (e) nil;
} else {
-- Update existing: delete old, insert new
let to_delete = from c in RateLimitCounter where c.key == key take 1 select c;
if len(to_delete) > 0 { delete to_delete[0]; }
try insert RateLimitCounter { key: counter.key, count: counter.count, window: counter.window } catch (e) nil;
}
-- Check limit
if counter.count > self.max {
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", "${((counter.window + self.window - now) / 1_000_000) + 1}");
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_hdr;
req.ctx["ratelimit_reset"] = reset_hdr;
return nil;
}
}
-- Default hybrid key function: net.peer unless verified proxy
pub fn limiter_key(req: Req) -> Text {
if req.ctx["verified_proxy"] == "true" {
let ip = client_ip(req);
if ip != "" { return "ip:${ip}"; }
}
-- net.Conn is a scalar (fd), so req.conn should work directly as the fd argument
let peer = net.peer(req.conn);
if peer != "" { return "ip:${peer}"; }
if req.principal != "" { return "principal:${req.principal}"; }
return "unknown";
}
-- Helper to build a Limiter with defaults
pub fn make_limiter(max: Int, window_sec: Int) -> Limiter {
return Limiter { max: max, window: window_sec * 1_000_000 };
}