96 lines
No EOL
3.4 KiB
Text
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 };
|
|
} |