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:
Jakub Zych
2026-10-15 17:34:39 +02:00
parent 77dd11c804
commit fa4d89d0b9
15 changed files with 777 additions and 10 deletions

View File

@@ -61,7 +61,7 @@ Some costs come over with the port unchanged, and a few framework behaviours nee
- Garbage collection pauses are sub-millisecond and do not matter at this scale.
> [!WARNING]
> Rate limits are counted per process. The throttle middleware that `serve` builds keeps its counters in memory (`surf.MemoryStore`, behind the `surf.Store` interface), and `serve` offers no way to supply a different store yet. With N replicas the effective limit is N times the configured one. Until a shared store exists, divide the per-route limits by the replica count, or enforce the limit at the load balancer. [Rate limiting](../services/rate-limiting.md#named-buckets) describes the buckets.
> Rate limits are counted per process by default. The throttle middleware keeps its counters in memory (`surf.MemoryStore`, behind the `surf.Store` interface), so with N replicas the effective limit is N times the configured one. Set `http.rate_limit.store: redis` and `redis.url`, and `serve` counts in Redis through [cooler](../../modules/cooler/README.md)'s `cooler.RateLimitStore`, shared by every replica; see [Sharing limits across instances](../services/rate-limiting.md#sharing-limits-across-instances).
## Scaling and operations

View File

@@ -33,7 +33,32 @@ Each `surf.Bucket` has the maximum number of requests, the window length (`Decay
A request over the limit gets a 429 response with the body `{"message":"Too Many Attempts."}` and a `Retry-After` header. Every throttled response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a rejected one also `X-RateLimit-Reset`.
The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. Counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface), so each application instance counts on its own. Behind a load balancer with several instances, the effective limit is the configured limit times the number of instances.
The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. By default counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface). With this memory store each application instance counts on its own, so behind a load balancer with several instances the effective limit is the configured limit times the number of instances. [Sharing limits across instances](#sharing-limits-across-instances) moves the counters to Redis.
## Sharing limits across instances
Laravel shares its limiter counters through the cache store, usually Redis. SummerCMS does the same through [cooler](../../modules/cooler/README.md): set `http.rate_limit.store` to `redis` (the default is `memory`) and point `redis.url` at a Redis every instance reaches.
```yaml
# config/http.yaml
rate_limit:
store: redis
```
```yaml
# config/redis.yaml
url: redis://10.0.0.7:6379/0
```
Keep a URL that carries a password in the environment instead: `SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0`. `rediss://` connects over TLS and `unix://` through a socket.
When `serve` starts, it calls `cooler.Install`, which builds the Redis client and pings it, and publishes a `cooler.RateLimitStore` as the `surf.Store` before the router is built. Routes do not change: `throttle:60,1` and `throttle:<bucket>` count in Redis, with the same fixed-window rules as the memory store. Each attempt is one atomic Lua script, so two instances answering requests from the same client count one window together: with `throttle:2,1`, the third request in the minute gets a 429 whichever instance answers it.
Keys are `<prefix>rl:<key>`. The prefix is `cache.prefix`, or by default `app.name` lower-cased with other characters than letters and digits replaced by `_`, followed by `:`, so several applications can share one Redis.
When Redis does not answer, each attempt waits at most `cooler.AttemptTimeout` (500 ms) and then counts in a per-instance `surf.MemoryStore`, as if the memory store were selected, until Redis answers again. A warning is logged at most once a minute. Redis being down at start-up is a warning too, not a failed start; a missing or malformed `redis.url` fails the start with an error that names the key and never shows the URL.
Only `serve` connects to Redis. `route:list` builds the router with the memory store, and so does `surf.Assemble` in tests, so neither needs a running Redis.
## Client IP and trusted proxies

View File

@@ -53,6 +53,8 @@ These are the keys most applications set. Each module's reference page lists all
| `app.locale`, `app.fallback_locale` | Default and fallback translation locales. | [phrasebook](../../modules/phrasebook/README.md) |
| `http.body_limits.default_bytes`, `http.body_limits.upload_bytes` | Request body limits. Required by `serve` and `route:list`. | [surf](../../modules/surf/README.md) |
| `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. | [surf](../../modules/surf/README.md) |
| `http.rate_limit.store` | Where `throttle` counters live: `memory` (per instance, the default) or `redis` (shared by every instance). | [cooler](../../modules/cooler/README.md) |
| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) |
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `storage.uploads.bucket_url` | Uploads bucket, `file://` or `mem://`. Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Defaults to `false`. | [surf](../../modules/surf/README.md) |