mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-11 05:27:47 +00:00
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.
511 lines
15 KiB
Go
511 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/goccy/go-json"
|
|
goratecounter "github.com/lukaszraczylo/go-ratecounter"
|
|
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
|
)
|
|
|
|
func (suite *Tests) Test_loadRatelimitConfig() {
|
|
// Setup
|
|
cfg = &config{}
|
|
parseConfig()
|
|
cfg.Logger = libpack_logger.New()
|
|
|
|
// Create a temporary test ratelimit.json file
|
|
tempDir := os.TempDir()
|
|
testConfigPath := filepath.Join(tempDir, "test_ratelimit.json")
|
|
|
|
testConfig := struct {
|
|
RateLimit map[string]RateLimitConfig `json:"ratelimit"`
|
|
}{
|
|
RateLimit: map[string]RateLimitConfig{
|
|
"admin": {
|
|
Interval: 1 * time.Second,
|
|
Req: 100,
|
|
},
|
|
"user": {
|
|
Interval: 1 * time.Second,
|
|
Req: 10,
|
|
},
|
|
},
|
|
}
|
|
|
|
configData, err := json.Marshal(testConfig)
|
|
suite.NoError(err)
|
|
|
|
err = os.WriteFile(testConfigPath, configData, 0o644)
|
|
suite.NoError(err)
|
|
defer func() { _ = os.Remove(testConfigPath) }()
|
|
|
|
// Test loading config from custom path
|
|
suite.Run("load from custom path", func() {
|
|
// Clear existing rate limits
|
|
rateLimitMu.Lock()
|
|
rateLimits = make(map[string]RateLimitConfig)
|
|
rateLimitMu.Unlock()
|
|
|
|
err := loadConfigFromPath(testConfigPath)
|
|
suite.NoError(err)
|
|
|
|
// Verify rate limits were loaded
|
|
rateLimitMu.RLock()
|
|
defer rateLimitMu.RUnlock()
|
|
|
|
suite.Equal(2, len(rateLimits))
|
|
suite.Contains(rateLimits, "admin")
|
|
suite.Contains(rateLimits, "user")
|
|
suite.Equal(100, rateLimits["admin"].Req)
|
|
suite.Equal(10, rateLimits["user"].Req)
|
|
suite.NotNil(rateLimits["admin"].RateCounterTicker)
|
|
suite.NotNil(rateLimits["user"].RateCounterTicker)
|
|
})
|
|
|
|
// Test loading config from non-existent path
|
|
suite.Run("load from non-existent path", func() {
|
|
err := loadConfigFromPath("/non/existent/path.json")
|
|
suite.Error(err)
|
|
})
|
|
|
|
// Test loading config with invalid JSON
|
|
suite.Run("load invalid JSON", func() {
|
|
invalidPath := filepath.Join(tempDir, "invalid_ratelimit.json")
|
|
err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o644)
|
|
suite.NoError(err)
|
|
defer func() { _ = os.Remove(invalidPath) }()
|
|
|
|
err = loadConfigFromPath(invalidPath)
|
|
suite.Error(err)
|
|
})
|
|
|
|
// Test with a temporary ratelimit.json file in the current directory
|
|
suite.Run("load from current directory", func() {
|
|
// Create a temporary ratelimit.json in current directory
|
|
currentDirPath := "./ratelimit.json"
|
|
err := os.WriteFile(currentDirPath, configData, 0o644)
|
|
suite.NoError(err)
|
|
defer func() { _ = os.Remove(currentDirPath) }()
|
|
|
|
// Clear existing rate limits
|
|
rateLimitMu.Lock()
|
|
rateLimits = make(map[string]RateLimitConfig)
|
|
rateLimitMu.Unlock()
|
|
|
|
// This should find the file in the current directory
|
|
err = loadRatelimitConfig()
|
|
suite.NoError(err)
|
|
|
|
// Verify rate limits were loaded
|
|
rateLimitMu.RLock()
|
|
defer rateLimitMu.RUnlock()
|
|
|
|
suite.Equal(2, len(rateLimits))
|
|
})
|
|
|
|
// Test with all files missing
|
|
suite.Run("all files missing", func() {
|
|
// Save the original load function and restore it when done
|
|
originalLoadFunc := loadConfigFunc
|
|
defer func() {
|
|
loadConfigFunc = originalLoadFunc
|
|
}()
|
|
|
|
// Replace with a mock function that always returns "file does not exist" error
|
|
loadConfigFunc = func(string) error {
|
|
return fmt.Errorf("file does not exist")
|
|
}
|
|
|
|
// Clear existing rate limits
|
|
rateLimitMu.Lock()
|
|
rateLimits = make(map[string]RateLimitConfig)
|
|
rateLimitMu.Unlock()
|
|
|
|
// This should fail as our mock returns errors for all paths
|
|
err = loadRatelimitConfig()
|
|
suite.Error(err)
|
|
|
|
// The error should be a RateLimitConfigError
|
|
configErr, ok := err.(*RateLimitConfigError)
|
|
suite.True(ok, "Expected *RateLimitConfigError but got %T", err)
|
|
|
|
// All path errors should contain our mock error message
|
|
for _, errMsg := range configErr.PathErrors {
|
|
suite.Equal("file does not exist", errMsg)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (suite *Tests) Test_rateLimitedRequest() {
|
|
// Setup
|
|
cfg = &config{}
|
|
parseConfig()
|
|
cfg.Logger = libpack_logger.New()
|
|
|
|
// Create test rate limits
|
|
rateLimitMu.Lock()
|
|
rateLimits = make(map[string]RateLimitConfig)
|
|
|
|
// Admin role with high limit
|
|
adminCounter := goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
|
Interval: 1 * time.Second,
|
|
})
|
|
rateLimits["admin"] = RateLimitConfig{
|
|
RateCounterTicker: adminCounter,
|
|
Interval: 1 * time.Second,
|
|
Req: 100,
|
|
}
|
|
|
|
// User role with low limit
|
|
userCounter := goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
|
Interval: 1 * time.Second,
|
|
})
|
|
rateLimits["user"] = RateLimitConfig{
|
|
RateCounterTicker: userCounter,
|
|
Interval: 1 * time.Second,
|
|
Req: 2, // Set very low for testing
|
|
}
|
|
rateLimitMu.Unlock()
|
|
|
|
// Test non-existent role - should be denied for security
|
|
suite.Run("non-existent role", func() {
|
|
allowed := rateLimitedRequest("test-user-1", "non-existent-role")
|
|
suite.False(allowed, "Unknown roles should be denied for security")
|
|
})
|
|
|
|
// Test admin role (high limit)
|
|
suite.Run("admin role within limit", func() {
|
|
allowed := rateLimitedRequest("admin-user", "admin")
|
|
suite.True(allowed, "Admin should be within rate limit")
|
|
})
|
|
|
|
// Test user role (low limit)
|
|
suite.Run("user role within limit", func() {
|
|
// First request should be allowed
|
|
allowed := rateLimitedRequest("regular-user", "user")
|
|
suite.True(allowed, "First request should be within rate limit")
|
|
|
|
// Second request should be allowed
|
|
allowed = rateLimitedRequest("regular-user", "user")
|
|
suite.True(allowed, "Second request should be within rate limit")
|
|
|
|
// Third request should exceed limit
|
|
allowed = rateLimitedRequest("regular-user", "user")
|
|
suite.False(allowed, "Third request should exceed rate limit")
|
|
})
|
|
}
|
|
|
|
func (suite *Tests) Test_RateLimitConfig_UnmarshalJSON() {
|
|
// Test unmarshaling of string-based intervals
|
|
suite.Run("unmarshal string intervals", func() {
|
|
// Test JSON with string-based intervals
|
|
jsonString := `{
|
|
"ratelimit": {
|
|
"admin": {
|
|
"req": 100,
|
|
"interval": "second"
|
|
},
|
|
"guest": {
|
|
"req": 5,
|
|
"interval": "minute"
|
|
},
|
|
"user": {
|
|
"req": 1000,
|
|
"interval": "hour"
|
|
},
|
|
"service": {
|
|
"req": 10000,
|
|
"interval": "day"
|
|
},
|
|
"custom": {
|
|
"req": 50,
|
|
"interval": "5s"
|
|
}
|
|
}
|
|
}`
|
|
|
|
var config struct {
|
|
RateLimit map[string]RateLimitConfig `json:"ratelimit"`
|
|
}
|
|
|
|
err := json.Unmarshal([]byte(jsonString), &config)
|
|
suite.NoError(err)
|
|
|
|
// Verify correct parsing of intervals
|
|
suite.Equal(time.Second, config.RateLimit["admin"].Interval)
|
|
suite.Equal(time.Minute, config.RateLimit["guest"].Interval)
|
|
suite.Equal(time.Hour, config.RateLimit["user"].Interval)
|
|
suite.Equal(24*time.Hour, config.RateLimit["service"].Interval)
|
|
suite.Equal(5*time.Second, config.RateLimit["custom"].Interval)
|
|
|
|
// Verify req values
|
|
suite.Equal(100, config.RateLimit["admin"].Req)
|
|
suite.Equal(5, config.RateLimit["guest"].Req)
|
|
})
|
|
|
|
// Test unmarshaling of invalid interval formats
|
|
suite.Run("unmarshal invalid intervals", func() {
|
|
// Test with an invalid interval format
|
|
jsonString := `{
|
|
"req": 100,
|
|
"interval": "invalid_format"
|
|
}`
|
|
|
|
var config RateLimitConfig
|
|
err := json.Unmarshal([]byte(jsonString), &config)
|
|
suite.Error(err)
|
|
suite.Contains(err.Error(), "invalid duration format")
|
|
})
|
|
|
|
// Test unmarshaling of numeric intervals
|
|
suite.Run("unmarshal numeric intervals", func() {
|
|
// Test with a numeric interval (seconds)
|
|
jsonString := `{
|
|
"req": 100,
|
|
"interval": 60
|
|
}`
|
|
|
|
var config RateLimitConfig
|
|
err := json.Unmarshal([]byte(jsonString), &config)
|
|
suite.NoError(err)
|
|
suite.Equal(60*time.Second, config.Interval)
|
|
})
|
|
}
|
|
|
|
func TestRateLimitConfigUnmarshalKeepsBurstAndEndpoints(t *testing.T) {
|
|
// Regression: the custom UnmarshalJSON temp struct used to carry only
|
|
// interval/req, silently dropping the configured burst and endpoints
|
|
// fields. A config with "burst" or "endpoints" must keep them.
|
|
raw := `{"interval":"minute","req":100,"burst":250,"endpoints":["/v1/graphql"]}`
|
|
var rc RateLimitConfig
|
|
if err := json.Unmarshal([]byte(raw), &rc); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if rc.Interval != time.Minute {
|
|
t.Errorf("interval = %v, want %v", rc.Interval, time.Minute)
|
|
}
|
|
if rc.Req != 100 {
|
|
t.Errorf("req = %d, want 100", rc.Req)
|
|
}
|
|
if rc.Burst != 250 {
|
|
t.Errorf("burst = %d, want 250", rc.Burst)
|
|
}
|
|
if len(rc.Endpoints) != 1 || rc.Endpoints[0] != "/v1/graphql" {
|
|
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)
|
|
}
|
|
})
|
|
}
|