feat(19.3-01): share rate limits across instances through cooler's Redis store
- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore (atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback) - serve installs cooler and publishes the Redis surf.Store before BuildRouter; BuildRouter looks up a published Store, route:list keeps the memory store - config keys http.rate_limit.store and redis.url; go-redis v9.23.0 - cooler README, root table row, surf README and rate-limiting, configuration and scaling docs
This commit is contained in:
@@ -12,7 +12,7 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht
|
||||
|
||||
## Role in the framework
|
||||
|
||||
The HTTP entry point, at the top of the stack: it depends on `backpack`, `bonfire`, `bouncer`, `cabana`, `compass`, `conga`, `lagoon`, `pact`, `party`, `towel` and `wire`, and provides the serve command. Plugins contribute routes through `pact`; `surf` collects them into one standard library ServeMux.
|
||||
The HTTP entry point, at the top of the stack: it depends on `backpack`, `bonfire`, `bouncer`, `cabana`, `compass`, `conga`, `cooler`, `lagoon`, `pact`, `party`, `towel` and `wire`, and provides the serve command. Plugins contribute routes through `pact`; `surf` collects them into one standard library ServeMux.
|
||||
|
||||
Use cases:
|
||||
|
||||
@@ -28,7 +28,7 @@ Use cases:
|
||||
- Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot.
|
||||
- Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500.
|
||||
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
|
||||
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface.
|
||||
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`).
|
||||
- Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`).
|
||||
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
|
||||
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
|
||||
@@ -156,6 +156,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. |
|
||||
| `http.cors.max_age` | `0` | Preflight cache time in seconds. |
|
||||
| `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. |
|
||||
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
|
||||
| `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. |
|
||||
|
||||
@@ -173,20 +174,21 @@ http:
|
||||
supports_credentials: true
|
||||
```
|
||||
|
||||
The `serve` command also opens the database through [lagoon](../lagoon/README.md) and the uploads bucket through `attach.OpenBucket`, so their settings must be present as well. It starts the background job worker of [conga](../conga/README.md) in the same process unless `queue.work_in_serve` is `false` (default `true`); set it to `false` when a separate `queue:work` process runs the jobs. The other `queue.*` keys are documented in the conga README.
|
||||
The `serve` command also opens the database through [lagoon](../lagoon/README.md) and the uploads bucket through `attach.OpenBucket`, so their settings must be present as well. It then installs [cooler](../cooler/README.md) (`cooler.Install`) before it builds the router: with `http.rate_limit.store: redis` it publishes cooler's Redis-backed store as the `surf.Store`, falling back to a per-instance `surf.MemoryStore` while Redis does not answer. A Redis that is down at start-up is a warning, not a boot failure; a missing or malformed `redis.url` is. It starts the background job worker of [conga](../conga/README.md) in the same process unless `queue.work_in_serve` is `false` (default `true`); set it to `false` when a separate `queue:work` process runs the jobs. The other `queue.*` keys are documented in the conga README.
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, installs cooler (the shared rate-limit store when `http.rate_limit.store` is `redis`), assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md).
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md).
|
||||
- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`).
|
||||
- Standard library: `bytes`, `context`, `fmt`, `math`, `net`, `net/http`, `net/netip`, `os`, `os/signal`, `regexp`, `strconv`, `strings`, `sync`, `syscall`, `time`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container).
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -194,4 +196,4 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
go test ./modules/surf/...
|
||||
```
|
||||
|
||||
The tests use `net/http/httptest` and in-memory stores and need no external services.
|
||||
The tests use `net/http/httptest` and in-memory stores. `TestRateLimitAcrossInstancesSmoke` starts a Redis container through testcontainers-go and needs Docker; `go test -short ./modules/surf/...` skips it.
|
||||
|
||||
@@ -472,8 +472,19 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
if app != nil {
|
||||
trusted = TrustedProxies(app.Config)
|
||||
}
|
||||
// longest bucket decay is 1 minute; sweep at 2x
|
||||
lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted)
|
||||
// serve publishes a shared Store (cooler's Redis store) before building
|
||||
// the router; route:list and tests get the in-process MemoryStore.
|
||||
var store Store
|
||||
if app != nil {
|
||||
if s, ok := app.Lookup[Store](); ok && s != nil {
|
||||
store = s
|
||||
}
|
||||
}
|
||||
if store == nil {
|
||||
// longest bucket decay is 1 minute; sweep at 2x
|
||||
store = NewMemoryStore(2 * time.Minute)
|
||||
}
|
||||
lim := NewFixedWindowLimiter(store, trusted)
|
||||
r.limiter = lim
|
||||
if err := r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware {
|
||||
return lim.Middleware(param)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
@@ -53,6 +54,11 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return err
|
||||
}
|
||||
defer bucket.Close()
|
||||
coolerHandle, err := installCooler(ctx, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer coolerHandle.Close()
|
||||
r, err := BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -139,6 +145,25 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
|
||||
return bucket, nil
|
||||
}
|
||||
|
||||
// installCooler installs cooler and, when http.rate_limit.store is redis,
|
||||
// publishes its Redis-backed Store (falling back to a per-instance
|
||||
// MemoryStore while Redis does not answer) so BuildRouter's limiter shares
|
||||
// counters across instances. Only serve calls it: route:list never dials Redis.
|
||||
func installCooler(ctx context.Context, app *backpack.App) (*cooler.Handle, error) {
|
||||
h, err := cooler.Install(ctx, app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.RateLimitRedis() {
|
||||
// longest bucket decay is 1 minute; sweep at 2x (as BuildRouter)
|
||||
if err := app.Publish[Store](h.RateLimitStore(NewMemoryStore(2 * time.Minute))); err != nil {
|
||||
_ = h.Close()
|
||||
return nil, fmt.Errorf("surf: %w", err)
|
||||
}
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// adminCreateCommand is the command the startup hints name for creating the
|
||||
// first backend administrator.
|
||||
const adminCreateCommand = "summer admin:create --email you@example.com --superuser"
|
||||
|
||||
151
modules/surf/store_lookup_test.go
Normal file
151
modules/surf/store_lookup_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
// Smoke tests only; plan 05 of phase 19.3 owns full coverage.
|
||||
|
||||
const storeHTTPYAML = `body_limits:
|
||||
default_bytes: 1048576
|
||||
upload_bytes: 1048576
|
||||
rate_limit:
|
||||
store: redis
|
||||
`
|
||||
|
||||
// redisStoreApp builds an app whose config selects the Redis rate-limit store
|
||||
// at redisURL.
|
||||
func redisStoreApp(t *testing.T, redisURL string) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"app.yaml": "name: store-test\n",
|
||||
"http.yaml": storeHTTPYAML,
|
||||
"redis.yaml": fmt.Sprintf("url: %q\n", redisURL),
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
type countingStore struct{ n atomic.Int64 }
|
||||
|
||||
func (s *countingStore) Attempt(_ string, max int, decay time.Duration) (bool, int, time.Duration) {
|
||||
n := int(s.n.Add(1))
|
||||
return n <= max, n, decay
|
||||
}
|
||||
|
||||
func TestBuildRouterStoreSmoke(t *testing.T) {
|
||||
// Redis selected but never installed (route:list's path): BuildRouter
|
||||
// must not dial and keeps the in-process store.
|
||||
app := redisStoreApp(t, "redis://127.0.0.1:1")
|
||||
start := time.Now()
|
||||
r, err := BuildRouter(app, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Fatalf("BuildRouter took %s; it must not dial Redis", d)
|
||||
}
|
||||
if _, ok := r.limiter.store.(*MemoryStore); !ok {
|
||||
t.Fatalf("limiter store = %T, want *MemoryStore", r.limiter.store)
|
||||
}
|
||||
|
||||
// A published Store is what the throttle counts in.
|
||||
app = redisStoreApp(t, "redis://127.0.0.1:1")
|
||||
fake := &countingStore{}
|
||||
if err := app.Publish[Store](fake); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:2,1"}}
|
||||
h, err := Assemble(app, []party.Plugin{p})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ping", nil))
|
||||
if rec.Code != http.StatusOK || fake.n.Load() != 1 {
|
||||
t.Fatalf("status %d, fake attempts %d; want 200 through the published store", rec.Code, fake.n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAcrossInstancesSmoke(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers redis")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
ctr, err := testcontainers.Run(ctx, "redis:7-alpine",
|
||||
testcontainers.WithExposedPorts("6379/tcp"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("redis: testcontainers: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
|
||||
host, err := ctr.Host(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := ctr.MappedPort(ctx, "6379/tcp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := fmt.Sprintf("redis://%s:%s/0", host, port.Port())
|
||||
|
||||
instance := func() http.Handler {
|
||||
app := redisStoreApp(t, url)
|
||||
hd, err := installCooler(ctx, app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = hd.Close() })
|
||||
p := assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:2,1"}}
|
||||
h, err := Assemble(app, []party.Plugin{p})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
a, b := instance(), instance()
|
||||
get := func(h http.Handler) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||
req.RemoteAddr = "192.0.2.10:4000"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for i, tc := range []struct {
|
||||
h http.Handler
|
||||
want int
|
||||
}{{a, http.StatusOK}, {b, http.StatusOK}, {a, http.StatusTooManyRequests}} {
|
||||
rec := get(tc.h)
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("request %d: status %d, want %d", i+1, rec.Code, tc.want)
|
||||
}
|
||||
if tc.want == http.StatusTooManyRequests && rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("429 without Retry-After")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user