fix(ratelimit): make burst a short-window allowance above the rate

Burst and the sustained rate were compared against the same window, so
a burst above the sustained rate never took effect. Burst is now
measured over a short window on top of the sustained limit. An unset
burst preserves the previous rate-only behaviour.
This commit is contained in:
2026-08-14 11:10:24 +01:00
parent e6a43356c0
commit 6751346796
2 changed files with 336 additions and 15 deletions
+209
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
@@ -299,3 +301,210 @@ func TestRateLimitConfigUnmarshalKeepsBurstAndEndpoints(t *testing.T) {
t.Errorf("endpoints = %v, want [\"/v1/graphql\"]", rc.Endpoints)
}
}
// fakeClock is a manually-advanced clock injected into a burstWindow so
// tests can move the burst window forward deterministically, without
// sleeping on real time.
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func newFakeClock(start time.Time) *fakeClock {
return &fakeClock{now: start}
}
func (f *fakeClock) Now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
func (f *fakeClock) Advance(d time.Duration) {
f.mu.Lock()
defer f.mu.Unlock()
f.now = f.now.Add(d)
}
// newBurstTestRoleConfig mirrors the RateLimitConfig construction performed
// by loadConfigFromPath: a real RateCounterTicker for the sustained check,
// and (only when burst > 0) a burstCounter sized by burstWindowDuration.
func newBurstTestRoleConfig(interval time.Duration, req, burst int) RateLimitConfig {
rc := RateLimitConfig{
RateCounterTicker: goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
Interval: interval,
}),
Interval: interval,
Req: req,
Burst: burst,
}
if burst > 0 {
rc.burstCounter = newBurstWindow(burstWindowDuration(interval))
}
return rc
}
// Test_checkRateLimit_BurstSemantics is the table test for the C10 burst
// fix. Each case is self-contained and deterministic: sustained-rate
// timing relies only on test execution being far faster than the
// configured Interval (the same assumption Test_rateLimitedRequest already
// makes), and burst-window timing is controlled directly via fakeClock, so
// nothing here depends on a wall-clock sleep as the pass/fail gate.
func Test_checkRateLimit_BurstSemantics(t *testing.T) {
cfg = &config{}
parseConfig()
cfg.Logger = libpack_logger.New()
t.Run("a_burst_unset_only_req_enforced", func(t *testing.T) {
rc := newBurstTestRoleConfig(1*time.Second, 2, 0)
if rc.burstCounter != nil {
t.Fatalf("burstCounter = %v, want nil when Burst <= 0", rc.burstCounter)
}
if !checkRateLimit("u", "role-a", rc, "") {
t.Errorf("request 1: got denied, want allowed (within Req)")
}
if !checkRateLimit("u", "role-a", rc, "") {
t.Errorf("request 2: got denied, want allowed (at Req)")
}
if checkRateLimit("u", "role-a", rc, "") {
t.Errorf("request 3: got allowed, want denied (exceeds Req, burst disabled)")
}
})
t.Run("b_burst_above_req_allows_short_spike_sustained_still_capped", func(t *testing.T) {
const (
interval = 10 * time.Second
req = 3
burst = 6 // burst > req
)
rc := newBurstTestRoleConfig(interval, req, burst)
fc := newFakeClock(time.Now())
rc.burstCounter.now = fc.Now
// A rapid cluster of `burst` requests must all be allowed, even
// though burst (6) > req (3): the sustained gate only bounds the
// total over the full 10s Interval (tickerRate <= 3 needs count <=
// 30), so a small cluster like this never trips it.
for i := 1; i <= burst; i++ {
if !checkRateLimit("u", "role-b", rc, "") {
t.Fatalf("request %d in first burst: got denied, want allowed (<= Burst=%d)", i, burst)
}
}
// The (burst+1)th request in the SAME short window must be denied
// by the burst gate specifically - not by the sustained gate,
// which alone would still allow it (tickerRate = 7/10 = 0.7 <= 3).
// This is the proof burst > req is not a no-op: it is the gate
// that actually fires here.
if checkRateLimit("u", "role-b", rc, "") {
t.Fatalf("request %d: got allowed, want denied by burst gate (> Burst=%d)", burst+1, burst)
}
// Roll the burst window forward (fake clock only - the real
// sustained ticker has not aged, since no real time passed) and
// repeat clusters of `burst` requests. Each cluster alone passes
// burst, but the cumulative sustained count keeps climbing until
// it exceeds req*interval.Seconds() = 30, at which point the
// sustained gate must deny regardless of how the burst window
// resets. This proves "sustained still capped at Req".
sustainedTripped := false
for batch := 0; batch < 10 && !sustainedTripped; batch++ {
fc.Advance(2 * burstWindowDuration(interval)) // clears the burst window
for i := 1; i <= burst; i++ {
allowed := checkRateLimit("u", "role-b", rc, "")
rate := rc.RateCounterTicker.GetRate()
if rate > float64(req) {
if allowed {
t.Fatalf("batch %d req %d: allowed with sustained rate %.2f > Req=%d", batch, i, rate, req)
}
sustainedTripped = true
break
}
}
}
if !sustainedTripped {
t.Fatalf("sustained gate never tripped after repeated bursts; expected it to cap total requests at Req over the Interval")
}
})
t.Run("c_burst_below_req_documented_behaviour", func(t *testing.T) {
t.Run("clustered_traffic_capped_below_req_by_burst", func(t *testing.T) {
const (
interval = 10 * time.Second
req = 6
burst = 3 // burst < req
)
rc := newBurstTestRoleConfig(interval, req, burst)
fc := newFakeClock(time.Now())
rc.burstCounter.now = fc.Now
for i := 1; i <= burst; i++ {
if !checkRateLimit("u", "role-c1", rc, "") {
t.Fatalf("request %d: got denied, want allowed (<= Burst=%d)", i, burst)
}
}
// The 4th rapid request is within Req (tickerRate = 4/10 = 0.4
// <= 6) but must still be denied: with burst < req, the burst
// gate is the tighter, binding constraint for clustered
// traffic - it is not a no-op the way the pre-fix code made
// burst > req.
if checkRateLimit("u", "role-c1", rc, "") {
t.Fatalf("request %d: got allowed, want denied by burst gate (> Burst=%d) though within Req", burst+1, burst)
}
})
t.Run("evenly_spread_traffic_still_governed_by_req_alone", func(t *testing.T) {
const (
interval = 1 * time.Second
req = 3
burst = 1 // burst < req
)
rc := newBurstTestRoleConfig(interval, req, burst)
fc := newFakeClock(time.Now())
rc.burstCounter.now = fc.Now
window := burstWindowDuration(interval)
// One request per (window*2): each lands in its own fresh
// burst window (count resets to 1 <= Burst=1 every time), so
// the burst gate never fires. Only the sustained gate, over
// req=3, eventually denies.
for i := 1; i <= req; i++ {
if !checkRateLimit("u", "role-c2", rc, "") {
t.Fatalf("spread request %d: got denied, want allowed (within Req=%d, burst resets every time)", i, req)
}
fc.Advance(2 * window)
}
if checkRateLimit("u", "role-c2", rc, "") {
t.Fatalf("spread request %d: got allowed, want denied by sustained gate (> Req=%d)", req+1, req)
}
})
})
t.Run("d_fail_safe_never_allows_more_than_burst_in_window", func(t *testing.T) {
const (
interval = 10 * time.Second
req = 1000 // high enough it never binds; isolates the burst gate
burst = 5
goroutines = 50
)
rc := newBurstTestRoleConfig(interval, req, burst)
var allowed int64
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
if checkRateLimit("u", "role-d", rc, "") {
atomic.AddInt64(&allowed, 1)
}
}()
}
wg.Wait()
if allowed != burst {
t.Fatalf("allowed = %d concurrent requests, want exactly Burst=%d (fail-safe: never more than Burst allowed in the burst window)", allowed, burst)
}
})
}