diff --git a/docs/examples/porch/middleware/limiter.wo b/docs/examples/porch/middleware/limiter.wo new file mode 100644 index 0000000..9271965 --- /dev/null +++ b/docs/examples/porch/middleware/limiter.wo @@ -0,0 +1,96 @@ +-- 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:" 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 }; +} \ No newline at end of file