api/ratelimit.go
Virgil 2f8f8f805e fix(api): scope rate limiting by key
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:22:17 +00:00

216 lines
4.5 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api
import (
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const (
rateLimitCleanupInterval = time.Minute
rateLimitStaleAfter = 10 * time.Minute
)
type rateLimitStore struct {
mu sync.Mutex
buckets map[string]*rateLimitBucket
limit int
lastSweep time.Time
}
type rateLimitBucket struct {
mu sync.Mutex
tokens float64
last time.Time
lastSeen time.Time
}
type rateLimitDecision struct {
allowed bool
retryAfter time.Duration
limit int
remaining int
resetAt time.Time
}
func newRateLimitStore(limit int) *rateLimitStore {
now := time.Now()
return &rateLimitStore{
buckets: make(map[string]*rateLimitBucket),
limit: limit,
lastSweep: now,
}
}
func (s *rateLimitStore) allow(key string) rateLimitDecision {
now := time.Now()
s.mu.Lock()
bucket, ok := s.buckets[key]
if !ok || now.Sub(bucket.lastSeen) > rateLimitStaleAfter {
bucket = &rateLimitBucket{
tokens: float64(s.limit),
last: now,
lastSeen: now,
}
s.buckets[key] = bucket
} else {
bucket.lastSeen = now
}
if now.Sub(s.lastSweep) >= rateLimitCleanupInterval {
for k, candidate := range s.buckets {
if now.Sub(candidate.lastSeen) > rateLimitStaleAfter {
delete(s.buckets, k)
}
}
s.lastSweep = now
}
s.mu.Unlock()
bucket.mu.Lock()
defer bucket.mu.Unlock()
elapsed := now.Sub(bucket.last)
if elapsed > 0 {
refill := elapsed.Seconds() * float64(s.limit)
if bucket.tokens+refill > float64(s.limit) {
bucket.tokens = float64(s.limit)
} else {
bucket.tokens += refill
}
bucket.last = now
}
if bucket.tokens >= 1 {
bucket.tokens--
return rateLimitDecision{
allowed: true,
limit: s.limit,
remaining: int(math.Floor(bucket.tokens)),
resetAt: now.Add(timeUntilFull(bucket.tokens, s.limit)),
}
}
deficit := 1 - bucket.tokens
wait := time.Duration(deficit / float64(s.limit) * float64(time.Second))
if wait <= 0 {
wait = time.Second / time.Duration(s.limit)
if wait <= 0 {
wait = time.Second
}
}
return rateLimitDecision{
allowed: false,
retryAfter: wait,
limit: s.limit,
remaining: 0,
resetAt: now.Add(wait),
}
}
func rateLimitMiddleware(limit int) gin.HandlerFunc {
if limit <= 0 {
return func(c *gin.Context) {
c.Next()
}
}
store := newRateLimitStore(limit)
return func(c *gin.Context) {
key := clientRateLimitKey(c)
decision := store.allow(key)
if !decision.allowed {
secs := int(decision.retryAfter / time.Second)
if decision.retryAfter%time.Second != 0 {
secs++
}
if secs < 1 {
secs = 1
}
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
c.Header("Retry-After", strconv.Itoa(secs))
c.AbortWithStatusJSON(http.StatusTooManyRequests, Fail(
"rate_limit_exceeded",
"Too many requests",
))
return
}
c.Next()
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
}
}
func setRateLimitHeaders(c *gin.Context, limit, remaining int, resetAt time.Time) {
if limit > 0 {
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
}
if remaining < 0 {
remaining = 0
}
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
if !resetAt.IsZero() {
reset := resetAt.Unix()
if reset <= time.Now().Unix() {
reset = time.Now().Add(time.Second).Unix()
}
c.Header("X-RateLimit-Reset", strconv.FormatInt(reset, 10))
}
}
func timeUntilFull(tokens float64, limit int) time.Duration {
if limit <= 0 {
return 0
}
missing := float64(limit) - tokens
if missing <= 0 {
return 0
}
seconds := missing / float64(limit)
if seconds <= 0 {
return 0
}
return time.Duration(math.Ceil(seconds * float64(time.Second)))
}
// clientRateLimitKey prefers caller-provided credentials for bucket
// isolation, then falls back to the network address.
func clientRateLimitKey(c *gin.Context) string {
if apiKey := strings.TrimSpace(c.GetHeader("X-API-Key")); apiKey != "" {
return "api_key:" + apiKey
}
if bearer := bearerTokenFromHeader(c.GetHeader("Authorization")); bearer != "" {
return "bearer:" + bearer
}
if ip := c.ClientIP(); ip != "" {
return "ip:" + ip
}
if c.Request != nil && c.Request.RemoteAddr != "" {
return "ip:" + c.Request.RemoteAddr
}
return "ip:unknown"
}
func bearerTokenFromHeader(header string) string {
header = strings.TrimSpace(header)
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}