- 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.
215 lines
6.0 KiB
Go
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 时应拒绝)")
|
|
}
|
|
}
|