Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions api/core/limiter_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package core

import (
"context"
"log"
"sync"
"time"

"github.com/redis/go-redis/v9"
)

// redisLimiterStorage adapts the global Redis client to fiber.Storage so
// the rate-limiter middleware shares one counter pool across replicas
// (ADR-0001). With Fiber's default in-memory storage, every replica
// keeps its own counters, multiplying the effective per-IP budget by
// the replica count.
//
// Fails open by design: when Redis is unreachable, Get reports "no
// entry" and Set/Delete swallow the error (rate-limited log), so
// requests pass instead of erroring. Rate limiting here is abuse
// protection, not a correctness layer — Redis being down already
// degrades SSE delivery, and turning it into a full API outage via
// limiter errors would be strictly worse.
type redisLimiterStorage struct {
prefix string
}

func newRedisLimiterStorage(prefix string) *redisLimiterStorage {
return &redisLimiterStorage{prefix: prefix}
}

// Rate-limited error log, same pattern as logDispatchDrop: Redis being
// down would otherwise log once per request.
var (
limiterLogMu sync.Mutex
limiterLogLast time.Time
)

const limiterLogInterval = 60 * time.Second

func logLimiterStorageError(op string, err error) {
limiterLogMu.Lock()
defer limiterLogMu.Unlock()
if time.Since(limiterLogLast) < limiterLogInterval {
return
}
limiterLogLast = time.Now()
log.Printf("[RateLimit] Redis %s failed, failing open (rate-limited log): %v", op, err)
}

func (s *redisLimiterStorage) Get(key string) ([]byte, error) {
val, err := Rdb.Get(context.Background(), s.prefix+key).Bytes()
if err == redis.Nil {
return nil, nil
}
if err != nil {
logLimiterStorageError("GET", err)
return nil, nil
}
return val, nil
}

func (s *redisLimiterStorage) Set(key string, val []byte, exp time.Duration) error {
if err := Rdb.Set(context.Background(), s.prefix+key, val, exp).Err(); err != nil {
logLimiterStorageError("SET", err)
}
return nil
}

func (s *redisLimiterStorage) Delete(key string) error {
if err := Rdb.Del(context.Background(), s.prefix+key).Err(); err != nil {
logLimiterStorageError("DEL", err)
}
return nil
}

// Reset deletes every key under the storage prefix. Fiber's limiter
// never calls this on the request path; it exists to satisfy the
// Storage interface.
func (s *redisLimiterStorage) Reset() error {
ctx := context.Background()
iter := Rdb.Scan(ctx, 0, s.prefix+"*", 100).Iterator()
for iter.Next(ctx) {
if err := Rdb.Del(ctx, iter.Val()).Err(); err != nil {
logLimiterStorageError("RESET", err)
return nil
}
}
if err := iter.Err(); err != nil {
logLimiterStorageError("RESET-SCAN", err)
}
return nil
}

// Close is a no-op: the Redis client's lifecycle is owned by main.
func (s *redisLimiterStorage) Close() error {
return nil
}
97 changes: 97 additions & 0 deletions api/core/limiter_storage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package core

import (
"bytes"
"testing"
"time"
)

// Tests for the Redis-backed fiber.Storage adapter behind the rate
// limiter (ADR-0001). The contract: counters round-trip with TTL, a
// missing key reads as (nil, nil), and Redis being down fails OPEN —
// never an error that would turn rate limiting into an outage.

func TestRedisLimiterStorage_RoundTripWithTTL(t *testing.T) {
mr, cleanup := setupMiniRedis(t)
defer cleanup()

s := newRedisLimiterStorage("ratelimit:")

if err := s.Set("1.2.3.4", []byte("counter-state"), 1*time.Minute); err != nil {
t.Fatalf("Set: %v", err)
}

got, err := s.Get("1.2.3.4")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !bytes.Equal(got, []byte("counter-state")) {
t.Errorf("Get = %q, want %q", got, "counter-state")
}

// The entry must expire with its window, or stale counters would
// throttle clients forever.
mr.FastForward(2 * time.Minute)
got, err = s.Get("1.2.3.4")
if err != nil {
t.Fatalf("Get after expiry: %v", err)
}
if got != nil {
t.Errorf("Get after expiry = %q, want nil", got)
}
}

func TestRedisLimiterStorage_MissingKeyIsNilNil(t *testing.T) {
_, cleanup := setupMiniRedis(t)
defer cleanup()

s := newRedisLimiterStorage("ratelimit:")
got, err := s.Get("never-seen")
if err != nil {
t.Fatalf("Get on missing key returned error: %v", err)
}
if got != nil {
t.Errorf("Get on missing key = %q, want nil", got)
}
}

func TestRedisLimiterStorage_DeleteRemovesEntry(t *testing.T) {
_, cleanup := setupMiniRedis(t)
defer cleanup()

s := newRedisLimiterStorage("ratelimit:")
_ = s.Set("4.3.2.1", []byte("x"), time.Minute)
if err := s.Delete("4.3.2.1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if got, _ := s.Get("4.3.2.1"); got != nil {
t.Errorf("entry survived Delete: %q", got)
}
}

// TestRedisLimiterStorage_FailsOpenWhenRedisDown is the load-bearing
// test: with Redis unreachable, Get must report "no entry" and Set must
// not surface an error, so the limiter admits requests instead of
// erroring them.
func TestRedisLimiterStorage_FailsOpenWhenRedisDown(t *testing.T) {
mr, cleanup := setupMiniRedis(t)
defer cleanup()
mr.Close() // kill the backend; Rdb now points at a dead address

s := newRedisLimiterStorage("ratelimit:")

got, err := s.Get("1.2.3.4")
if err != nil {
t.Errorf("Get with Redis down returned error: %v (must fail open)", err)
}
if got != nil {
t.Errorf("Get with Redis down = %q, want nil", got)
}

if err := s.Set("1.2.3.4", []byte("x"), time.Minute); err != nil {
t.Errorf("Set with Redis down returned error: %v (must fail open)", err)
}
if err := s.Delete("1.2.3.4"); err != nil {
t.Errorf("Delete with Redis down returned error: %v (must fail open)", err)
}
}
7 changes: 7 additions & 0 deletions api/core/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ func (s *Server) setupMiddleware() {
"/support/ticket": true,
}

// Counters live in Redis so all replicas share one per-IP budget
// (ADR-0001); the limiters' KeyGenerators keep their keys disjoint
// ("oauth:"-prefixed vs bare IP) within the shared prefix.
limiterStorage := newRedisLimiterStorage("ratelimit:")

// Stricter rate limiter for OAuth initiation endpoints (e.g. /yahoo/start).
// Applied BEFORE the general rate limiter so it runs first.
oauthRateLimitPaths := map[string]bool{
Expand All @@ -136,6 +141,7 @@ func (s *Server) setupMiddleware() {
s.App.Use(limiter.New(limiter.Config{
Max: OAuthRateLimitMax,
Expiration: OAuthRateLimitExpiration,
Storage: limiterStorage,
KeyGenerator: func(c *fiber.Ctx) string {
return "oauth:" + c.IP()
},
Expand All @@ -147,6 +153,7 @@ func (s *Server) setupMiddleware() {
s.App.Use(limiter.New(limiter.Config{
Max: RateLimitMax,
Expiration: RateLimitExpiration,
Storage: limiterStorage,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
Expand Down