Files
ghproxy/ratelimit_test.go
T
Geekertao d9d2bbb850 fix: sample limiter timestamp under lock and use per-request config snapshot
- rateLimiter.allow now samples time.Now() while holding the mutex, so entries[ip] stays time-ascending under concurrency; allowAt/allowLocked share the same locked logic for deterministic tests.
- handler passes its single config snapshot into proxy and render404 so one request never mixes config versions.
- rewrite sweep test to hit sweepLocked directly; restore config/log/gin global state in tests; add real-clock concurrency ordering test.
2026-10-10 23:04:41 +08:00

215 lines
6.0 KiB
Go

package main
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
// newTestLimiter 关闭自动回收,便于测试精确控制时间。
func newTestLimiter() *rateLimiter {
l := newRateLimiter()
l.sweepEvery = time.Hour
return l
}
// 1. 关闭限流(limit <= 0)时应始终放行且不记录任何状态。
func TestLimiterDisabled(t *testing.T) {
l := newTestLimiter()
now := time.Now()
for i := 0; i < 1000; i++ {
if !l.allowAt("1.1.1.1", 0, now) {
t.Fatalf("关闭限流时应始终放行,第 %d 次被拒绝", i+1)
}
if !l.allowAt("2.2.2.2", -5, now) {
t.Fatalf("limitRate 为负时应始终放行")
}
}
if len(l.entries) != 0 {
t.Fatalf("关闭限流时不应记录任何 IP,实际记录 %d 个", len(l.entries))
}
}
// 2. 请求数恰好达到限额时应全部放行。
func TestLimiterExactlyAtLimit(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 3
for i := 0; i < limit; i++ {
if !l.allowAt("1.1.1.1", limit, now) {
t.Fatalf("第 %d 次请求应放行(恰好达到限额)", i+1)
}
}
}
// 3. 超过限额时应被拒绝。
func TestLimiterOverLimitRejected(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 2
if !l.allowAt("1.1.1.1", limit, now) || !l.allowAt("1.1.1.1", limit, now) {
t.Fatal("前两次请求应放行")
}
if l.allowAt("1.1.1.1", limit, now) {
t.Fatal("第 3 次请求应被拒绝")
}
if l.allowAt("1.1.1.1", limit, now) {
t.Fatal("超限后应持续拒绝")
}
}
// 4. 时间窗口过期后应恢复放行,并验证窗口边界不出现 off-by-one。
func TestLimiterWindowExpiry(t *testing.T) {
l := newTestLimiter()
t0 := time.Now()
const limit = 2
l.allowAt("1.1.1.1", limit, t0)
l.allowAt("1.1.1.1", limit, t0)
if l.allowAt("1.1.1.1", limit, t0.Add(30*time.Second)) {
t.Fatal("窗口内应继续拒绝")
}
// 恰好在窗口边界(age == window)时记录仍有效,应拒绝。
if l.allowAt("1.1.1.1", limit, t0.Add(time.Minute)) {
t.Fatal("窗口边界上的记录仍应计入,必须拒绝")
}
// 刚超过一个窗口后,旧记录过期,应恢复放行。
if !l.allowAt("1.1.1.1", limit, t0.Add(time.Minute+time.Nanosecond)) {
t.Fatal("窗口过期后应恢复放行")
}
}
// 5. 多个 IP 之间互不影响。
func TestLimiterMultipleIPsIndependent(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 1
if !l.allowAt("a", limit, now) {
t.Fatal("a 第一次应放行")
}
if l.allowAt("a", limit, now) {
t.Fatal("a 第二次应拒绝")
}
if !l.allowAt("b", limit, now) {
t.Fatal("b 不应受 a 的限制影响")
}
if l.allowAt("b", limit, now) {
t.Fatal("b 第二次应拒绝")
}
}
// 6a. 同一 IP 的并发请求(固定时间注入):放行总数必须恰好等于限额。
func TestLimiterConcurrentSameIP(t *testing.T) {
l := newTestLimiter()
now := time.Now()
const limit = 50
const goroutines = 500
var allowed int64
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if l.allowAt("same", limit, now) {
atomic.AddInt64(&allowed, 1)
}
}()
}
wg.Wait()
if allowed != limit {
t.Fatalf("并发放行数应为 %d,实际为 %d", limit, allowed)
}
}
// 6b. 使用真实生产入口(锁内采样 time.Now)并发请求:
// 放行数恰当,且 entries[ip] 必须保持按时间升序,验证时间戳顺序修复。
func TestLimiterConcurrentRealClockOrdering(t *testing.T) {
l := newTestLimiter()
const limit = 100
const goroutines = 2000
var allowed int64
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if l.allow("same", limit) {
atomic.AddInt64(&allowed, 1)
}
}()
}
wg.Wait()
if allowed != limit {
t.Fatalf("并发放行数应为 %d,实际为 %d", limit, allowed)
}
times := l.entries["same"]
if len(times) != limit {
t.Fatalf("窗口内记录数应为 %d,实际 %d", limit, len(times))
}
for i := 1; i < len(times); i++ {
if times[i].Before(times[i-1]) {
t.Fatalf("时间戳乱序:索引 %d (%v) 早于 %d (%v)", i, times[i], i-1, times[i-1])
}
}
}
// 7. 大量不同 IP 请求后,过期条目应被回收,避免无界增长。
func TestLimiterReclaimsInactiveIPs(t *testing.T) {
l := newTestLimiter()
base := time.Now()
const n = 5000
for i := 0; i < n; i++ {
ip := fmt.Sprintf("10.%d.%d.%d", (i>>16)&0xff, (i>>8)&0xff, i&0xff)
l.allowAt(ip, 10, base)
}
if got := len(l.entries); got != n {
t.Fatalf("应记录 %d 个 IP,实际 %d", n, got)
}
// 强制在窗口之后触发一次回收,清掉全部不活跃 IP。
l.sweepEvery = 0
after := base.Add(2 * time.Minute)
if !l.allowAt("192.168.0.1", 10, after) {
t.Fatal("新 IP 应放行")
}
if got := len(l.entries); got != 1 {
t.Fatalf("过期 IP 应被回收,仅剩新 IP(1 个),实际 %d", got)
}
}
// 8. 直接验证 sweep 的目标代码路径:裁剪活跃 IP 的过期记录、回收不活跃 IP,
// 且回收不破坏仍然有效的窗口内记录。
func TestLimiterSweepPrunesActiveAndReclaimsInactive(t *testing.T) {
l := newTestLimiter()
base := time.Now()
// 直接构造 entries,使旧记录在 sweep 被调用时确实仍然存在,
// 不经过 allowAt 触发的 prune,从而真正覆盖 sweepLocked 的裁剪逻辑。
l.entries["active"] = []time.Time{base, base.Add(80 * time.Second)}
l.entries["stale"] = []time.Time{base}
now := base.Add(90 * time.Second) // cutoff = now - window = base + 30s
l.mu.Lock()
l.sweepLocked(now)
l.mu.Unlock()
// 活跃 IP:旧记录被裁剪,窗口内记录保留。
active := l.entries["active"]
if len(active) != 1 || !active[0].Equal(base.Add(80*time.Second)) {
t.Fatalf("active 应仅保留窗口内的 1 条记录,实际 %v", active)
}
// 不活跃 IP:被正确回收。
if _, ok := l.entries["stale"]; ok {
t.Fatal("不活跃 IP 应被回收")
}
// 回收不破坏仍有效的限流:active 在窗口内已有 1 条记录,limit=1 时下一次应被拒绝。
if l.allowAt("active", 1, now) {
t.Fatal("活跃 IP 的窗口内记录应仍然生效(limit=1 时应拒绝)")
}
}