feat(backend): update rate limiting middleware
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
package mw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"net"
|
||||
@@ -75,6 +76,126 @@ func (rl *RateLimiter) Allow(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ProgressiveRateLimiter implements per-IP rate limiting with increasing backoff
|
||||
// Designed for bot-spam prevention across accounts (not account-specific lockout)
|
||||
type ProgressiveRateLimiter struct {
|
||||
requests map[string]*ipProgressiveState
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type ipProgressiveState struct {
|
||||
// Timestamps of all requests within the tracking window
|
||||
timestamps []time.Time
|
||||
}
|
||||
|
||||
func NewProgressiveRateLimiter() *ProgressiveRateLimiter {
|
||||
prl := &ProgressiveRateLimiter{
|
||||
requests: make(map[string]*ipProgressiveState),
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
prl.cleanup()
|
||||
}
|
||||
}()
|
||||
return prl
|
||||
}
|
||||
|
||||
func (prl *ProgressiveRateLimiter) cleanup() {
|
||||
prl.mu.Lock()
|
||||
defer prl.mu.Unlock()
|
||||
cutoff := time.Now().Add(-60 * time.Second)
|
||||
for ip, state := range prl.requests {
|
||||
var valid []time.Time
|
||||
for _, t := range state.timestamps {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
delete(prl.requests, ip)
|
||||
} else {
|
||||
state.timestamps = valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check returns the delay in milliseconds. Returns 0 if no delay needed.
|
||||
// Strategy:
|
||||
// - Count requests in last 5 seconds (burst): allow up to 30
|
||||
// - Count requests in last 60 seconds (sustained): allow up to 60
|
||||
// - Only delay when BOTH windows are exceeded (high sustained rate with recent bursts)
|
||||
// - Progressive: once throttled, delay increases with sustained rate
|
||||
func (prl *ProgressiveRateLimiter) Check(ip string) (delayMs int) {
|
||||
prl.mu.Lock()
|
||||
defer prl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
state, exists := prl.requests[ip]
|
||||
if !exists {
|
||||
prl.requests[ip] = &ipProgressiveState{
|
||||
timestamps: []time.Time{now},
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
state.timestamps = append(state.timestamps, now)
|
||||
|
||||
burstCutoff := now.Add(-5 * time.Second)
|
||||
burstCount := 0
|
||||
for _, t := range state.timestamps {
|
||||
if t.After(burstCutoff) {
|
||||
burstCount++
|
||||
}
|
||||
}
|
||||
|
||||
sustainedCutoff := now.Add(-60 * time.Second)
|
||||
sustainedCount := 0
|
||||
for _, t := range state.timestamps {
|
||||
if t.After(sustainedCutoff) {
|
||||
sustainedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if burstCount <= 30 && sustainedCount <= 120 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Progressive delay based on how far over the sustained limit they are
|
||||
// Rate = requests per minute
|
||||
if sustainedCount <= 140 {
|
||||
return 500 // 500ms - scraping but not too aggressively
|
||||
} else if sustainedCount <= 200 {
|
||||
return 2000 // 2s - moderate spam
|
||||
} else if sustainedCount <= 300 {
|
||||
return 5000 // 5s - heavy spam
|
||||
} else {
|
||||
return 10000 // 10s - abuse
|
||||
}
|
||||
}
|
||||
|
||||
var globalProgressiveLimiter = NewProgressiveRateLimiter()
|
||||
|
||||
func ProgressiveRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.Header.Get("CF-Connecting-IP")
|
||||
if ip == "" {
|
||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
|
||||
delay := globalProgressiveLimiter.Check(ip)
|
||||
if delay > 0 {
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
w.Header().Set("X-RateLimit-Delay", fmt.Sprintf("%d", delay))
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimit middleware - limits requests per IP
|
||||
func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler {
|
||||
limiter := NewRateLimiter(limit, window)
|
||||
|
||||
@@ -5,6 +5,7 @@ package mw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,3 +16,29 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressiveRateLimiter struct {
|
||||
requests map[string]*ipProgressiveState
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type ipProgressiveState struct {
|
||||
timestamps []time.Time
|
||||
}
|
||||
|
||||
func NewProgressiveRateLimiter() *ProgressiveRateLimiter {
|
||||
return &ProgressiveRateLimiter{
|
||||
requests: make(map[string]*ipProgressiveState),
|
||||
}
|
||||
}
|
||||
|
||||
func (prl *ProgressiveRateLimiter) cleanup() {}
|
||||
func (prl *ProgressiveRateLimiter) Check(ip string) int { return 0 }
|
||||
|
||||
var globalProgressiveLimiter = NewProgressiveRateLimiter()
|
||||
|
||||
func ProgressiveRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//go:build test && !dev
|
||||
// +build test,!dev
|
||||
|
||||
package mw
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestProgressiveRateLimiter_SingleRequest verifies no delay for first request.
|
||||
func TestProgressiveRateLimiter_SingleRequest(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
delay := prl.Check("192.168.1.1")
|
||||
if delay != 0 {
|
||||
t.Errorf("expected 0 delay for first request, got %d", delay)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressiveRateLimiter_BurstAllowsPageLoad verifies 15 requests in 5s are OK.
|
||||
func TestProgressiveRateLimiter_BurstAllowsPageLoad(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
ip := "192.168.1.1"
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
delay := prl.Check(ip)
|
||||
if delay != 0 {
|
||||
t.Errorf("expected 0 delay for request %d (within burst), got %d", i+1, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressiveRateLimiter_ExcessBurstDelays verifies 35+ requests in 5s get delayed.
|
||||
func TestProgressiveRateLimiter_ExcessBurstDelays(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
ip := "192.168.1.1"
|
||||
|
||||
delayed := false
|
||||
for i := 0; i < 35; i++ {
|
||||
delay := prl.Check(ip)
|
||||
if delay > 0 {
|
||||
delayed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !delayed {
|
||||
t.Error("expected at least one delay after 35 burst requests")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressiveRateLimiter_SustainedAllowsNormal verifies 30 requests
|
||||
// spread over 60 seconds are not delayed.
|
||||
func TestProgressiveRateLimiter_SustainedAllowsNormal(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
ip := "192.168.1.2"
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
prl.mu.Lock()
|
||||
state, exists := prl.requests[ip]
|
||||
if !exists {
|
||||
prl.requests[ip] = &ipProgressiveState{
|
||||
timestamps: []time.Time{time.Now().Add(-time.Duration(60-i*2) * time.Second)},
|
||||
}
|
||||
prl.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
state.timestamps = append(state.timestamps, time.Now().Add(-time.Duration(60-i*2)*time.Second))
|
||||
prl.mu.Unlock()
|
||||
}
|
||||
|
||||
delay := prl.Check(ip)
|
||||
if delay != 0 {
|
||||
t.Errorf("expected 0 delay for 30 sustained requests over 60s, got %d", delay)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressiveRateLimiter_ExcessSustainedDelays verifies 130+ requests
|
||||
// in 60s triggers delay.
|
||||
func TestProgressiveRateLimiter_ExcessSustainedDelays(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
ip := "192.168.1.3"
|
||||
|
||||
now := time.Now()
|
||||
prl.mu.Lock()
|
||||
state := &ipProgressiveState{timestamps: make([]time.Time, 130)}
|
||||
for i := 0; i < 130; i++ {
|
||||
state.timestamps[i] = now.Add(-time.Duration(60-i/3) * time.Second)
|
||||
}
|
||||
prl.requests[ip] = state
|
||||
prl.mu.Unlock()
|
||||
|
||||
delay := prl.Check(ip)
|
||||
if delay == 0 {
|
||||
t.Error("expected delay > 0 for 130 sustained requests")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressiveRateLimiter_DifferentIPs verifies rate limiter
|
||||
// tracks IPs independently.
|
||||
func TestProgressiveRateLimiter_DifferentIPs(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
|
||||
for i := 0; i < 40; i++ {
|
||||
prl.Check("10.0.0.1")
|
||||
}
|
||||
|
||||
delay := prl.Check("10.0.0.2")
|
||||
if delay != 0 {
|
||||
t.Errorf("expected 0 delay for separate IP, got %d", delay)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressiveRateLimiter_CleanupRemovesStaleEntries verifies that
|
||||
// IPs with no activity for 60s are cleaned up.
|
||||
func TestProgressiveRateLimiter_CleanupRemovesStaleEntries(t *testing.T) {
|
||||
prl := NewProgressiveRateLimiter()
|
||||
|
||||
prl.mu.Lock()
|
||||
prl.requests["stale-ip"] = &ipProgressiveState{
|
||||
timestamps: []time.Time{time.Now().Add(-120 * time.Second)},
|
||||
}
|
||||
prl.mu.Unlock()
|
||||
|
||||
prl.cleanup()
|
||||
|
||||
prl.mu.RLock()
|
||||
_, exists := prl.requests["stale-ip"]
|
||||
prl.mu.RUnlock()
|
||||
if exists {
|
||||
t.Error("expected stale IP to be cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = sync.Mutex{}
|
||||
Reference in New Issue
Block a user