feat(porch-store): Limiter middleware (Phase B)
(cherry picked from commit 5b1e82ab4bf3bad39bb6762a52b2dfaafd18a110)
This commit is contained in:
parent
192d451f5e
commit
e159b5a754
1 changed files with 96 additions and 0 deletions
96
docs/examples/porch/middleware/limiter.wo
Normal file
96
docs/examples/porch/middleware/limiter.wo
Normal file
|
|
@ -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:<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 };
|
||||
}
|
||||
Loading…
Reference in a new issue