// Package ratelimit provides a Redis-backed fixed-window rate limiter and // lightweight per-key usage counters for the public API. // // All state lives in Redis so it is shared across API replicas and visible to // the admin console, and so the public server keeps its read-only contract // against PostgreSQL. Every operation fails open: if Redis is unavailable the // limiter allows the request rather than taking the API down. package ratelimit import ( "context" "fmt" "log" "time" "github.com/redis/go-redis/v9" ) // Limiter throttles callers and records usage. A nil-backed Limiter (when Redis // could not be configured) disables limiting and usage tracking. type Limiter struct { rdb *redis.Client } // Result describes the outcome of an Allow check and the headers to surface. type Result struct { Allowed bool Limit int Remaining int ResetUnix int64 } // UsageStat is the aggregated usage for a single API key. type UsageStat struct { Total int64 `json:"total"` Today int64 `json:"today"` LastUsedAt *int64 `json:"last_used_at,omitempty"` } // New builds a Limiter from a redis:// URL. On a parse error it logs and returns // a fail-open limiter (Redis disabled) so the server still boots. func New(redisURL string) *Limiter { opt, err := redis.ParseURL(redisURL) if err != nil { log.Printf("ratelimit: invalid redis url %q: %v (rate limiting disabled)", redisURL, err) return &Limiter{} } return &Limiter{rdb: redis.NewClient(opt)} } // Enabled reports whether a Redis backend is configured. func (l *Limiter) Enabled() bool { return l != nil && l.rdb != nil } // Ping verifies the Redis backend is reachable. Returns an error if disabled or // unreachable. func (l *Limiter) Ping(ctx context.Context) error { if !l.Enabled() { return redis.ErrClosed } return l.rdb.Ping(ctx).Err() } // Allow records a hit for id within a fixed window and reports whether the // caller is under limit. Fails open (Allowed=true) on any Redis error. func (l *Limiter) Allow(ctx context.Context, id string, limit int, window time.Duration) Result { reset := func() int64 { win := int64(window / time.Second) if win < 1 { win = 1 } return (time.Now().Unix()/win + 1) * win } if !l.Enabled() { return Result{Allowed: true, Limit: limit, Remaining: limit, ResetUnix: reset()} } win := int64(window / time.Second) if win < 1 { win = 1 } bucket := time.Now().Unix() / win key := fmt.Sprintf("rl:%s:%d", id, bucket) n, err := l.rdb.Incr(ctx, key).Result() if err != nil { return Result{Allowed: true, Limit: limit, Remaining: limit, ResetUnix: (bucket + 1) * win} } if n == 1 { l.rdb.Expire(ctx, key, time.Duration(win)*time.Second) } remaining := limit - int(n) if remaining < 0 { remaining = 0 } return Result{ Allowed: int(n) <= limit, Limit: limit, Remaining: remaining, ResetUnix: (bucket + 1) * win, } } // RecordUsage increments total/daily counters and stamps last-used for a key. // Best-effort: errors are ignored. func (l *Limiter) RecordUsage(ctx context.Context, keyID string) { if !l.Enabled() || keyID == "" { return } now := time.Now() day := now.Format("20060102") pipe := l.rdb.Pipeline() pipe.Incr(ctx, "usage:total:"+keyID) dayKey := "usage:day:" + keyID + ":" + day pipe.Incr(ctx, dayKey) pipe.Expire(ctx, dayKey, 90*24*time.Hour) pipe.Set(ctx, "usage:last:"+keyID, now.Unix(), 0) _, _ = pipe.Exec(ctx) } // IncrTotal increments the lifetime call counter for subject and returns the // new total. The counter never expires; it is the cumulative number of calls // attributed to a caller (an API key id, or "ip:" for anonymous callers). // Fails open returning 0 on any error so quota enforcement never takes the API // down. func (l *Limiter) IncrTotal(ctx context.Context, subject string) int64 { if !l.Enabled() || subject == "" { return 0 } n, err := l.rdb.Incr(ctx, "usage:total:"+subject).Result() if err != nil { return 0 } return n } // CopyTotal carries a lifetime counter from one subject to another, used when a // key is regenerated so a caller cannot reset their cumulative quota. Best // effort: a missing or zero source counter is a no-op. func (l *Limiter) CopyTotal(ctx context.Context, from, to string) { if !l.Enabled() || from == "" || to == "" { return } n, err := l.rdb.Get(ctx, "usage:total:"+from).Int64() if err != nil || n == 0 { return } l.rdb.Set(ctx, "usage:total:"+to, n, 0) } // TotalUsed reads the lifetime call counter for subject without incrementing. func (l *Limiter) TotalUsed(ctx context.Context, subject string) int64 { if !l.Enabled() || subject == "" { return 0 } n, _ := l.rdb.Get(ctx, "usage:total:"+subject).Int64() return n } // Usage reads aggregated usage for a key. Returns a zero-value stat on error. func (l *Limiter) Usage(ctx context.Context, keyID string) UsageStat { var st UsageStat if !l.Enabled() || keyID == "" { return st } day := time.Now().Format("20060102") st.Total, _ = l.rdb.Get(ctx, "usage:total:"+keyID).Int64() st.Today, _ = l.rdb.Get(ctx, "usage:day:"+keyID+":"+day).Int64() if v, err := l.rdb.Get(ctx, "usage:last:"+keyID).Int64(); err == nil { st.LastUsedAt = &v } return st }