Rate Limiting
Per-IP, sliding-window rate limiting. Implemented in ratelimiter.go and configured through the rate_limit block inside the waf directive.
Caddyfile
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. |
max_entries | positive integer | 1000000 | Upper bound on the number of distinct bucket keys tracked at once, so a flood of unique source IPs cannot grow the in-memory map without bound between cleanup sweeps. See Behaviour. |
The block is required to set requests > 0 and window > 0; otherwise the parser rejects the configuration.
Bucket key
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.
Source IP
The bucket key's IP is the resolved client IP under the trusted_proxies trust boundary — the connection peer by default, or the forwarded client when the peer is a configured trusted proxy. Behind a CDN or reverse proxy, set trusted_proxies (and optionally client_ip_header) so each client is rate-limited on its own address; without it every request through the proxy shares the proxy's IP and collapses into one bucket. See Client IP & trusted proxies.
Behaviour
- 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.- The key table is bounded by
max_entries. Once it is full, a request from a new key that is not yet tracked is allowed through without being counted (it is not rate-limited by this instance) rather than growing the map further; keys that are already tracked keep being counted normally, and a cleanup sweep frees capacity for new keys again. This is a deliberate memory bound: a flood of distinct source IPs cannot exhaust process memory, and — unlike an LRU — an attacker cannot evict and reset the counters of legitimate, already-tracked clients.
Counters and metrics
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).
Reload semantics
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.
Examples
Limit a specific endpoint
rate_limit {
requests 5
window 1m
cleanup_interval 5m
paths ^/login$
}Global limit on every request
rate_limit {
requests 1000
window 1m
cleanup_interval 5m
match_all_paths true
}Limit two API surfaces independently
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.
Pitfalls
- 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.