- 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
217 lines
6.8 KiB
Go
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()
|
|
}
|