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

@@ -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