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:
216
modules/cooler/cooler.go
Normal file
216
modules/cooler/cooler.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user