mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-24 06:48:09 +00:00
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:
+127
-15
@@ -15,17 +15,21 @@ import (
|
||||
// RateLimitConfig holds the rate limit configuration for a role
|
||||
type RateLimitConfig struct {
|
||||
RateCounterTicker *goratecounter.RateCounter
|
||||
Endpoints []string `json:"endpoints,omitempty"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Req int `json:"req"`
|
||||
Burst int `json:"burst,omitempty"`
|
||||
// burstCounter enforces Burst over a short trailing window, independent
|
||||
// of RateCounterTicker's sustained-rate window. Nil when Burst <= 0
|
||||
// (burst disabled). See burstWindow and checkRateLimit for semantics.
|
||||
burstCounter *burstWindow
|
||||
Endpoints []string `json:"endpoints,omitempty"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Req int `json:"req"`
|
||||
Burst int `json:"burst,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling for RateLimitConfig
|
||||
func (r *RateLimitConfig) UnmarshalJSON(data []byte) error {
|
||||
// Use a temporary struct to unmarshal the JSON data.
|
||||
// It must carry every configurable field: Burst (used by the burst
|
||||
// allowance at the top of checkRateLimit) and Endpoints would otherwise be
|
||||
// allowance in checkRateLimit) and Endpoints would otherwise be
|
||||
// silently dropped from the loaded config and left at their zero values.
|
||||
type RateLimitConfigTemp struct {
|
||||
Interval any `json:"interval"`
|
||||
@@ -82,6 +86,72 @@ var (
|
||||
rateLimitConfigAtomic atomic.Value
|
||||
)
|
||||
|
||||
// burstWindowFraction sets the short burst window as a fraction of the
|
||||
// sustained Interval: window = Interval / burstWindowFraction. Not
|
||||
// specified by any upstream spec; chosen so the burst window is clearly
|
||||
// shorter than the sustained window while remaining simple to reason about.
|
||||
const burstWindowFraction = 10
|
||||
|
||||
// minBurstWindow floors the short burst window so a very small Interval
|
||||
// (for example a few milliseconds) still leaves the burst window a usable,
|
||||
// non-degenerate duration.
|
||||
const minBurstWindow = 10 * time.Millisecond
|
||||
|
||||
// burstWindowDuration derives the short burst-window length from the
|
||||
// sustained Interval. See burstWindowFraction and minBurstWindow.
|
||||
func burstWindowDuration(interval time.Duration) time.Duration {
|
||||
w := interval / burstWindowFraction
|
||||
if w < minBurstWindow {
|
||||
w = minBurstWindow
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// burstWindow is a minimal, clock-injectable, trailing-window request
|
||||
// counter used to enforce RateLimitConfig.Burst independently of the
|
||||
// sustained-rate RateCounterTicker.
|
||||
//
|
||||
// [email protected] cannot provide this second, shorter window
|
||||
// cleanly: the window length lives on *RateCounter itself, not per named
|
||||
// Counter (helpers.go: GetRate -> c.parent.interval.Seconds()), so two
|
||||
// different window lengths need two separate RateCounter instances. Each
|
||||
// instance drives its own background goroutine off wall-clock time.Now with
|
||||
// no seam to inject a fake clock (helpers.go: start(), cleanUpOldValues(),
|
||||
// addValue() all call time.Now() directly). That would force any test of
|
||||
// window rollover to sleep on real time. burstWindow is implemented here
|
||||
// instead, with a swappable clock, so tests can advance time deterministically.
|
||||
type burstWindow struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
window time.Duration
|
||||
hits []time.Time
|
||||
}
|
||||
|
||||
// newBurstWindow returns a burstWindow that tracks hits over the trailing
|
||||
// duration window, using the real wall clock.
|
||||
func newBurstWindow(window time.Duration) *burstWindow {
|
||||
return &burstWindow{now: time.Now, window: window}
|
||||
}
|
||||
|
||||
// recordAndCount records a hit at the current time, drops hits older than
|
||||
// the trailing window, and returns the resulting hit count within the
|
||||
// window. Safe for concurrent use.
|
||||
func (b *burstWindow) recordAndCount() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
now := b.now()
|
||||
cutoff := now.Add(-b.window)
|
||||
live := b.hits[:0]
|
||||
for _, t := range b.hits {
|
||||
if t.After(cutoff) {
|
||||
live = append(live, t)
|
||||
}
|
||||
}
|
||||
live = append(live, now)
|
||||
b.hits = live
|
||||
return len(b.hits)
|
||||
}
|
||||
|
||||
// Variable to hold the current load config function - allows for testing
|
||||
var loadConfigFunc = loadConfigFromPath
|
||||
|
||||
@@ -171,6 +241,12 @@ func loadConfigFromPath(path string) error {
|
||||
Interval: value.Interval,
|
||||
})
|
||||
|
||||
// Burst disabled (<=0) leaves burstCounter nil, so checkRateLimit
|
||||
// performs the Req-only check exactly as before burst existed.
|
||||
if value.Burst > 0 {
|
||||
value.burstCounter = newBurstWindow(burstWindowDuration(value.Interval))
|
||||
}
|
||||
|
||||
if cfg.LogLevel == "DEBUG" {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Setting ratelimit config for role",
|
||||
@@ -226,7 +302,34 @@ func rateLimitedRequest(userID, userRole string) bool {
|
||||
return checkRateLimit(userID, userRole, roleConfig, "")
|
||||
}
|
||||
|
||||
// checkRateLimit performs the actual rate limit check
|
||||
// checkRateLimit performs the actual rate limit check.
|
||||
//
|
||||
// Two independent gates apply, both must pass:
|
||||
//
|
||||
// 1. Sustained: tickerRate (requests measured over the full Interval) must
|
||||
// stay at or below Req. This is the only gate when Burst <= 0, and its
|
||||
// comparison is unchanged from before the burst fix.
|
||||
// 2. Burst (only when roleConfig.burstCounter != nil, i.e. Burst > 0): the
|
||||
// raw request count within a short trailing window (see
|
||||
// burstWindowDuration), much shorter than Interval, must stay at or
|
||||
// below Burst.
|
||||
//
|
||||
// Because the burst window is short relative to Interval, a client can
|
||||
// legally land up to Burst requests in a tight cluster - more than a
|
||||
// steady Req-paced stream would deliver in that same short span - as long
|
||||
// as the sustained gate still holds over the full Interval. That is what
|
||||
// makes burst > req a real spike allowance instead of a no-op. Burst is a
|
||||
// short-window ceiling layered ON TOP of the sustained Req rate, not a
|
||||
// replacement for it. burst < req is an unusual config: the burst gate
|
||||
// becomes the tighter, binding constraint whenever requests cluster in
|
||||
// time, but the sustained cap for evenly-spread traffic is still Req
|
||||
// alone. The pre-fix code compared the SAME sustained-window tickerRate
|
||||
// against both Burst and Req, so when burst < req its Burst check tripped
|
||||
// first and Burst was effectively the sustained cap too. This gate's
|
||||
// Req-only sustained check can therefore allow MORE through than the
|
||||
// pre-fix code did for evenly-spread traffic under a burst < req config.
|
||||
// In both cases the burst gate never allows more than Burst requests
|
||||
// within its own window (fail-safe).
|
||||
func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoint string) bool {
|
||||
roleConfig.RateCounterTicker.Incr(1)
|
||||
tickerRate := roleConfig.RateCounterTicker.GetRate()
|
||||
@@ -245,15 +348,6 @@ func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoin
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
|
||||
// Check burst limit if configured
|
||||
if roleConfig.Burst > 0 && tickerRate > float64(roleConfig.Burst) {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Burst limit exceeded",
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if tickerRate > float64(roleConfig.Req) {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit exceeded",
|
||||
@@ -262,5 +356,23 @@ func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoin
|
||||
return false
|
||||
}
|
||||
|
||||
if roleConfig.burstCounter != nil {
|
||||
burstCount := roleConfig.burstCounter.recordAndCount()
|
||||
burstLogDetails := map[string]any{
|
||||
"user_role": userRole,
|
||||
"user_id": userID,
|
||||
"burst_count": burstCount,
|
||||
"config_burst": roleConfig.Burst,
|
||||
"endpoint": endpoint,
|
||||
}
|
||||
if burstCount > roleConfig.Burst {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Burst limit exceeded",
|
||||
Pairs: map[string]any{"log_details": burstLogDetails},
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user