feat(api): API keys + Redis rate limiting + usage stats
Add an optional API-key layer to the public read-only API. Keys grant higher per-minute rate limits and attribute usage; anonymous callers are still allowed at a lower IP-based budget. - migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once) - apikey pkg: key generation + hashing - ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open - public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After - admin: issue/list/revoke keys + usage view (API + UI tab) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDisabledFailsOpen verifies that a Limiter without a Redis backend allows
|
||||
// all requests and reports usage as zero rather than erroring.
|
||||
func TestDisabledFailsOpen(t *testing.T) {
|
||||
l := New("not-a-valid-url") // parse error => disabled
|
||||
if l.Enabled() {
|
||||
t.Fatal("expected limiter to be disabled for invalid url")
|
||||
}
|
||||
res := l.Allow(context.Background(), "x", 1, time.Minute)
|
||||
if !res.Allowed || res.Remaining != 1 {
|
||||
t.Fatalf("disabled limiter must fail open: %+v", res)
|
||||
}
|
||||
// Must not panic and must return zero usage.
|
||||
l.RecordUsage(context.Background(), "k1")
|
||||
if u := l.Usage(context.Background(), "k1"); u.Total != 0 {
|
||||
t.Fatalf("disabled usage should be zero, got %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNilReceiverSafe ensures a nil *Limiter is safe to use (handler default).
|
||||
func TestNilReceiverSafe(t *testing.T) {
|
||||
var l *Limiter
|
||||
if l.Enabled() {
|
||||
t.Fatal("nil limiter must report disabled")
|
||||
}
|
||||
res := l.Allow(context.Background(), "x", 5, time.Minute)
|
||||
if !res.Allowed {
|
||||
t.Fatal("nil limiter must fail open")
|
||||
}
|
||||
l.RecordUsage(context.Background(), "k")
|
||||
_ = l.Usage(context.Background(), "k")
|
||||
}
|
||||
|
||||
func testLimiter(t *testing.T) *Limiter {
|
||||
t.Helper()
|
||||
url := os.Getenv("OPENGOODS_REDIS_URL")
|
||||
if url == "" {
|
||||
url = "redis://localhost:6379/0"
|
||||
}
|
||||
l := New(url)
|
||||
if !l.Enabled() {
|
||||
t.Skip("redis not configured")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := l.rdb.Ping(ctx).Err(); err != nil {
|
||||
t.Skipf("redis not reachable: %v", err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestAllowFixedWindow(t *testing.T) {
|
||||
l := testLimiter(t)
|
||||
ctx := context.Background()
|
||||
id := fmt.Sprintf("test:%d", time.Now().UnixNano())
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
if res := l.Allow(ctx, id, 2, time.Minute); !res.Allowed {
|
||||
t.Fatalf("request %d should be allowed: %+v", i, res)
|
||||
}
|
||||
}
|
||||
res := l.Allow(ctx, id, 2, time.Minute)
|
||||
if res.Allowed {
|
||||
t.Fatalf("3rd request over limit 2 should be denied: %+v", res)
|
||||
}
|
||||
if res.Remaining != 0 {
|
||||
t.Fatalf("remaining should be 0 when over limit, got %d", res.Remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAndReadUsage(t *testing.T) {
|
||||
l := testLimiter(t)
|
||||
ctx := context.Background()
|
||||
key := fmt.Sprintf("usagekey:%d", time.Now().UnixNano())
|
||||
|
||||
l.RecordUsage(ctx, key)
|
||||
l.RecordUsage(ctx, key)
|
||||
u := l.Usage(ctx, key)
|
||||
if u.Total != 2 || u.Today != 2 {
|
||||
t.Fatalf("expected total=2 today=2, got %+v", u)
|
||||
}
|
||||
if u.LastUsedAt == nil {
|
||||
t.Fatal("expected last-used timestamp to be set")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user