Skip to content

Commit

Permalink
feat: add support matchers in Caddyfile format
Browse files Browse the repository at this point in the history
  • Loading branch information
dunglas committed Apr 25, 2024
1 parent 359636d commit c0d1c6a
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 4 deletions.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ This module implements both internal and distributed HTTP rate limiting. Request

**PLANNED:**

- Ability to define matchers in zones with Caddyfile
- Smoothed estimates of distributed rate limiting
- RL state persisted in storage for resuming after restarts
- Admin API endpoints to inspect or modify rate limits
Expand All @@ -52,7 +51,7 @@ The `rate_limit` HTTP handler module lets you define rate limit zones, which hav

A zone also has a key, which is different from its name. Keys associate 1:1 with rate limiters, implemented as ring buffers; i.e. a new key implies allocating a new ring buffer. Keys can be static (no placeholders; same for every request), in which case only one rate limiter will be allocated for the whole zone. Or, keys can contain placeholders which can be different for every request, in which case a zone may contain numerous rate limiters depending on the result of expanding the key.

A zone is synomymous with a rate limit, being a number of events per duration. Both `window` and `max_events` are required configuration for a zone. For example: 100 events every 1 minute. Because this module uses a sliding window algorithm, it works by looking back `<window>` duration and seeing if `<max_events>` events have already happened in that timeframe. If so, an internal HTTP 429 error is generated and returned, invoking error routes which you have defined (if any). Otherwise, the a reservation is made and the event is allowed through.
A zone is synonymous with a rate limit, being a number of events per duration. Both `window` and `max_events` are required configuration for a zone. For example: 100 events every 1 minute. Because this module uses a sliding window algorithm, it works by looking back `<window>` duration and seeing if `<max_events>` events have already happened in that timeframe. If so, an internal HTTP 429 error is generated and returned, invoking error routes which you have defined (if any). Otherwise, a reservation is made and the event is allowed through.

Each zone may optionally filter the requests it applies to by specifying [request matchers](https://caddyserver.com/docs/modules/http#servers/routes/match).

Expand Down Expand Up @@ -121,6 +120,9 @@ Here is the syntax. See the JSON config section above for explanations about eac
```
rate_limit {
zone <name> {
match {
<matchers>
}
key <string>
window <duration>
events <max_events>
Expand Down Expand Up @@ -197,8 +199,6 @@ We also enable distributed rate limiting. By deploying this config to two or mor

### Caddyfile example

(The Caddyfile does not yet support defining matchers for RL zones, so that has been omitted from this example.)

```
{
order rate_limit before basicauth
Expand All @@ -209,6 +209,9 @@ We also enable distributed rate limiting. By deploying this config to two or mor
rate_limit {
distributed
zone static_example {
match {
method GET
}
key static
events 100
window 1m
Expand Down
7 changes: 7 additions & 0 deletions caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return d.Errf("invalid max events integer '%s': %v", d.Val(), err)
}
zone.MaxEvents = maxEvents
case "match":
matcherSet, err := caddyhttp.ParseCaddyfileNestedMatcherSet(d)
if err != nil {
return d.Errf("failed to parse lb_retry_match: %v", err)
}

zone.MatcherSetsRaw = append(zone.MatcherSetsRaw, matcherSet)
}
}
if zone.Window == 0 || zone.MaxEvents == 0 {
Expand Down
77 changes: 77 additions & 0 deletions caddyfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2023 Matthew Holt

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at

// http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package caddyrl

import (
"bytes"
"fmt"
"testing"

"github.com/caddyserver/caddy/v2/caddytest"
)

func TestCaddyfileRateLimits(t *testing.T) {
window := 60
maxEvents := 10
// Admin API must be exposed on port 2999 to match what caddytest.Tester does
config := fmt.Sprintf(`
{
skip_install_trust
admin localhost:2999
http_port 8080
order rate_limit before basicauth
}
localhost:8080
rate_limit {
zone zone1 {
match {
method GET
}
key static
window %ds
events %d
}
}
respond 200
`, window, maxEvents)

initTime()

tester := caddytest.NewTester(t)
tester.InitServer(config, "caddyfile")

for i := 0; i < maxEvents; i++ {
tester.AssertGetResponse("http://localhost:8080", 200, "")
}

assert429Response(t, tester, int64(window))
tester.AssertPostResponseBody("http://localhost:8080", nil, &bytes.Buffer{}, 200, "")

// After advancing time by half the window, the retry-after value should
// change accordingly
advanceTime(window / 2)

assert429Response(t, tester, int64(window/2))

// Advance time beyond the window where the events occurred. We should now
// be able to make requests again.
advanceTime(window)

tester.AssertGetResponse("http://localhost:8080", 200, "")
}

0 comments on commit c0d1c6a

Please sign in to comment.