Compare commits

1 Commits
Author SHA1 Message Date
clawbot 808245e7d5 Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 2m27s
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
2026-10-04 01:42:52 +00:00
4 changed files with 55 additions and 5 deletions
+3 -2
View File
@@ -412,8 +412,9 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later.
the checks, passes the request to the app and the answer back with the the checks, passes the request to the app and the answer back with the
standard library's `httputil.ReverseProxy` within the timeouts and size standard library's `httputil.ReverseProxy` within the timeouts and size
limits, and writes the request's log line. Its `check` method is where a limits, and writes the request's log line. Its `check` method is where a
request is refused before anything reaches the app: for a rate limit, and, request is refused before anything reaches the app: for a rate limit, for an
with the rest of milestone 2, for the country lists. announced body over the size limit, and, with the rest of milestone 2, for the
country lists.
- `internal/ratelimit`: counts each client's requests and tells when one takes - `internal/ratelimit`: counts each client's requests and tells when one takes
it over a rate limit. it over a rate limit.
- `internal/requestlog`: the lines on stdout: the request log line and the - `internal/requestlog`: the lines on stdout: the request log line and the
+1 -1
View File
@@ -31,7 +31,7 @@ func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
{client, http.StatusTooManyRequests}, {client, http.StatusTooManyRequests},
{"203.0.113.10", http.StatusOK}, {"203.0.113.10", http.StatusOK},
{"2001:db8::1", http.StatusOK}, {"2001:db8::1", http.StatusOK},
{"2001:db8::ffff:2", http.StatusTooManyRequests}, {"2001:db8::8000:0:0:1", http.StatusTooManyRequests},
{"2001:db8:0:1::1", http.StatusOK}, {"2001:db8:0:1::1", http.StatusOK},
} }
+9 -2
View File
@@ -101,9 +101,16 @@ type buckets struct {
// under way, and those in the bucket before it weighted by how much of // under way, and those in the bucket before it weighted by how much of
// that bucket the window still covers. // that bucket the window still covers.
// //
// Concurrent requests can be counted out of order, so now can be before // Concurrent requests can be counted out of order, so now can be a moment
// the bucket under way began; such a request is counted in that bucket. // before the bucket under way began; such a request is counted in that
// bucket. A request dated more than a second before it means the clock
// was set back, and the buckets start afresh: otherwise the bucket before
// would keep its full weight until the clock caught up.
func (b *buckets) add(now time.Time, length time.Duration) float64 { func (b *buckets) add(now time.Time, length time.Duration) float64 {
if now.Before(b.start.Add(-time.Second)) {
*b = buckets{}
}
start := now.Truncate(length) start := now.Truncate(length)
if start.After(b.start) { if start.After(b.start) {
if start.Equal(b.start.Add(length)) { if start.Equal(b.start.Add(length)) {
+42
View File
@@ -87,6 +87,48 @@ func TestRefusedRequestsCount(t *testing.T) {
wantCount(t, limiter, within, later, "") wantCount(t, limiter, within, later, "")
} }
func TestRequestCountedLateGoesInTheBucketUnderWay(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit})
client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight()
for range limit {
wantCount(t, limiter, client, start, "")
}
// A concurrent request dated a moment before the bucket under way, but
// counted after it began, is counted in it: 3 + 1 is over the limit.
wantCount(t, limiter, client, start.Add(-time.Millisecond), minute)
}
func TestClockSetBackStartsTheBucketsAfresh(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit})
client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight()
for range limit {
wantCount(t, limiter, client, start, "")
}
// Half an hour into the next bucket: 3 / 2 + 1 is within the limit.
wantCount(t, limiter, client, start.Add(time.Hour+time.Hour/2), "")
// The clock is set back an hour. Counted in the bucket under way, the
// next request would find the bucket before it at full weight, 3 + 2,
// over the limit until the clock caught up. The buckets start afresh
// instead, and the client is refused only past the limit again.
setBack := start.Add(time.Hour / 2)
for range limit {
wantCount(t, limiter, client, setBack, "")
}
wantCount(t, limiter, client, setBack, hour)
}
func TestKeepsAtMost20000ClientsDroppingTheLeastRecentlySeen(t *testing.T) { func TestKeepsAtMost20000ClientsDroppingTheLeastRecentlySeen(t *testing.T) {
t.Parallel() t.Parallel()