Per-IP, sliding-window rate limiting. Implemented in ratelimiter.go and configured through the rate_limit block inside the waf directive.
waf {
rate_limit {
requests 100
window 10s
cleanup_interval 5m
paths /api/v1/.* /admin/.*
match_all_paths false
}
}| Sub-directive | Type | Default | Description |
|---|---|---|---|
requests |
positive integer | 100 |
Maximum requests permitted per window, per bucket key. |
window |
Go duration (10s, 1m, 1h) |
10s |
Width of the sliding window. |
cleanup_interval |
Go duration | 300s (5 min) |
How often the cleanup goroutine sweeps expired entries from the in-memory map. |
paths |
one or more regex patterns | empty | When non-empty (and match_all_paths=false), only requests whose path matches one of these regex patterns are subject to the limit. |
match_all_paths |
true / false |
false |
When true, the limiter applies to every request regardless of paths. |
The block is required to set requests > 0 and window > 0; otherwise the parser rejects the configuration.
The bucket key — the value used to count requests against the limit — depends on configuration:
match_all_paths |
paths |
Bucket key | Effect |
|---|---|---|---|
true |
(any) | ip |
Every request is counted; one global counter per IP. |
false |
empty | none | The limiter is a no-op. Requests bypass it without being counted. |
false |
non-empty | ip + path (when path matches a regex) |
Each (IP, path) pair has its own counter; non-matching paths bypass the limiter. |
Note: the bucket key uses the exact request path string concatenated with the IP, not the regex pattern.
/api/v1/usersand/api/v1/ordersare tracked as separate buckets even when both match the samepathsregex.
The rate limiter uses the host portion of r.RemoteAddr (parsed by net.SplitHostPort). It does not consult X-Forwarded-For. If your deployment is behind a trusted reverse proxy that forwards the original client IP only via that header, place the WAF behind a Caddy upstream that rewrites r.RemoteAddr, or accept that all traffic shares the proxy's IP for rate-limit purposes.
- Each request is checked under a write lock (
rl.Lock()); the path-regex matching happens before the lock to keep the critical section short. - When the bucket counter exceeds
requestswithin the active window, the request is blocked with HTTP429 Too Many Requestsand the per-rate-limiterblockedRequestscounter is incremented. - When the active window has expired, the bucket is reset (
count=1, newwindow=now). cleanup_intervalticks a background goroutine that walks the map and deletes buckets whose window is older thanwindow.
Metric (in /waf_metrics) |
Source |
|---|---|
rate_limiter_requests |
Total requests that passed through the limiter (counted under lock; includes both blocked and allowed). |
rate_limiter_blocked_requests |
Requests blocked because the bucket counter exceeded requests. |
When the limiter is configured with match_all_paths=false and a non-empty paths, only path-matching requests are counted in rate_limiter_requests; non-matching paths are counted in the metric increment that runs immediately before the early return (see isRateLimited in ratelimiter.go).
The rate limiter is built during Provision. Subsequent file-watcher reloads of rules.json and the blacklists do not rebuild it; changing requests, window, cleanup_interval, paths, or match_all_paths requires a full caddy reload.
On shutdown, signalStopCleanup closes the cleanup channel, terminating the cleanup goroutine cleanly.
rate_limit {
requests 5
window 1m
cleanup_interval 5m
paths ^/login$
}rate_limit {
requests 1000
window 1m
cleanup_interval 5m
match_all_paths true
}rate_limit {
requests 100
window 10s
cleanup_interval 5m
paths ^/api/v1/.* ^/admin/.*
}Note: only one rate_limit block is permitted per waf directive (a second block returns rate_limit directive already specified). For multiple independent limits, use multiple waf blocks on different routes.
- No
paths, nomatch_all_paths: the limiter accepts every request without counting. This is rarely the intent. - Path regex misses the leading
/: regex patterns are matched againstr.URL.Path, which always starts with/. Anchor with^/. - Tight
windowon noisy clients: legitimate burstiness (page loads pulling many assets) can trip a low limit on the asset path. Either widen the window or scopepathsto the resources that need protection.