Per-client request rate limits over a minute, an hour and a day #48

Merged
clawbot merged 1 commits from issue-43-rate-limits into next 2026-10-04 04:24:35 +02:00
Collaborator

Part 1 of milestone 2 (#14): the per-client request rate limits of #43.

  • internal/ratelimit counts each client's requests over a minute, an hour and a day as the "Counting method" section of SPEC.md describes, keeping at most 20,000 clients, in memory only, least recently seen dropped first.

  • The proxy's check counts every request, refused ones included, against its client: one IPv4 address, or one IPv6 /64. A request over a limit gets 429 before anything reaches the app; its log line has the action rate_limited, and limit_hit names the window.

  • SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and SWWAF_RATE_LIMIT_PER_DAY (1000, 10000, 50000) each take a whole number or off; any other value stops the start with a message naming the setting.

  • Deviation from SPEC.md, as the issue decides: the 20,000-client bound and the /64 are fixed, not settings.

  • Judgement call: the rate limits come before the check of a body's announced size, so a request refused with 413 still counts.

  • Judgement call: a request dated over a second before its window's bucket under way means the clock was set back, and that window starts afresh; within a second, it is a concurrent request counted late, and goes in that bucket.

  • Judgement call: github.com/hashicorp/golang-lru/v2 holds the clients, not github.com/go-chi/httprate, which does not count refused requests and keeps any number of clients.

  • Judgement call: a request over several limits has limit_hit naming the shortest window.

  • Deviation: go.mod and go.sum were written by hand, as no make target runs go mod tidy.

Model: opus-5-5

Part 1 of milestone 2 (https://git.eeqj.de/sneak/smallwebwaf/issues/14): the per-client request rate limits of https://git.eeqj.de/sneak/smallwebwaf/issues/43. - `internal/ratelimit` counts each client's requests over a minute, an hour and a day as the "Counting method" section of `SPEC.md` describes, keeping at most 20,000 clients, in memory only, least recently seen dropped first. - The proxy's `check` counts every request, refused ones included, against its client: one IPv4 address, or one IPv6 /64. A request over a limit gets `429` before anything reaches the app; its log line has the `action` `rate_limited`, and `limit_hit` names the window. - `SWWAF_RATE_LIMIT_PER_MINUTE`, `SWWAF_RATE_LIMIT_PER_HOUR` and `SWWAF_RATE_LIMIT_PER_DAY` (`1000`, `10000`, `50000`) each take a whole number or `off`; any other value stops the start with a message naming the setting. - Deviation from `SPEC.md`, as the issue decides: the 20,000-client bound and the /64 are fixed, not settings. - Judgement call: the rate limits come before the check of a body's announced size, so a request refused with `413` still counts. - Judgement call: a request dated over a second before its window's bucket under way means the clock was set back, and that window starts afresh; within a second, it is a concurrent request counted late, and goes in that bucket. - Judgement call: `github.com/hashicorp/golang-lru/v2` holds the clients, not `github.com/go-chi/httprate`, which does not count refused requests and keeps any number of clients. - Judgement call: a request over several limits has `limit_hit` naming the shortest window. - Deviation: `go.mod` and `go.sum` were written by hand, as no `make` target runs `go mod tidy`. Model: opus-5-5
clawbot added the needs-review label 2026-10-04 03:24:08 +02:00
clawbot self-assigned this 2026-10-04 03:24:08 +02:00
Author
Collaborator

Review failed: needs rework.

  1. internal/ratelimit/ratelimit.go, buckets.add: if the wall clock steps back, every window stops moving. A request dated before the current bucket goes into that bucket, and the previous bucket keeps its full weight until the clock catches up. After a one-hour step back, a client sending well under its limit is refused for most of that hour. Acceptable: a step back never holds a window still, for example by counting from a clock that cannot go back, or by starting a client's buckets afresh when a request is dated well before the current bucket. Add a test that steps the clock back.
  2. internal/proxy/ratelimits_test.go: 2001:db8::1 and 2001:db8::ffff:2 are also in the same /96, so the test still passes with clients grouped by any prefix from /64 to /96. It does not show that one /64 is one client. Acceptable: the second address differs from the first in the top bits of the last 64, for example 2001:db8::8000:0:0:1.
  3. README.md, Design, internal/proxy: the sentence on check lists the rate limits and the country lists as its refusals and leaves out the refusal of an announced body over the size limit, which check also makes. Acceptable: the sentence names all three.
  4. The commit message body is about 150 words, over the limit of about 120. Acceptable: trim it to about 120 words or fewer.

Judgement calls accepted as disclosed: off for the three settings, a 413 request counting, limit_hit naming the shortest window, golang-lru/v2 instead of httprate, and the hand-written go.mod and go.sum.

Model: opus-5-5

Review failed: needs rework. 1. `internal/ratelimit/ratelimit.go`, `buckets.add`: if the wall clock steps back, every window stops moving. A request dated before the current bucket goes into that bucket, and the previous bucket keeps its full weight until the clock catches up. After a one-hour step back, a client sending well under its limit is refused for most of that hour. Acceptable: a step back never holds a window still, for example by counting from a clock that cannot go back, or by starting a client's buckets afresh when a request is dated well before the current bucket. Add a test that steps the clock back. 2. `internal/proxy/ratelimits_test.go`: `2001:db8::1` and `2001:db8::ffff:2` are also in the same /96, so the test still passes with clients grouped by any prefix from /64 to /96. It does not show that one /64 is one client. Acceptable: the second address differs from the first in the top bits of the last 64, for example `2001:db8::8000:0:0:1`. 3. `README.md`, Design, `internal/proxy`: the sentence on `check` lists the rate limits and the country lists as its refusals and leaves out the refusal of an announced body over the size limit, which `check` also makes. Acceptable: the sentence names all three. 4. The commit message body is about 150 words, over the limit of about 120. Acceptable: trim it to about 120 words or fewer. Judgement calls accepted as disclosed: `off` for the three settings, a `413` request counting, `limit_hit` naming the shortest window, `golang-lru/v2` instead of `httprate`, and the hand-written `go.mod` and `go.sum`. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 03:34:52 +02:00
clawbot force-pushed issue-43-rate-limits from a2aba8f48a to 808245e7d5 2026-10-04 03:43:02 +02:00 Compare
Author
Collaborator

Rework of #48 (comment):

  1. A request dated more than a second before its window's bucket under way now starts that window afresh; a new test sets the clock back an hour, another counts a request a moment late.
  2. The second IPv6 address is now 2001:db8::8000:0:0:1: same /64, different /65.
  3. The README.md sentence on check now names all three refusals.
  4. The commit body is down to about 120 words.

Model: opus-5-5

Rework of https://git.eeqj.de/sneak/smallwebwaf/pulls/48#issuecomment-119073: 1. A request dated more than a second before its window's bucket under way now starts that window afresh; a new test sets the clock back an hour, another counts a request a moment late. 2. The second IPv6 address is now `2001:db8::8000:0:0:1`: same /64, different /65. 3. The `README.md` sentence on `check` now names all three refusals. 4. The commit body is down to about 120 words. Model: opus-5-5
clawbot added needs-review and removed needs-rework labels 2026-10-04 03:44:00 +02:00
Author
Collaborator

Review failed: needs rework.

  1. internal/ratelimit/ratelimit_test.go: nothing tests a client that comes back after a whole bucket or more without a request, the case in which buckets.add drops the earlier counts (the branch that sets previous to 0). Were that branch wrong, a client refused for its day limit on Monday would be refused again for most of Wednesday, and no test would fail. Acceptable: a test in which a client over a limit sends nothing for more than one whole bucket and is then within the limit at once.
  2. README.md, the paragraph that begins "Two limits are fixed rather than settings": the 20,000-client bound and the IPv6 /64 are now fixed too, though SPEC.md makes both settings (SWWAF_MAX_TRACKED_CLIENTS, SWWAF_IPV6_GROUP_PREFIX). The paragraph still names only two. Acceptable: it names all four.

Judgement calls accepted: the clock rule (a request dated over a second before its window's bucket under way starts that window afresh), off for the three settings, a 413 request counting, limit_hit naming the shortest window, golang-lru/v2 instead of httprate, and the hand-written go.mod and go.sum.

Model: opus-5-5

Review failed: needs rework. 1. `internal/ratelimit/ratelimit_test.go`: nothing tests a client that comes back after a whole bucket or more without a request, the case in which `buckets.add` drops the earlier counts (the branch that sets `previous` to 0). Were that branch wrong, a client refused for its day limit on Monday would be refused again for most of Wednesday, and no test would fail. Acceptable: a test in which a client over a limit sends nothing for more than one whole bucket and is then within the limit at once. 2. `README.md`, the paragraph that begins "Two limits are fixed rather than settings": the 20,000-client bound and the IPv6 /64 are now fixed too, though `SPEC.md` makes both settings (`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_IPV6_GROUP_PREFIX`). The paragraph still names only two. Acceptable: it names all four. Judgement calls accepted: the clock rule (a request dated over a second before its window's bucket under way starts that window afresh), `off` for the three settings, a `413` request counting, `limit_hit` naming the shortest window, `golang-lru/v2` instead of `httprate`, and the hand-written `go.mod` and `go.sum`. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 03:53:20 +02:00
clawbot force-pushed issue-43-rate-limits from 808245e7d5 to 669524cf8e 2026-10-04 03:58:03 +02:00 Compare
Author
Collaborator
  1. Added TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce: a client over the hour limit sends nothing for the whole next bucket and is within the limit a quarter into the one after.
  2. The paragraph now begins "Four limits are fixed" and adds the 20,000-client bound and the IPv6 /64.

Model: opus-5-5

1. Added `TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce`: a client over the hour limit sends nothing for the whole next bucket and is within the limit a quarter into the one after. 2. The paragraph now begins "Four limits are fixed" and adds the 20,000-client bound and the IPv6 /64. Model: opus-5-5
clawbot added needs-review and removed needs-rework labels 2026-10-04 03:58:18 +02:00
Author
Collaborator

Review failed: needs rework.

  1. internal/proxy/client.go, clientGroup: an IPv4-mapped address such as ::ffff:203.0.113.10 counts as IPv6 there, so it would be grouped by its /64, ::/64, together with every other such address. It is right today only because clientAddress unmaps the address first, and no test sends a mapped address: with that Unmap dropped, every test still passes while every IPv4 client a proxy names in mapped form shares one set of rate limits. Acceptable: clientGroup unmaps the address before grouping it, and a test shows ::ffff:203.0.113.10 in X-Forwarded-For counted as the same client as 203.0.113.10.

Judgement calls accepted: the clock rule (a request dated over a second before its window's bucket under way starts that window afresh), off for the three settings, a 413 request counting, limit_hit naming the shortest window, golang-lru/v2 instead of httprate, and the hand-written go.mod and go.sum.

Model: opus-5-5

Review failed: needs rework. 1. `internal/proxy/client.go`, `clientGroup`: an IPv4-mapped address such as `::ffff:203.0.113.10` counts as IPv6 there, so it would be grouped by its /64, `::/64`, together with every other such address. It is right today only because `clientAddress` unmaps the address first, and no test sends a mapped address: with that `Unmap` dropped, every test still passes while every IPv4 client a proxy names in mapped form shares one set of rate limits. Acceptable: `clientGroup` unmaps the address before grouping it, and a test shows `::ffff:203.0.113.10` in `X-Forwarded-For` counted as the same client as `203.0.113.10`. Judgement calls accepted: the clock rule (a request dated over a second before its window's bucket under way starts that window afresh), `off` for the three settings, a `413` request counting, `limit_hit` naming the shortest window, `golang-lru/v2` instead of `httprate`, and the hand-written `go.mod` and `go.sum`. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 04:06:35 +02:00
clawbot added 1 commit 2026-10-04 04:14:58 +02:00
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
clawbot force-pushed issue-43-rate-limits from 669524cf8e to 9501aad890 2026-10-04 04:14:58 +02:00 Compare
Author
Collaborator

Review #48 (comment), finding 1: clientGroup now unmaps the address itself, and the rate-limit test sends ::ffff:203.0.113.10 after 203.0.113.10 and expects it refused as the same client. The PR body no longer calls the order of the rate-limit and size checks invisible in the diff.

Model: opus-5-5

Review https://git.eeqj.de/sneak/smallwebwaf/pulls/48#issuecomment-119306, finding 1: `clientGroup` now unmaps the address itself, and the rate-limit test sends `::ffff:203.0.113.10` after `203.0.113.10` and expects it refused as the same client. The PR body no longer calls the order of the rate-limit and size checks invisible in the diff. Model: opus-5-5
clawbot added needs-review and removed needs-rework labels 2026-10-04 04:15:16 +02:00
Author
Collaborator

Review passed.

  • Accepted: the 20,000-client bound and the IPv6 /64 fixed rather than settings.
  • Accepted: the clock rule (a request dated more than a second before its window's bucket under way starts that window afresh).
  • Accepted: off for the three settings.
  • Accepted: a request refused with 413 still counts.
  • Accepted: limit_hit names the shortest window.
  • Accepted: golang-lru/v2 instead of httprate.
  • Accepted: the hand-written go.mod and go.sum.

Model: opus-5-5

Review passed. - Accepted: the 20,000-client bound and the IPv6 /64 fixed rather than settings. - Accepted: the clock rule (a request dated more than a second before its window's bucket under way starts that window afresh). - Accepted: `off` for the three settings. - Accepted: a request refused with `413` still counts. - Accepted: `limit_hit` names the shortest window. - Accepted: `golang-lru/v2` instead of `httprate`. - Accepted: the hand-written `go.mod` and `go.sum`. Model: opus-5-5
clawbot merged commit f51459fbfe into next 2026-10-04 04:24:35 +02:00
clawbot deleted branch issue-43-rate-limits 2026-10-04 04:24:35 +02:00
clawbot removed the needs-review label 2026-10-04 04:24:36 +02:00
Sign in to join this conversation.