Compare commits

...
6 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub 2271aaff53 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-11-07 03:04:15 +00:00
github-actions[bot]andGitHub 9c271cdd4c chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-11-06 03:05:19 +00:00
github-actions[bot]andGitHub d3d0b3f7c2 chore(deps): update module github.com/avast/retry-go/v5 to v5.0.1
Automated dependency update. All tests passed.
2026-11-05 03:05:21 +00:00
github-actions[bot]andGitHub e3066ad7f0 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-11-04 03:05:05 +00:00
github-actions[bot]andGitHub e138b44d00 chore(deps): update module go.yaml.in/yaml/v3 to v3.0.6
Automated dependency update. All tests passed.
2026-11-03 03:05:12 +00:00
lukaszraczylo 3a6403c30b fix(ratelimit): bound memory and CPU per request
The sustained gate stored one entry for every attempt, denied ones too, and summed all entries on each request. A flood grew memory with rate times interval and made each request slower. Both gates now keep only the newest limit plus one attempt times. A Lean proof shows this decides the same as the full count. One check dropped from 29,502 ns and 11 allocations to 43 ns and none. The go-ratecounter dependency is gone.
2026-11-03 00:55:35 +00:00
7 changed files with 190 additions and 147 deletions
+8 -9
View File
@@ -5,12 +5,12 @@ go 1.26.0
toolchain go1.27.3
require (
github.com/MicahParks/keyfunc/v3 v3.8.2
github.com/VictoriaMetrics/metrics v1.44.1
github.com/MicahParks/keyfunc/v3 v3.9.1
github.com/VictoriaMetrics/metrics v1.45.0
github.com/alicebob/miniredis/v2 v2.40.0
github.com/avast/retry-go/v5 v5.0.0
github.com/avast/retry-go/v5 v5.1.1
github.com/goccy/go-json v0.12.0
github.com/gofiber/contrib/v3/websocket v1.2.10
github.com/gofiber/contrib/v3/websocket v1.2.12
github.com/gofiber/fiber/v3 v3.6.1
github.com/gofrs/flock v0.13.1
github.com/golang-jwt/jwt/v5 v5.3.1
@@ -20,7 +20,6 @@ require (
github.com/graphql-go/graphql v0.8.1
github.com/jackc/pgx/v5 v5.12.0
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9
github.com/lukaszraczylo/go-ratecounter v0.1.13
github.com/lukaszraczylo/go-simple-graphql v1.2.89
github.com/lukaszraczylo/oss-telemetry v0.2.5
github.com/redis/go-redis/v9 v9.23.0
@@ -36,15 +35,15 @@ require (
)
require (
github.com/MicahParks/jwkset v0.11.3 // indirect
github.com/MicahParks/jwkset v0.11.4 // indirect
github.com/avast/retry-go/v4 v4.7.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofiber/schema v1.9.2 // indirect
github.com/gofiber/utils/v2 v2.6.3 // indirect
github.com/gofiber/schema v1.9.3 // indirect
github.com/gofiber/utils/v2 v2.6.4 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -66,7 +65,7 @@ require (
go.opentelemetry.io/otel/metric v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.1 // indirect
go.uber.org/atomic v1.12.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go.yaml.in/yaml/v3 v3.0.6 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.62.0 // indirect
golang.org/x/sync v0.24.0 // indirect
+18 -20
View File
@@ -1,15 +1,15 @@
github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPRdkQ=
github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.2 h1:eydEwk/pBAVrDIpmFfB/gkCcrp++xQ7YYXirrI2zlWE=
github.com/MicahParks/keyfunc/v3 v3.8.2/go.mod h1:T4snFPe26GwMg45bBAdM5P6qWQyLxZHLwBhxR/9PnCs=
github.com/VictoriaMetrics/metrics v1.44.1 h1:OMyGrMkhI3hnq+IpJ1gZK9VhXcsOjUX1CoOFv//bJ0I=
github.com/VictoriaMetrics/metrics v1.44.1/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
github.com/MicahParks/jwkset v0.11.4 h1:sm6pqlEpa1YDu82Hoxho/O+Y291lPcksNImVIsV74gg=
github.com/MicahParks/jwkset v0.11.4/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.9.1 h1:10dlh2kS3kRtfj5x2bokCxLhTpmIpf3WtsvBdJ2TfOo=
github.com/MicahParks/keyfunc/v3 v3.9.1/go.mod h1:h8DMhdulFa3yjG1q74FqaehNDekB18HrVCXPl9Raa4I=
github.com/VictoriaMetrics/metrics v1.45.0 h1:Tv8dZRAjkO90rBTxYrWOHUWFBHsAKdKcZYmS/Wbo2EU=
github.com/VictoriaMetrics/metrics v1.45.0/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
github.com/avast/retry-go/v5 v5.1.1 h1:xinK2CJs8zMswWeFFSYeqBbqx9RskWDDdzybrFrz4Q8=
github.com/avast/retry-go/v5 v5.1.1/go.mod h1:kb/MOVQOBAxjo+kIre6StLXJro2ZPAQh5IO/6wICTjI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -21,8 +21,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/fxamacker/cbor/v2 v2.10.0 h1:HVF3T5vYwLqO81ebPirWEPNv2SMrUMD3HLnKgEr6uWE=
github.com/fxamacker/cbor/v2 v2.10.0/go.mod h1:uSwlToFuT9BZAKRxxVm3qQC8DR4tlWl7D6pf20iRYH4=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -32,14 +32,14 @@ github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms=
github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE=
github.com/gofiber/contrib/v3/websocket v1.2.10 h1:tUyRcHSH9GUsh6WO03Xn+Kjvt3850W6raF6WPngdJLE=
github.com/gofiber/contrib/v3/websocket v1.2.10/go.mod h1:O6MOrNHCVdCrrstdLrYmRIL0eJy2N87Wa/y2dszuxvQ=
github.com/gofiber/contrib/v3/websocket v1.2.12 h1:3jpKRoqTY4rdq5lAeTXc762JtKbvrWuxX4JkrNS3EF8=
github.com/gofiber/contrib/v3/websocket v1.2.12/go.mod h1:tOI7AiJKlz9XbGw7BLv8tAkwikdNOkoNWZ7lMPOLokc=
github.com/gofiber/fiber/v3 v3.6.1 h1:wYjbgqpJm6VErIVLSGzGpX7lLZyTNgElsj8nQ1i+iFo=
github.com/gofiber/fiber/v3 v3.6.1/go.mod h1:+nxjBal3gw0EPf3gqK5EmtxaxTL01+zbSTagFletlDI=
github.com/gofiber/schema v1.9.2 h1:Gzw2PF1K5DpWgG2L51/9t9dkmxw4kqK6auKMvgD9mIU=
github.com/gofiber/schema v1.9.2/go.mod h1:219nYAgzIEBWl/aHCyhQtFoXBvfLjsC6+FI3DUN1+Ck=
github.com/gofiber/utils/v2 v2.6.3 h1:VlnOB89rFnonlXoYOkESOkXtIkAw4CBBwpBHu+/a/YQ=
github.com/gofiber/utils/v2 v2.6.3/go.mod h1:Mq3OXwyBPxORb3jPafy6Swki4/TJJW9TDPHeerXkXpg=
github.com/gofiber/schema v1.9.3 h1:KbKui12hYcvv6Ht0iUCOPrFU4KnrxX4D/h74TeE9Q/E=
github.com/gofiber/schema v1.9.3/go.mod h1:TmN3rIGytK690YcZZ6BSq8OOswWcmRhLYcReauIfC68=
github.com/gofiber/utils/v2 v2.6.4 h1:VsC3hEFZXk2JO6I/+FZ1OdZzMX4COPgwwrFaDAey3+o=
github.com/gofiber/utils/v2 v2.6.4/go.mod h1:SpYvWEQlYmZzzpuSfQX0gUiPSoxpOKldiEmVDzdl8Ds=
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -72,8 +72,6 @@ github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV91
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
github.com/lukaszraczylo/go-ratecounter v0.1.13 h1:p1r53oyMb9ZnMbqub6IF4CWhURFiz7k967I0GbUyQMA=
github.com/lukaszraczylo/go-ratecounter v0.1.13/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
github.com/lukaszraczylo/go-simple-graphql v1.2.89 h1:Xbu1Ny+a0lT2Sr2SaSC8mcHmGQDwGD4TJKk4DDd+PwA=
github.com/lukaszraczylo/go-simple-graphql v1.2.89/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s=
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
@@ -144,8 +142,8 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
+25 -80
View File
@@ -8,17 +8,17 @@ import (
"time"
"github.com/goccy/go-json"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
// RateLimitConfig holds the rate limit configuration for a role
type RateLimitConfig struct {
RateCounterTicker *goratecounter.RateCounter
// sustained enforces Req over the full Interval.
sustained *attemptWindow
// burstCounter enforces Burst over a short trailing window, independent
// of RateCounterTicker's sustained-rate window. Nil when Burst <= 0
// of the sustained window. Nil when Burst <= 0
// (burst disabled). See burstWindow and checkRateLimit for semantics.
burstCounter *burstWindow
burstCounter *attemptWindow
Endpoints []string `json:"endpoints,omitempty"`
Interval time.Duration `json:"interval"`
Req int `json:"req"`
@@ -107,51 +107,6 @@ func burstWindowDuration(interval time.Duration) time.Duration {
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
@@ -237,14 +192,12 @@ func loadConfigFromPath(path string) error {
newRateLimits := make(map[string]RateLimitConfig, len(config.RateLimit))
for key, value := range config.RateLimit {
value.RateCounterTicker = goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
Interval: value.Interval,
})
value.sustained = newAttemptWindow(value.Interval, value.Req)
// 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))
value.burstCounter = newAttemptWindow(burstWindowDuration(value.Interval), value.Burst)
}
if cfg.LogLevel == "DEBUG" {
@@ -279,7 +232,7 @@ func rateLimitedRequest(userID, userRole string) bool {
// Try to get config from atomic value first for better performance
if configInterface := rateLimitConfigAtomic.Load(); configInterface != nil {
if config, ok := configInterface.(map[string]RateLimitConfig); ok {
if roleConfig, exists := config[userRole]; exists && roleConfig.RateCounterTicker != nil {
if roleConfig, exists := config[userRole]; exists && roleConfig.sustained != nil {
return checkRateLimit(userID, userRole, roleConfig, "")
}
}
@@ -290,7 +243,7 @@ func rateLimitedRequest(userID, userRole string) bool {
roleConfig, ok := rateLimits[userRole]
rateLimitMu.RUnlock()
if !ok || roleConfig.RateCounterTicker == nil {
if !ok || roleConfig.sustained == nil {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Rate limit role not found or ticker not initialized - defaulting to deny",
Pairs: map[string]any{"user_role": userRole},
@@ -315,45 +268,37 @@ func rateLimitedRequest(userID, userRole string) bool {
// sustained count. Burst >= Req therefore never rejects anything the sustained
// gate admits; Burst only takes effect when it is below Req.
func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoint string) bool {
roleConfig.RateCounterTicker.Incr(1)
tickerRate := roleConfig.RateCounterTicker.Get()
debug := cfg.Logger.IsLevelEnabled(libpack_logger.LEVEL_DEBUG)
logDetails := map[string]any{
if roleConfig.sustained.recordAndExceeds() {
if debug {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit exceeded",
Pairs: map[string]any{
"user_role": userRole,
"user_id": userID,
"rate": tickerRate,
"config_rate": roleConfig.Req,
"interval": roleConfig.Interval,
"endpoint": endpoint,
},
})
}
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit ticker",
Pairs: map[string]any{"log_details": logDetails},
})
if tickerRate > int64(roleConfig.Req) {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit exceeded",
Pairs: map[string]any{"log_details": logDetails},
})
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 {
if roleConfig.burstCounter.recordAndExceeds() {
if debug {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Burst limit exceeded",
Pairs: map[string]any{"log_details": burstLogDetails},
Pairs: map[string]any{
"user_role": userRole,
"user_id": userID,
"config_burst": roleConfig.Burst,
"endpoint": endpoint,
},
})
}
return false
}
}
+7 -16
View File
@@ -10,7 +10,6 @@ import (
"time"
"github.com/goccy/go-json"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
@@ -65,8 +64,8 @@ func (suite *Tests) Test_loadRatelimitConfig() {
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)
suite.NotNil(rateLimits["admin"].sustained)
suite.NotNil(rateLimits["user"].sustained)
})
// Test loading config from non-existent path
@@ -154,21 +153,15 @@ func (suite *Tests) Test_rateLimitedRequest() {
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,
sustained: newAttemptWindow(1*time.Second, 100),
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,
sustained: newAttemptWindow(1*time.Second, 2),
Interval: 1 * time.Second,
Req: 2, // Set very low for testing
}
@@ -327,19 +320,17 @@ func (f *fakeClock) Advance(d time.Duration) {
}
// newBurstTestRoleConfig mirrors the RateLimitConfig construction performed
// by loadConfigFromPath: a real RateCounterTicker for the sustained check,
// by loadConfigFromPath: a real sustained window for the Req 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,
}),
sustained: newAttemptWindow(interval, req),
Interval: interval,
Req: req,
Burst: burst,
}
if burst > 0 {
rc.burstCounter = newBurstWindow(burstWindowDuration(interval))
rc.burstCounter = newAttemptWindow(burstWindowDuration(interval), burst)
}
return rc
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"sync"
"time"
)
// attemptWindow decides "more than limit attempts in the trailing window" while
// storing only the newest limit+1 attempt times. The attempt number limit+1
// counted back from now lies inside the window exactly when the full count
// exceeds limit (see theorems/Theorems/SlidingWindow.lean), so memory and time
// per attempt stay constant however hard a client floods.
type attemptWindow struct {
now func() time.Time
base time.Time // monotonic origin for stored offsets
ring []int64 // offsets from base, grows to capacity then wraps
mu sync.Mutex
window time.Duration
head int // index of the oldest stored attempt once the ring is full
size int // limit+1: attempts kept; the ring grows lazily up to this
}
func newAttemptWindow(window time.Duration, limit int) *attemptWindow {
return &attemptWindow{
now: time.Now,
base: time.Now(),
window: window,
size: max(limit, 0) + 1,
}
}
// recordAndExceeds records an attempt now and reports whether the attempts in
// the trailing window, this one included, outnumber the limit.
func (w *attemptWindow) recordAndExceeds() bool {
w.mu.Lock()
defer w.mu.Unlock()
t := int64(w.now().Sub(w.base))
if len(w.ring) < w.size {
w.ring = append(w.ring, t)
} else {
w.ring[w.head] = t
w.head = (w.head + 1) % len(w.ring)
}
if len(w.ring) < w.size {
return false
}
return w.ring[w.head] > t-int64(w.window)
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"math/rand"
"testing"
"time"
)
// naiveExceeds is the exact reference: count every attempt in the trailing window.
func naiveExceeds(attempts []time.Time, now time.Time, window time.Duration, limit int) bool {
cutoff := now.Add(-window)
n := 0
for _, t := range attempts {
if t.After(cutoff) {
n++
}
}
return n > limit
}
// Pins theorems/Theorems/SlidingWindow.lean: storing the newest limit+1 attempts decides like the full count.
func TestAttemptWindow_MatchesExactCount(t *testing.T) {
for _, limit := range []int{0, 1, 3, 10} {
rng := rand.New(rand.NewSource(int64(limit) + 1))
window := 10 * time.Second
fc := newFakeClock(time.Unix(1_000_000, 0))
w := newAttemptWindow(window, limit)
w.now = fc.Now
w.base = fc.Now()
var attempts []time.Time
for i := 0; i < 5000; i++ {
fc.Advance(time.Duration(rng.Intn(3000)) * time.Millisecond)
attempts = append(attempts, fc.Now())
want := naiveExceeds(attempts, fc.Now(), window, limit)
if got := w.recordAndExceeds(); got != want {
t.Fatalf("limit=%d step=%d: got %v, want %v", limit, i, got, want)
}
}
}
}
func TestAttemptWindow_StorageIsBounded(t *testing.T) {
const limit = 10
fc := newFakeClock(time.Unix(1_000_000, 0))
w := newAttemptWindow(time.Hour, limit)
w.now = fc.Now
w.base = fc.Now()
for i := 0; i < 100_000; i++ {
w.recordAndExceeds()
fc.Advance(time.Millisecond)
}
if got := len(w.ring); got > limit+1 {
t.Fatalf("stored %d timestamps, want <= %d", got, limit+1)
}
}
func BenchmarkAttemptWindow(b *testing.B) {
w := newAttemptWindow(time.Hour, 1000)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w.recordAndExceeds()
}
}
+1 -4
View File
@@ -11,7 +11,6 @@ import (
fiber "github.com/gofiber/fiber/v3"
gorillaws "github.com/gorilla/websocket"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
"github.com/stretchr/testify/assert"
@@ -652,9 +651,7 @@ func TestHandleWebSocketOrDefault_RateLimitedUserRejected(t *testing.T) {
rateLimitMu.Lock()
rateLimits = map[string]RateLimitConfig{
defaultValue: {
RateCounterTicker: goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
Interval: 1 * time.Second,
}),
sustained: newAttemptWindow(1*time.Second, 0),
Interval: 1 * time.Second,
Req: 0,
},