Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 3m31s

Each client, one IPv4 address or one IPv6 /64, is counted in two buckets
per window, the earlier weighted by how much of it the window covers; at
most 20,000 clients are kept, least recently seen dropped first. A
request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000,
50000, or off) gets 429 before reaching the app. Refused requests count,
413s included. A clock set back over a second behind a bucket's start
restarts that window. The log line gains limit_hit and the action
rate_limited.

Deviation from SPEC.md, per the issue: the 20,000 bound and /64 are fixed.
Judgement call: golang-lru/v2 holds the table; httprate does not count refused requests.
Deviation: go.mod and go.sum hand-written; no make target tidies them.

Model: opus-5-5
This commit is contained in:
2026-10-04 02:14:51 +00:00
committed by sneak
parent bedd324f3c
commit 9501aad890
15 changed files with 587 additions and 32 deletions
+15 -3
View File
@@ -38,6 +38,7 @@ type request struct {
body *requestBody // nil for a request without a body
line requestlog.Line
client netip.Addr
peer netip.Addr
peerTrusted bool
start time.Time
@@ -75,6 +76,7 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
client: client,
peer: peer,
peerTrusted: isInside(peer, trusted),
start: start,
@@ -100,10 +102,20 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
}
// check is the one place where a request can be refused once its client
// is known, before its body is read or anything reaches the app; the rate
// limits and country lists of milestone 2 go here. It returns nil to let
// the request through.
// is known, before its body is read or anything reaches the app. It
// returns nil to let the request through. The rate limits come first, so
// that every request is counted, one refused for its size too.
func (rq *request) check() *refusal {
limitHit := rq.h.limiter.Count(clientGroup(rq.client), rq.start)
if limitHit != "" {
rq.line.LimitHit = limitHit
return &refusal{
status: http.StatusTooManyRequests,
action: requestlog.ActionRateLimited,
}
}
maxBytes := rq.h.config.RequestMaxBytes
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
return &refusal{