Files
summercms/modules/cooler/cooler.go
Jakub Zych fa4d89d0b9 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
2026-10-15 17:35:05 +02:00

217 lines
6.8 KiB
Go

// 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()
}