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

117
modules/cooler/README.md Normal file
View File

@@ -0,0 +1,117 @@
# cooler
Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, and a Redis-backed rate-limit store.
`import "git.golem15.com/golem15/summercms/modules/cooler"`
**Name:** The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group.
## Overview
cooler holds the state that several application instances share. Its first piece is a Redis-backed rate-limit store: with `http.rate_limit.store: redis`, the `serve` command of [surf](../surf/README.md) calls `cooler.Install`, which builds a Redis client from `redis.url`, and publishes the store so the `throttle` middleware counts in Redis instead of in the process. Two instances behind one load balancer then enforce one limit together. It is the counterpart of Laravel's Redis cache store behind `RateLimiter` and the `throttle` middleware.
## Role in the framework
A service module on `backpack` and `compass`, below `surf`: surf imports cooler, never the reverse. `cooler.RateLimitStore` has the method set of `surf.Store` (through `cooler.Attempter`), so surf takes no Redis dependency and cooler imports neither surf nor cabana. The framework wires cooler at boot; applications import nothing.
Use cases:
- Keep `throttle` limits exact when an application runs as several instances behind a load balancer.
- Share one Redis connection setting (`redis.url`) between the framework services that need Redis.
## Features
- `cooler.Install` reads `http.rate_limit.store` (`memory`, the default, or `redis`). With `redis` it builds the client from `redis.url` and pings it within `cooler.PingTimeout` (3 s).
- Start-up never fails because Redis is down: an unanswered ping is one warning that names the endpoint (host and port, never the URL), and the limiter counts per instance until Redis answers. A missing or malformed `redis.url`, or an unknown store name, fails start-up with an error that names the key and never contains the URL or its password.
- `cooler.RateLimitStore` runs one atomic Lua script per attempt with the semantics of `surf.MemoryStore`: the first attempt opens the window, a denied attempt does not increment, the window never extends, and the retry time is what is left of the window. Redis keeps the clock, so instance clock skew does not matter.
- Each attempt is bounded by `cooler.AttemptTimeout` (500 ms). When Redis fails, the attempt is counted in the fallback store surf passes in (a per-instance `surf.MemoryStore`) and a warning is logged at most once per minute.
- Keys carry a per-application prefix: `cache.prefix`, or by default `app.name` lower-cased with every run of other characters than `a-z` and `0-9` replaced by `_`, then `:` (`summer:` when `app.name` is empty). Rate-limit keys are `<prefix>rl:<key>`.
- `cooler.OpenRedis` accepts `redis://`, `rediss://` (TLS) and `unix://` URLs, does not dial at construction, and skips the `CLIENT SETINFO` and maintenance-notification handshakes.
- Only `serve` installs cooler: `route:list` and `surf.Assemble` in tests keep the in-process store and never dial Redis.
## Usage
Select the shared store in the application's config and keep the URL, which may carry a password, in the environment (`SUMMER_REDIS__URL`):
```yaml
# config/http.yaml
rate_limit:
store: redis
```
```yaml
# config/redis.yaml (or SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0)
url: redis://10.0.0.7:6379/0
```
Nothing else changes: routes keep `throttle:60,1` or `throttle:<bucket>`, and `serve` publishes the Redis store before it builds the router.
A tool outside `serve` can use the store directly:
```go
package blog
import (
"context"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cooler"
)
// AllowExport admits five exports a minute per user across all instances.
func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempter, userKey string) (bool, error) {
client, err := cooler.OpenRedis(ctx, app.Config.String(cooler.RedisURLKey))
if err != nil {
return false, err
}
defer client.Close()
store := cooler.NewRateLimitStore(client, "acme:", fallback)
allowed, _, _ := store.Attempt("export|"+userKey, 5, time.Minute)
return allowed, nil
}
```
## API reference
| Identifier | Description |
|------------|-------------|
| `cooler.Install` | Reads cooler's config, opens and pings Redis when a Redis store is selected, and returns a `cooler.Handle`. Called by surf's `serve`. |
| `cooler.Handle` | What `cooler.Install` set up. |
| `cooler.Handle.Close` | Closes the Redis client `cooler.Install` opened, if any. |
| `cooler.Handle.RateLimitRedis` | Reports whether `http.rate_limit.store` selects the Redis store. |
| `cooler.Handle.RateLimitStore` | Returns the Redis rate-limit store with the given fallback, or the fallback itself when Redis is not selected. |
| `cooler.Attempter` | One atomic fixed-window admission; the method set of `surf.Store`. |
| `cooler.RateLimitStore` | Redis fixed-window store shared by all instances, with a fallback while Redis fails. |
| `cooler.NewRateLimitStore` | Builds a `cooler.RateLimitStore` from a go-redis client, a key prefix and a fallback. |
| `cooler.RateLimitStore.Attempt` | Admits and counts one request in one Lua script; falls back on a Redis error. |
| `cooler.OpenRedis` | Builds a go-redis client from a `redis://`, `rediss://` or `unix://` URL without dialing; errors never contain the URL. |
| `cooler.RedisURLKey` | `redis.url`. |
| `cooler.RateLimitStoreKey` | `http.rate_limit.store`. |
| `cooler.CachePrefixKey` | `cache.prefix`. |
| `cooler.PingTimeout` | Bound of the start-up ping (3 s). |
| `cooler.AttemptTimeout` | Bound of one rate-limit attempt on Redis (500 ms). |
## Configuration
`cooler.Install` reads these keys from the app's [compass](../compass/README.md) config:
| Key | Default | Controls |
|-----|---------|----------|
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared). Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
| `redis.url` | none | Redis URL (`redis://`, `rediss://` or `unix://`). Required when a Redis store is selected; keep it in the environment when it carries a password. Environment: `SUMMER_REDIS__URL`. |
| `cache.prefix` | from `app.name` | Prefix of every cooler key. Environment: `SUMMER_CACHE__PREFIX`. |
## Dependencies
- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md).
- Third-party: `github.com/redis/go-redis/v9` (Redis client).
- Standard library: `context`, `errors`, `fmt`, `log/slog`, `strings`, `sync`, `time`.
## Testing
```sh
go test ./modules/cooler/...
```
The smoke tests need no external services. The cross-instance rate-limit test lives in surf (`go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke`), starts a Redis container through testcontainers-go and needs Docker; `-short` skips it.

216
modules/cooler/cooler.go Normal file
View File

@@ -0,0 +1,216 @@
// Package cooler holds the shared-state services of a SummerCMS application:
// a Redis-backed fixed-window rate-limit store that keeps limits across
// application instances, with the Redis client built from the one redis.url
// key. The framework installs it at boot (surf's serve command), so
// applications import nothing.
//
// cooler imports neither surf nor cabana: surf imports cooler, and the
// rate-limit store satisfies surf.Store structurally through Attempter.
package cooler
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"github.com/redis/go-redis/v9"
)
// Config keys and defaults cooler reads.
const (
// RedisURLKey is the shared Redis URL (redis://, rediss:// or unix://),
// required when a Redis driver is selected. Environment: SUMMER_REDIS__URL.
RedisURLKey = "redis.url"
// CacheDriverKey selects the cache driver: memory (default) or redis.
CacheDriverKey = "cache.driver"
// CachePrefixKey is the per-application key prefix. The default derives
// from app.name.
CachePrefixKey = "cache.prefix"
// CacheMaxEntriesKey bounds the memory driver's entry count.
CacheMaxEntriesKey = "cache.memory.max_entries"
// RateLimitStoreKey selects where rate-limit counters live: memory
// (default, per instance) or redis (shared).
RateLimitStoreKey = "http.rate_limit.store"
// DefaultMaxEntries is the memory driver's size bound when
// cache.memory.max_entries is unset.
DefaultMaxEntries = 10000
// PingTimeout bounds the start-up Ping that Install sends to Redis.
PingTimeout = 3 * time.Second
// AttemptTimeout bounds one rate-limit attempt on Redis before the
// fallback store answers.
AttemptTimeout = 500 * time.Millisecond
)
// Driver names accepted by cache.driver and http.rate_limit.store.
const (
driverMemory = "memory"
driverRedis = "redis"
)
// defaultPrefix is the key prefix when neither cache.prefix nor app.name is set.
const defaultPrefix = "summer:"
// Attempter is one atomic fixed-window admission. It has exactly the method
// set of surf.Store, so any surf.Store is an Attempter and a RateLimitStore
// is a surf.Store.
type Attempter interface {
Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)
}
// Handle is what Install set up. Close releases the Redis client Install
// opened, if any.
type Handle struct {
client *redis.Client
prefix string
rateRedis bool
log *slog.Logger
}
// Close closes the Redis client Install opened. It is safe on a nil Handle
// and when no Redis driver was selected.
func (h *Handle) Close() error {
if h == nil || h.client == nil {
return nil
}
err := h.client.Close()
h.client = nil
return err
}
// RateLimitRedis reports whether http.rate_limit.store selects the Redis store.
func (h *Handle) RateLimitRedis() bool {
return h != nil && h.rateRedis && h.client != nil
}
// RateLimitStore returns the Redis rate-limit store, falling back to fallback
// while Redis does not answer. When the Redis store is not selected it
// returns fallback itself.
func (h *Handle) RateLimitStore(fallback Attempter) Attempter {
if !h.RateLimitRedis() {
return fallback
}
s := NewRateLimitStore(h.client, h.prefix, fallback)
s.log = h.log
return s
}
// Install reads cooler's configuration and opens what it selects. With
// http.rate_limit.store set to redis it builds a client from redis.url and
// pings it within PingTimeout. An unreachable Redis is logged as a warning
// (the endpoint only, never the URL) and start-up continues: the rate limiter
// falls back to per-instance memory until Redis answers. A missing or invalid
// redis.url, or an unknown store name, is a boot error naming the key.
//
// Only the serve command calls Install; route:list and tests never dial Redis.
func Install(ctx context.Context, app *backpack.App) (*Handle, error) {
if app == nil {
return nil, fmt.Errorf("cooler: app is nil")
}
if ctx == nil {
ctx = context.Background()
}
cfg := app.Config
h := &Handle{prefix: keyPrefix(cfg), log: loggerFromApp(app)}
store, err := driverName(cfg, RateLimitStoreKey)
if err != nil {
return nil, err
}
h.rateRedis = store == driverRedis
if h.rateRedis {
client, err := openFromConfig(ctx, cfg, RateLimitStoreKey)
if err != nil {
return nil, err
}
h.client = client
ping(ctx, client, h.log)
}
return h, nil
}
// openFromConfig builds the Redis client from redis.url, naming the key that
// selected Redis when the URL is missing.
func openFromConfig(ctx context.Context, cfg *compass.Config, selectedBy string) (*redis.Client, error) {
raw := ""
if cfg != nil {
raw = strings.TrimSpace(cfg.String(RedisURLKey))
}
if raw == "" {
return nil, fmt.Errorf("cooler: %s is required when %s is %s (set it in config/redis.yaml or SUMMER_REDIS__URL)", RedisURLKey, selectedBy, driverRedis)
}
return OpenRedis(ctx, raw)
}
// ping checks Redis once within PingTimeout and logs a warning on failure.
func ping(ctx context.Context, client *redis.Client, log *slog.Logger) {
pctx, cancel := context.WithTimeout(ctx, PingTimeout)
defer cancel()
if err := client.Ping(pctx).Err(); err != nil {
log.Warn("cooler: redis did not answer at start-up; rate limits count per instance until it does",
"endpoint", redisEndpoint(client), "error", err)
}
}
// driverName reads a memory|redis selector. Empty means memory.
func driverName(cfg *compass.Config, key string) (string, error) {
if cfg == nil {
return driverMemory, nil
}
v := strings.ToLower(strings.TrimSpace(cfg.String(key)))
switch v {
case "", driverMemory:
return driverMemory, nil
case driverRedis:
return driverRedis, nil
}
return "", fmt.Errorf("cooler: %s must be %s or %s, got %q", key, driverMemory, driverRedis, v)
}
// keyPrefix returns the key prefix every cooler key starts with: cache.prefix
// when set, otherwise app.name lower-cased with every run of characters
// outside [a-z0-9] replaced by "_" (trimmed at both ends) and a trailing ":",
// or "summer:" when app.name is empty too. Cache keys are <prefix>cache:<key>,
// rate-limit keys <prefix>rl:<key>.
func keyPrefix(cfg *compass.Config) string {
if cfg == nil {
return defaultPrefix
}
if p := strings.TrimSpace(cfg.String(CachePrefixKey)); p != "" {
return p
}
name := slug(cfg.String("app.name"))
if name == "" {
return defaultPrefix
}
return name + ":"
}
func slug(s string) string {
var b strings.Builder
under := false
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
under = false
continue
}
if !under {
b.WriteByte('_')
under = true
}
}
return strings.Trim(b.String(), "_")
}
func loggerFromApp(app *backpack.App) *slog.Logger {
if app != nil {
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
return log
}
}
return slog.Default()
}

121
modules/cooler/ratelimit.go Normal file
View File

@@ -0,0 +1,121 @@
package cooler
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// attemptScript is one atomic fixed-window admission with the semantics of
// surf.MemoryStore: a missing or expired key opens a window of decay
// milliseconds, a count at max denies without incrementing, otherwise the
// count is incremented. INCR keeps the TTL, so the window never extends.
// It returns {allowed (0|1), count, remaining window in ms}.
//
// KEYS[1] = <prefix>rl:<key>; ARGV[1] = max; ARGV[2] = decay in ms (>= 1).
var attemptScript = redis.NewScript(`
local max = tonumber(ARGV[1])
local decay = tonumber(ARGV[2])
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then
count = 0
redis.call('SET', KEYS[1], 0, 'PX', decay)
ttl = decay
end
if count >= max then
return {0, count, ttl}
end
count = redis.call('INCR', KEYS[1])
return {1, count, ttl}
`)
// warnEvery bounds how often a Redis failure is logged.
const warnEvery = time.Minute
// RateLimitStore is a fixed-window rate-limit store on Redis, shared by every
// application instance that points at the same Redis. Its Attempt has the
// exact signature of surf.Store, so surf uses it without cooler importing
// surf. When Redis does not answer, Attempt counts in the fallback store
// (per instance) and logs a warning at most once per minute.
type RateLimitStore struct {
client *redis.Client
prefix string
fallback Attempter
log *slog.Logger
mu sync.Mutex
lastWarn time.Time
}
// NewRateLimitStore returns a store that keeps its counters under
// <prefix>rl:<key> on client. fallback answers while Redis fails; surf passes
// a surf.MemoryStore. A nil fallback admits every request during an outage.
func NewRateLimitStore(client *redis.Client, prefix string, fallback Attempter) *RateLimitStore {
return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, log: slog.Default()}
}
// Attempt admits and counts one request when key is below max, in one
// atomic Redis script bounded by AttemptTimeout. The first attempt opens the
// window, a denied attempt does not increment, and retryAfter is the time
// left in the window.
func (s *RateLimitStore) Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) {
ms := decay.Milliseconds()
if ms < 1 {
ms = 1
}
ctx, cancel := context.WithTimeout(context.Background(), AttemptTimeout)
defer cancel()
res, err := s.run(ctx, s.prefix+"rl:"+key, max, ms)
if err != nil {
s.warn(err)
if s.fallback == nil {
return true, 0, 0
}
return s.fallback.Attempt(key, max, decay)
}
ttl := time.Duration(res[2]) * time.Millisecond
if ttl < 0 {
ttl = 0
}
return res[0] == 1, int(res[1]), ttl
}
func (s *RateLimitStore) run(ctx context.Context, key string, max int, ms int64) ([]int64, error) {
if s == nil || s.client == nil {
return nil, fmt.Errorf("cooler: rate-limit store has no Redis client")
}
res, err := attemptScript.Run(ctx, s.client, []string{key}, max, ms).Int64Slice()
if err != nil {
return nil, err
}
if len(res) != 3 {
return nil, fmt.Errorf("cooler: rate-limit script returned %d values, want 3", len(res))
}
return res, nil
}
// warn logs a Redis failure at most once per warnEvery.
func (s *RateLimitStore) warn(err error) {
if s == nil {
return
}
s.mu.Lock()
now := time.Now()
if !s.lastWarn.IsZero() && now.Sub(s.lastWarn) < warnEvery {
s.mu.Unlock()
return
}
s.lastWarn = now
s.mu.Unlock()
log := s.log
if log == nil {
log = slog.Default()
}
log.Warn("cooler: redis rate-limit store failed; counting per instance until it answers",
"endpoint", redisEndpoint(s.client), "error", err)
}

46
modules/cooler/redis.go Normal file
View File

@@ -0,0 +1,46 @@
package cooler
import (
"context"
"errors"
"strings"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
)
// errInvalidRedisURL is the only error OpenRedis returns for a URL that does
// not parse. It never wraps or quotes the parser's error: url.Parse echoes the
// whole URL, password included.
var errInvalidRedisURL = errors.New("cooler: " + RedisURLKey + " is not a valid redis://, rediss:// or unix:// URL")
// OpenRedis builds a go-redis client from a redis:// (plain), rediss:// (TLS)
// or unix:// URL, such as the value of redis.url. It does not dial: go-redis
// connects on the first command. A URL that does not parse yields an error that
// names redis.url and never contains the URL or its credentials.
//
// The client skips the CLIENT SETINFO and maintenance-notification handshakes,
// which older servers and test doubles do not know.
func OpenRedis(ctx context.Context, rawURL string) (*redis.Client, error) {
_ = ctx // reserved: go-redis does not dial at construction
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, errors.New("cooler: " + RedisURLKey + " is empty")
}
opt, err := redis.ParseURL(rawURL)
if err != nil {
return nil, errInvalidRedisURL
}
opt.DisableIdentity = true
opt.MaintNotificationsConfig = &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}
return redis.NewClient(opt), nil
}
// redisEndpoint is the part of a client's address that may be logged: the
// host and port (or socket path), never the URL, user or password.
func redisEndpoint(c *redis.Client) string {
if c == nil {
return ""
}
return c.Options().Addr
}

View File

@@ -0,0 +1,36 @@
package cooler
import (
"strings"
"testing"
)
// Smoke tests only; plan 05 of phase 19.3 owns full coverage.
func TestOpenRedisRedactsSmoke(t *testing.T) {
ctx := t.Context()
_, err := OpenRedis(ctx, "redis://alice:s3cretpw@cache.example.com:6379/0\x7f")
if err == nil {
t.Fatal("a URL with a control character must not parse")
}
msg := err.Error()
if strings.Contains(msg, "alice") || strings.Contains(msg, "s3cretpw") {
t.Fatalf("error leaks credentials: %q", msg)
}
if !strings.Contains(msg, RedisURLKey) {
t.Fatalf("error should name %s: %q", RedisURLKey, msg)
}
c, err := OpenRedis(ctx, "rediss://user:pw@cache.example.com:6380/1")
if err != nil {
t.Fatalf("rediss URL: %v", err)
}
defer c.Close()
opt := c.Options()
if opt.TLSConfig == nil || opt.Addr != "cache.example.com:6380" || opt.DB != 1 {
t.Fatalf("rediss options = addr %q db %d tls %v", opt.Addr, opt.DB, opt.TLSConfig != nil)
}
if !opt.DisableIdentity {
t.Fatal("OpenRedis must disable the CLIENT SETINFO handshake")
}
}