From bb47ab386cee6cac6456c999bea7c5522571aaea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pereira?= Date: Fri, 17 Apr 2026 15:59:15 -0300 Subject: [PATCH 01/29] build(deps): bump google.golang.org/grpc from v1.74.2 to v1.80.0 (#1111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off by: João Pereira Upgrades the gRPC dependency from v1.74.2 to v1.80.0, along with its transitive dependency updates (golang.org/x/net, google.golang.org/protobuf, genproto, go-control-plane, etc.). Signed-off-by: João Pereira Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Yusof Ganji --- go.mod | 28 ++++++++++++++-------------- go.sum | 58 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/go.mod b/go.mod index 4e1c6591..fcc6d676 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/alicebob/miniredis/v2 v2.33.0 github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 github.com/coocood/freecache v1.2.4 - github.com/envoyproxy/go-control-plane v0.13.4 - github.com/envoyproxy/go-control-plane/envoy v1.32.4 + github.com/envoyproxy/go-control-plane v0.14.0 + github.com/envoyproxy/go-control-plane/envoy v1.36.0 github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428 github.com/go-kit/log v0.2.1 github.com/golang/mock v1.6.0 @@ -23,7 +23,7 @@ require ( github.com/lyft/gostats v0.4.14 github.com/mediocregopher/radix/v4 v4.1.4 github.com/prometheus/client_golang v1.19.1 - github.com/prometheus/client_model v0.6.1 + github.com/prometheus/client_model v0.6.2 github.com/prometheus/statsd_exporter v0.26.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 @@ -34,27 +34,27 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/trace v1.40.0 - golang.org/x/net v0.42.0 - google.golang.org/grpc v1.74.2 - google.golang.org/protobuf v1.36.10 + golang.org/x/net v0.49.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 ) require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/Microsoft/go-winio v0.5.0 // indirect github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.48.0 // indirect @@ -64,10 +64,10 @@ require ( github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.27.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 076d2334..02be9a68 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/datadog-go/v5 v5.5.0 h1:G5KHeB8pWBNXT4Jtw0zAkhdxEAWSpWH00geHI6LDrKU= @@ -23,8 +23,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coocood/freecache v1.2.4 h1:UdR6Yz/X1HW4fZOuH0Z94KwG851GWOSknua5VUbb/5M= github.com/coocood/freecache v1.2.4/go.mod h1:RBUWa/Cy+OHdfTGFEhEuE1pMCMX51Ncizj7rthiQ3vk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -33,15 +33,15 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428 h1:AkYCu5lno5OO4HujsRKIDJ1XnqymM3saXdBoQ+lpWhk= github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428/go.mod h1:EWppLjKDYW2tcLcDYvR1kDWCZWeMVxH/0v6vaYXo0eA= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= @@ -78,8 +78,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= @@ -114,8 +114,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= @@ -171,8 +171,8 @@ go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4A go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -199,8 +199,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -227,8 +227,8 @@ golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -243,24 +243,26 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From 759083401cf47c129b96b6d4bae137f1ecf481ef Mon Sep 17 00:00:00 2001 From: yanavlasov Date: Sun, 19 Apr 2026 14:17:03 -0400 Subject: [PATCH 02/29] Send user defined metadata to the client (#1112) Signed-off-by: Yan Avlasov Signed-off-by: Yusof Ganji --- src/config/config.go | 2 + src/config/config_impl.go | 74 ++++++++++++++++ src/service/ratelimit.go | 58 ++++++++----- src/service/ratelimit_test.go | 59 ++++++++----- test/config/config_test.go | 53 ++++++++++++ test/config/metadata.yaml | 28 ++++++ test/service/ratelimit_test.go | 154 ++++++++++++++++++++++++++++++--- 7 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 test/config/metadata.yaml diff --git a/src/config/config.go b/src/config/config.go index 6e31883d..fd861926 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -4,6 +4,7 @@ import ( pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "golang.org/x/net/context" + "google.golang.org/protobuf/types/known/structpb" "github.com/envoyproxy/ratelimit/src/stats" ) @@ -29,6 +30,7 @@ type RateLimit struct { // ShareThresholdKeyPattern is a slice of wildcard patterns for descriptor entries // The slice index corresponds to the descriptor entry index. ShareThresholdKeyPattern []string + Metadata *structpb.Struct } // Interface for interacting with a loaded rate limit config. diff --git a/src/config/config_impl.go b/src/config/config_impl.go index dd3c5b5e..c2b48e3f 100644 --- a/src/config/config_impl.go +++ b/src/config/config_impl.go @@ -8,6 +8,7 @@ import ( pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" logger "github.com/sirupsen/logrus" "golang.org/x/net/context" + "google.golang.org/protobuf/types/known/structpb" "gopkg.in/yaml.v2" "github.com/envoyproxy/ratelimit/src/stats" @@ -35,6 +36,7 @@ type YamlDescriptor struct { DetailedMetric bool `yaml:"detailed_metric"` ValueToMetric bool `yaml:"value_to_metric"` ShareThreshold bool `yaml:"share_threshold"` + Metadata map[string]interface{} } type YamlRoot struct { @@ -56,6 +58,7 @@ type rateLimitDescriptor struct { valueToMetric bool shareThreshold bool wildcardPattern string // stores the wildcard pattern when share_threshold is true + metadata *structpb.Struct } type rateLimitDomain struct { @@ -84,6 +87,7 @@ var validKeys = map[string]bool{ "detailed_metric": true, "value_to_metric": true, "share_threshold": true, + "metadata": true, } // Create a new rate limit config entry. @@ -134,6 +138,64 @@ func newRateLimitConfigError(name string, err string) RateLimitConfigError { return RateLimitConfigError(fmt.Sprintf("%s: %s", name, err)) } +func convertMap(m map[interface{}]interface{}) map[string]interface{} { + res := make(map[string]interface{}) + for k, v := range m { + strKey := fmt.Sprintf("%v", k) + switch val := v.(type) { + case map[interface{}]interface{}: + res[strKey] = convertMap(val) + case []interface{}: + res[strKey] = convertSlice(val) + default: + res[strKey] = v + } + } + return res +} + +func convertSlice(s []interface{}) []interface{} { + res := make([]interface{}, len(s)) + for i, v := range s { + switch val := v.(type) { + case map[interface{}]interface{}: + res[i] = convertMap(val) + case []interface{}: + res[i] = convertSlice(val) + default: + res[i] = v + } + } + return res +} + +// Create envoyMetadata from YamlMetadata +// @param yamlMetadata supplies metadata parsed from config YAML +func parseMetadata(yamlMetadata map[string]interface{}) (*structpb.Struct, error) { + if len(yamlMetadata) == 0 { + return nil, nil + } + + convertedValue := make(map[string]interface{}) + for k, v := range yamlMetadata { + switch val := v.(type) { + case map[interface{}]interface{}: + convertedValue[k] = convertMap(val) + case []interface{}: + convertedValue[k] = convertSlice(val) + default: + convertedValue[k] = v + } + } + + pbStruct, err := structpb.NewStruct(convertedValue) + if err != nil { + return nil, err + } + + return pbStruct, nil +} + // wildcardMatch reports whether value matches a pre-split wildcard pattern. // parts is the pattern split on "*" at load time (e.g. ["path_bar", "baz", "qux"]). // Each * matches zero or more characters. No allocations are performed per call. @@ -268,6 +330,11 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p }) } + metadata, err := parseMetadata(descriptorConfig.Metadata) + if err != nil { + panic(newRateLimitConfigError(config.Name, fmt.Sprintf("error parsing metadata: %s", err.Error()))) + } + logger.Debugf( "loading descriptor: key=%s%s", newParentKey, rateLimitDebugString) newDescriptor := &rateLimitDescriptor{ @@ -277,6 +344,7 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p valueToMetric: descriptorConfig.ValueToMetric, shareThreshold: descriptorConfig.ShareThreshold, wildcardPattern: wildcardPattern, + metadata: metadata, } newDescriptor.loadDescriptors(config, newParentKey+".", descriptorConfig.Descriptors, statsManager) this.descriptors[finalKey] = newDescriptor @@ -298,6 +366,11 @@ func validateYamlKeys(fileName string, config_map map[interface{}]interface{}) { logger.Debug(errorText) panic(newRateLimitConfigError(fileName, errorText)) } + if k.(string) == "metadata" { + // Do not validate keys/values in the metadata, since they are arbitrary. If config is invalid the parsing fill fail + // later when it is converted to protobuf.Struct + continue + } switch v := v.(type) { case []interface{}: for _, e := range v { @@ -516,6 +589,7 @@ func (this *rateLimitConfigImpl) GetLimit( DetailedMetric: originalLimit.DetailedMetric, // Initialize ShareThresholdKeyPattern with correct length, empty strings for entries without share_threshold ShareThresholdKeyPattern: nil, + Metadata: nextDescriptor.metadata, } // Apply all tracked share_threshold patterns when we find the rate_limit // This works whether the rate_limit is at the wildcard level or deeper diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index f6ecd2e2..48e6a59a 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -207,7 +207,7 @@ func (this *service) shouldRateLimitWorker( var minimumDescriptor *pb.RateLimitResponse_DescriptorStatus = nil // Track quota mode violations for metadata - var quotaModeViolations []int + var passedDescriptors []int failedRateLimitDescriptors := 0 failedQuotaDescriptors := 0 totalQuotaDescriptors := 0 @@ -231,14 +231,15 @@ func (this *service) shouldRateLimitWorker( isQuotaMode := globalQuotaMode || (limitsToCheck[i] != nil && limitsToCheck[i].QuotaMode) if descriptorStatus.Code == pb.RateLimitResponse_OVER_LIMIT { if isQuotaMode { - // In quota mode: track the violation for metadata but keep response as OK - quotaModeViolations = append(quotaModeViolations, i) failedQuotaDescriptors += 1 } else { failedRateLimitDescriptors += 1 minimumDescriptor = descriptorStatus minLimitRemaining = 0 } + } else { + // Keep track of the descriptors that have passed + passedDescriptors = append(passedDescriptors, i) } if isQuotaMode { totalQuotaDescriptors += 1 @@ -270,14 +271,14 @@ func (this *service) shouldRateLimitWorker( // If response dynamic data enabled, set dynamic data on response. if this.responseDynamicMetadataEnabled { - response.DynamicMetadata = ratelimitToMetadata(request, quotaModeViolations, limitsToCheck) + response.DynamicMetadata = ratelimitToMetadata(request, passedDescriptors, limitsToCheck) } response.OverallCode = finalCode return response } -func ratelimitToMetadata(req *pb.RateLimitRequest, quotaModeViolations []int, limitsToCheck []*config.RateLimit) *structpb.Struct { +func ratelimitToMetadata(req *pb.RateLimitRequest, passedDescriptors []int, limitsToCheck []*config.RateLimit) *structpb.Struct { fields := make(map[string]*structpb.Value) // Domain @@ -301,26 +302,19 @@ func ratelimitToMetadata(req *pb.RateLimitRequest, quotaModeViolations []int, li fields["hitsAddend"] = structpb.NewNumberValue(float64(hitsAddend)) } - // Quota mode information - if len(quotaModeViolations) > 0 { - violationValues := make([]*structpb.Value, len(quotaModeViolations)) - for i, violationIndex := range quotaModeViolations { - violationValues[i] = structpb.NewNumberValue(float64(violationIndex)) + passedMetadata := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + for _, idx := range passedDescriptors { + if idx < len(limitsToCheck) { + limit := limitsToCheck[idx] + if limit != nil && limit.Metadata != nil { + mergeMetadata(passedMetadata, limit.Metadata) + } } - fields["quotaModeViolations"] = structpb.NewListValue(&structpb.ListValue{ - Values: violationValues, - }) } - // Check if any limits have quota mode enabled - quotaModeEnabled := false - for _, limit := range limitsToCheck { - if limit != nil && limit.QuotaMode { - quotaModeEnabled = true - break - } + if len(passedMetadata.GetFields()) > 0 { + fields["metadata"] = structpb.NewStructValue(passedMetadata) } - fields["quotaModeEnabled"] = structpb.NewBoolValue(quotaModeEnabled) return &structpb.Struct{Fields: fields} } @@ -355,6 +349,28 @@ func descriptorToStruct(descriptor *ratelimitv3.RateLimitDescriptor) *structpb.S return &structpb.Struct{Fields: fields} } +func mergeMetadata(dest *structpb.Struct, src *structpb.Struct) { + if src == nil { + return + } + for k, v := range src.GetFields() { + destVal, exists := dest.GetFields()[k] + if exists { + // If both are structs, merge them recursively + if destStruct := destVal.GetStructValue(); destStruct != nil { + if srcStruct := v.GetStructValue(); srcStruct != nil { + mergeMetadata(destStruct, srcStruct) + continue + } + } + // TODO(yanavlasov): add option to overwrite or add if typoe is a list + } else { + // Otherwise overwrite or add + dest.GetFields()[k] = v + } + } +} + func (this *service) rateLimitLimitHeader(descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { // Limit header only provides the mandatory part from the spec, the actual limit // the optional quota policy is currently not provided diff --git a/src/service/ratelimit_test.go b/src/service/ratelimit_test.go index 0f43b6fb..bb0234d7 100644 --- a/src/service/ratelimit_test.go +++ b/src/service/ratelimit_test.go @@ -16,11 +16,11 @@ import ( func TestRatelimitToMetadata(t *testing.T) { cases := []struct { - name string - req *pb.RateLimitRequest - quotaModeViolations []int - limitsToCheck []*config.RateLimit - expected string + name string + req *pb.RateLimitRequest + passedDescriptors []int + limitsToCheck []*config.RateLimit + expected string }{ { name: "Single descriptor with single entry, no quota violations", @@ -37,8 +37,8 @@ func TestRatelimitToMetadata(t *testing.T) { }, }, }, - quotaModeViolations: nil, - limitsToCheck: []*config.RateLimit{nil}, + passedDescriptors: nil, + limitsToCheck: []*config.RateLimit{nil}, expected: `{ "descriptors": [ { @@ -47,8 +47,7 @@ func TestRatelimitToMetadata(t *testing.T) { ] } ], - "domain": "fake-domain", - "quotaModeEnabled": false + "domain": "fake-domain" }`, }, { @@ -66,10 +65,15 @@ func TestRatelimitToMetadata(t *testing.T) { }, }, }, - quotaModeViolations: []int{0}, + passedDescriptors: []int{0}, limitsToCheck: []*config.RateLimit{ { QuotaMode: true, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_1"), + }, + }, }, }, expected: `{ @@ -81,8 +85,9 @@ func TestRatelimitToMetadata(t *testing.T) { } ], "domain": "quota-domain", - "quotaModeEnabled": true, - "quotaModeViolations": [0] + "metadata": { + "name": "service_1" + } }`, }, { @@ -116,16 +121,31 @@ func TestRatelimitToMetadata(t *testing.T) { }, }, }, - quotaModeViolations: []int{1, 2}, + passedDescriptors: []int{1, 2}, limitsToCheck: []*config.RateLimit{ { QuotaMode: false, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_1"), + }, + }, }, { QuotaMode: true, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_2"), + }, + }, }, { QuotaMode: true, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_3"), + }, + }, }, }, expected: `{ @@ -147,8 +167,9 @@ func TestRatelimitToMetadata(t *testing.T) { } ], "domain": "mixed-domain", - "quotaModeEnabled": true, - "quotaModeViolations": [1, 2] + "metadata": { + "name": "service_2" + } }`, }, { @@ -167,7 +188,7 @@ func TestRatelimitToMetadata(t *testing.T) { }, }, }, - quotaModeViolations: []int{0}, + passedDescriptors: []int{0}, limitsToCheck: []*config.RateLimit{ { QuotaMode: true, @@ -182,16 +203,14 @@ func TestRatelimitToMetadata(t *testing.T) { } ], "domain": "addend-domain", - "hitsAddend": 5, - "quotaModeEnabled": true, - "quotaModeViolations": [0] + "hitsAddend": 5 }`, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := ratelimitToMetadata(tc.req, tc.quotaModeViolations, tc.limitsToCheck) + got := ratelimitToMetadata(tc.req, tc.passedDescriptors, tc.limitsToCheck) expected := &structpb.Struct{} err := protojson.Unmarshal([]byte(tc.expected), expected) require.NoError(t, err) diff --git a/test/config/config_test.go b/test/config/config_test.go index 4b371fb6..2bd009a9 100644 --- a/test/config/config_test.go +++ b/test/config/config_test.go @@ -2206,5 +2206,58 @@ func TestWildcardStatsBehavior(t *testing.T) { }) } +func TestMetadata(t *testing.T) { + assert := assert.New(t) + stats := stats.NewStore(stats.NewNullSink(), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("metadata.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig.Dump() + assert.Equal(rlConfig.IsEmptyDomains(), false) + assert.Nil(rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{})) + assert.EqualValues(0, stats.NewCounter("test-domain.domain_not_found").Value()) + + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}, {Key: "subkey1", Value: "something"}}, + }) + rl.Stats.TotalHits.Inc() + rl.Stats.OverLimit.Inc() + rl.Stats.NearLimit.Inc() + rl.Stats.WithinLimit.Inc() + assert.EqualValues(5, rl.Limit.RequestsPerUnit) + assert.Equal(pb.RateLimitResponse_RateLimit_SECOND, rl.Limit.Unit) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.total_hits").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.over_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.near_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.within_limit").Value()) + + // Verify metadata for key1_value1.subkey1 + assert.NotNil(rl.Metadata) + nameVal, ok := rl.Metadata.GetFields()["name"] + assert.True(ok) + assert.Equal("service_1", nameVal.GetStringValue()) + + rl = rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "something"}}, + }) + rl.Stats.TotalHits.Inc() + rl.Stats.OverLimit.Inc() + rl.Stats.NearLimit.Inc() + rl.Stats.WithinLimit.Inc() + assert.EqualValues(20, rl.Limit.RequestsPerUnit) + assert.Equal(pb.RateLimitResponse_RateLimit_MINUTE, rl.Limit.Unit) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.total_hits").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.over_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.near_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.within_limit").Value()) + + assert.NotNil(rl.Metadata) + serviceVal, ok := rl.Metadata.GetFields()["service_with_quota"] + assert.True(ok) + assert.Equal("service_2", serviceVal.GetStructValue().GetFields()["name"].GetStringValue()) +} + // share_threshold parity (middle+trailing wildcards produce the same shared-counter // behaviour as trailing-only) is covered by TestShareThreshold Cases 5-7. diff --git a/test/config/metadata.yaml b/test/config/metadata.yaml new file mode 100644 index 00000000..cb1dadbc --- /dev/null +++ b/test/config/metadata.yaml @@ -0,0 +1,28 @@ +domain: test-domain +descriptors: + # Top level key/value with no default rate limit. + - key: key1 + value: value1 + descriptors: + # 2nd level key only with default rate limit. + - key: subkey1 + rate_limit: + unit: second + requests_per_unit: 5 + metadata: + name: service_1 + + # 2nd level key/value with limit. Specific override at 2nd level. + - key: subkey1 + value: subvalue1 + rate_limit: + unit: second + requests_per_unit: 10 + + - key: key2 + rate_limit: + unit: minute + requests_per_unit: 20 + metadata: + service_with_quota: + name: service_2 diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index a949f9a6..c77ba6b8 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -22,6 +22,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/protobuf/types/known/structpb" "github.com/envoyproxy/ratelimit/src/trace" @@ -719,7 +720,7 @@ func TestServiceGlobalQuotaMode(test *testing.T) { t.assert.Nil(err) } -func TestServiceQuotaModeWithMetadata(test *testing.T) { +func TestMetadataReturnedForPassedDescriptors(test *testing.T) { os.Setenv("QUOTA_MODE", "true") os.Setenv("RESPONSE_DYNAMIC_METADATA", "true") defer func() { @@ -749,6 +750,9 @@ func TestServiceQuotaModeWithMetadata(test *testing.T) { config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, true, "", nil, false), } + limits[0].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_1")}} + limits[1].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_2")}} + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( @@ -757,23 +761,149 @@ func TestServiceQuotaModeWithMetadata(test *testing.T) { {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, }) response, err := service.ShouldRateLimit(context.Background(), request) + test.Logf("DynamicMetadata: %+v", response.DynamicMetadata) + + // Verify response includes metadata about quota violations + t.assert.Nil(err) + t.assert.Equal(pb.RateLimitResponse_OK, response.OverallCode) + t.assert.NotNil(response.DynamicMetadata) + + // Verify metadata for passed limits + passedMetadataVal, ok := response.DynamicMetadata.GetFields()["metadata"] + t.assert.True(ok) + passedMetadata := passedMetadataVal.GetStructValue() + t.assert.NotNil(passedMetadata) + + fields := passedMetadata.GetFields() + nameVal, ok := fields["name"] + t.assert.True(ok) + // Since descriptor 1 has passed and 2 had failed, metadata from the first descriptors should be returned + t.assert.Equal("service_1", nameVal.GetStringValue()) +} + +func TestMetadataReturnedForAllPassedDescriptors(test *testing.T) { + os.Setenv("QUOTA_MODE", "true") + os.Setenv("RESPONSE_DYNAMIC_METADATA", "true") + defer func() { + os.Unsetenv("QUOTA_MODE") + os.Unsetenv("RESPONSE_DYNAMIC_METADATA") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // Make a request. + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1) + + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, true, "", nil, false), + config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, true, "", nil, false), + } + limits[0].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_1")}} + limits[1].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"some_other_name": structpb.NewStringValue("service_2")}} + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 6}, + }) + response, err := service.ShouldRateLimit(context.Background(), request) + test.Logf("DynamicMetadata: %+v", response.DynamicMetadata) + + // Verify response includes metadata about quota violations + t.assert.Nil(err) + t.assert.Equal(pb.RateLimitResponse_OK, response.OverallCode) + t.assert.NotNil(response.DynamicMetadata) + + // Verify metadata for passed limits + passedMetadataVal, ok := response.DynamicMetadata.GetFields()["metadata"] + t.assert.True(ok) + passedMetadata := passedMetadataVal.GetStructValue() + t.assert.NotNil(passedMetadata) + + fields := passedMetadata.GetFields() + nameVal, ok := fields["name"] + t.assert.True(ok) + // Both descriptors have passed metadata should contain values from both descriptors + t.assert.Equal("service_1", nameVal.GetStringValue()) + nameVal, ok = fields["some_other_name"] + t.assert.True(ok) + t.assert.Equal("service_2", nameVal.GetStringValue()) +} + +func TestOverlappingMetadataReturnsTheFirstValue(test *testing.T) { + os.Setenv("QUOTA_MODE", "true") + os.Setenv("RESPONSE_DYNAMIC_METADATA", "true") + defer func() { + os.Unsetenv("QUOTA_MODE") + os.Unsetenv("RESPONSE_DYNAMIC_METADATA") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // Make a request. + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1) + + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, true, "", nil, false), + config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, true, "", nil, false), + } + limits[0].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_1")}} + limits[1].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_2")}} + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 6}, + }) + response, err := service.ShouldRateLimit(context.Background(), request) + test.Logf("DynamicMetadata: %+v", response.DynamicMetadata) // Verify response includes metadata about quota violations t.assert.Nil(err) t.assert.Equal(pb.RateLimitResponse_OK, response.OverallCode) t.assert.NotNil(response.DynamicMetadata) - // Check that quota violation is tracked in metadata for descriptor index 1 - quotaViolations := response.DynamicMetadata.Fields["quotaModeViolations"] - t.assert.NotNil(quotaViolations) - violations := quotaViolations.GetListValue() - t.assert.Len(violations.Values, 1) - t.assert.Equal(float64(1), violations.Values[0].GetNumberValue()) - - // Check that quotaModeEnabled is true - quotaModeEnabled := response.DynamicMetadata.Fields["quotaModeEnabled"] - t.assert.NotNil(quotaModeEnabled) - t.assert.True(quotaModeEnabled.GetBoolValue()) + // Verify metadata for passed limits + passedMetadataVal, ok := response.DynamicMetadata.GetFields()["metadata"] + t.assert.True(ok) + passedMetadata := passedMetadataVal.GetStructValue() + t.assert.NotNil(passedMetadata) + + fields := passedMetadata.GetFields() + nameVal, ok := fields["name"] + t.assert.True(ok) + // Metadata from the first descriptor takes precendence + t.assert.Equal("service_1", nameVal.GetStringValue()) } func TestServicePerDescriptorQuotaMode(test *testing.T) { From 6863e37066050daf848df799e590faa7b61ae27e Mon Sep 17 00:00:00 2001 From: Ian Kerins Date: Sun, 19 Apr 2026 15:05:17 -0400 Subject: [PATCH 03/29] Dockerfile: add ENTRYPOINT (#1095) Make the docker image easier to consume. Signed-off-by: Ian Kerins Signed-off-by: Yusof Ganji --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index b1171c5d..cd89973f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,3 +12,4 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /go/bin/ratelimit -ldflags="-w -s" -v g FROM gcr.io/distroless/static-debian12:nonroot@sha256:e8a4044e0b4ae4257efa45fc026c0bc30ad320d43bd4c1a7d5271bd241e386d0 COPY --from=build /go/bin/ratelimit /bin/ratelimit +ENTRYPOINT ["/bin/ratelimit"] From b570b7bc6c54671f35ff3e20629ce702246d006e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 19 Apr 2026 22:21:24 +0300 Subject: [PATCH 04/29] Fix Prometheus response time units (#1104) Signed-off-by: Yusof Ganji --- README.md | 2 + src/service_cmd/runner/runner.go | 3 +- src/settings/settings.go | 25 +++++----- src/settings/settings_test.go | 19 ++++++++ src/stats/prom/default_mapper.yaml | 1 + src/stats/prom/prometheus_sink.go | 24 ++++++++-- src/stats/prom/prometheus_sink_test.go | 64 ++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 538b413c..6cf2e51c 100644 --- a/README.md +++ b/README.md @@ -1105,6 +1105,7 @@ To enable Prometheus integration set: 2. `PROMETHEUS_ADDR`: The port to listen on for Prometheus metrics. Defaults to `:9090` 3. `PROMETHEUS_PATH`: The path to listen on for Prometheus metrics. Defaults to `/metrics` 4. `PROMETHEUS_MAPPER_YAML`: The path to the YAML file that defines the mapping from statsd to prometheus metrics. +5. `PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS`: `true` to keep the legacy millisecond behavior for `ratelimit_server.*.response_time` in the built-in mapper. Ignored when `PROMETHEUS_MAPPER_YAML` is set. Define the mapping from statsd to prometheus metrics in a YAML file. Find more information about the mapping in the [Metric Mapping and Configuration](https://github.com/prometheus/statsd_exporter?tab=readme-ov-file#metric-mapping-and-configuration). @@ -1181,6 +1182,7 @@ mappings: # Requires statsd exporter >= v0.6.0 since it uses the "drop" action. - match: "ratelimit_server.*.response_time" name: "ratelimit_service_response_time_seconds" timer_type: histogram + scale: 0.001 labels: grpc_method: "$1" diff --git a/src/service_cmd/runner/runner.go b/src/service_cmd/runner/runner.go index f062d15f..a3889a58 100644 --- a/src/service_cmd/runner/runner.go +++ b/src/service_cmd/runner/runner.go @@ -68,7 +68,8 @@ func NewRunner(s settings.Settings) Runner { } logger.Info("Stats initialized for Prometheus") store = gostats.NewStore(prom.NewPrometheusSink(prom.WithAddr(s.PrometheusAddr), - prom.WithPath(s.PrometheusPath), prom.WithMapperYamlPath(s.PrometheusMapperYaml)), false) + prom.WithPath(s.PrometheusPath), prom.WithMapperYamlPath(s.PrometheusMapperYaml), + prom.WithResponseTimeAsMilliseconds(s.PrometheusResponseTimeAsMilliseconds)), false) default: logger.Info("Stats initialized for stdout") store = gostats.NewStore(gostats.NewLoggingSink(), false) diff --git a/src/settings/settings.go b/src/settings/settings.go index 7ad2d80d..6a1be618 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -82,18 +82,19 @@ type Settings struct { XdsClientGrpcOptionsMaxMsgSizeInBytes int `envconfig:"XDS_CLIENT_MAX_MSG_SIZE_IN_BYTES" default:""` // Stats-related settings - UseDogStatsd bool `envconfig:"USE_DOG_STATSD" default:"false"` - UseDogStatsdMogrifiers []string `envconfig:"USE_DOG_STATSD_MOGRIFIERS" default:""` - UseStatsd bool `envconfig:"USE_STATSD" default:"true"` - StatsdHost string `envconfig:"STATSD_HOST" default:"localhost"` - StatsdPort int `envconfig:"STATSD_PORT" default:"8125"` - ExtraTags map[string]string `envconfig:"EXTRA_TAGS" default:""` - StatsFlushInterval time.Duration `envconfig:"STATS_FLUSH_INTERVAL" default:"10s"` - DisableStats bool `envconfig:"DISABLE_STATS" default:"false"` - UsePrometheus bool `envconfig:"USE_PROMETHEUS" default:"false"` - PrometheusAddr string `envconfig:"PROMETHEUS_ADDR" default:":9090"` - PrometheusPath string `envconfig:"PROMETHEUS_PATH" default:"/metrics"` - PrometheusMapperYaml string `envconfig:"PROMETHEUS_MAPPER_YAML" default:""` + UseDogStatsd bool `envconfig:"USE_DOG_STATSD" default:"false"` + UseDogStatsdMogrifiers []string `envconfig:"USE_DOG_STATSD_MOGRIFIERS" default:""` + UseStatsd bool `envconfig:"USE_STATSD" default:"true"` + StatsdHost string `envconfig:"STATSD_HOST" default:"localhost"` + StatsdPort int `envconfig:"STATSD_PORT" default:"8125"` + ExtraTags map[string]string `envconfig:"EXTRA_TAGS" default:""` + StatsFlushInterval time.Duration `envconfig:"STATS_FLUSH_INTERVAL" default:"10s"` + DisableStats bool `envconfig:"DISABLE_STATS" default:"false"` + UsePrometheus bool `envconfig:"USE_PROMETHEUS" default:"false"` + PrometheusAddr string `envconfig:"PROMETHEUS_ADDR" default:":9090"` + PrometheusPath string `envconfig:"PROMETHEUS_PATH" default:"/metrics"` + PrometheusMapperYaml string `envconfig:"PROMETHEUS_MAPPER_YAML" default:""` + PrometheusResponseTimeAsMilliseconds bool `envconfig:"PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS" default:"false"` // Settings for rate limit configuration RuntimePath string `envconfig:"RUNTIME_ROOT" default:"/srv/runtime_data/current"` diff --git a/src/settings/settings_test.go b/src/settings/settings_test.go index b2c7bc7e..f15fbea2 100644 --- a/src/settings/settings_test.go +++ b/src/settings/settings_test.go @@ -7,12 +7,31 @@ import ( "github.com/stretchr/testify/assert" ) +const prometheusResponseTimeAsMillisecondsEnv = "PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS" + func TestSettingsTlsConfigUnmodified(t *testing.T) { settings := NewSettings() assert.NotNil(t, settings.RedisTlsConfig) assert.Nil(t, settings.RedisTlsConfig.RootCAs) } +func TestPrometheusResponseTimeAsMillisecondsDefault(t *testing.T) { + os.Unsetenv(prometheusResponseTimeAsMillisecondsEnv) + + settings := NewSettings() + + assert.False(t, settings.PrometheusResponseTimeAsMilliseconds) +} + +func TestPrometheusResponseTimeAsMillisecondsEnabled(t *testing.T) { + os.Setenv(prometheusResponseTimeAsMillisecondsEnv, "true") + defer os.Unsetenv(prometheusResponseTimeAsMillisecondsEnv) + + settings := NewSettings() + + assert.True(t, settings.PrometheusResponseTimeAsMilliseconds) +} + // Tests for RedisPoolOnEmptyBehavior func TestRedisPoolOnEmptyBehavior_Default(t *testing.T) { os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") diff --git a/src/stats/prom/default_mapper.yaml b/src/stats/prom/default_mapper.yaml index 6229ce15..e03706e3 100644 --- a/src/stats/prom/default_mapper.yaml +++ b/src/stats/prom/default_mapper.yaml @@ -86,6 +86,7 @@ mappings: - match: "ratelimit_server.*.response_time" name: "ratelimit_service_response_time_seconds" timer_type: histogram + scale: 0.001 labels: grpc_method: "$1" diff --git a/src/stats/prom/prometheus_sink.go b/src/stats/prom/prometheus_sink.go index 2c93c49c..24ae907f 100644 --- a/src/stats/prom/prometheus_sink.go +++ b/src/stats/prom/prometheus_sink.go @@ -3,6 +3,7 @@ package prom import ( _ "embed" "net/http" + "strings" "github.com/go-kit/log" gostats "github.com/lyft/gostats" @@ -64,9 +65,10 @@ var ( type prometheusSink struct { config struct { - addr string - path string - mapperYamlPath string + addr string + path string + mapperYamlPath string + responseTimeAsMilliseconds bool } mapper *mapper.MetricMapper events chan event.Events @@ -93,6 +95,20 @@ func WithMapperYamlPath(mapperYamlPath string) prometheusSinkOption { } } +func WithResponseTimeAsMilliseconds(responseTimeAsMilliseconds bool) prometheusSinkOption { + return func(sink *prometheusSink) { + sink.config.responseTimeAsMilliseconds = responseTimeAsMilliseconds + } +} + +func (s *prometheusSink) mapperConfig() string { + if s.config.responseTimeAsMilliseconds { + return strings.Replace(defaultMapper, " scale: 0.001\n", "", 1) + } + + return defaultMapper +} + // NewPrometheusSink returns a Sink that flushes stats to os.StdErr. func NewPrometheusSink(opts ...prometheusSinkOption) gostats.Sink { promRegistry := prometheus.DefaultRegisterer @@ -119,7 +135,7 @@ func NewPrometheusSink(opts ...prometheusSinkOption) gostats.Sink { if sink.config.mapperYamlPath != "" { _ = sink.mapper.InitFromFile(sink.config.mapperYamlPath) } else { - _ = sink.mapper.InitFromYAMLString(defaultMapper) + _ = sink.mapper.InitFromYAMLString(sink.mapperConfig()) } sink.exp = exporter.NewExporter(promRegistry, diff --git a/src/stats/prom/prometheus_sink_test.go b/src/stats/prom/prometheus_sink_test.go index d9731eba..d2d5e890 100644 --- a/src/stats/prom/prometheus_sink_test.go +++ b/src/stats/prom/prometheus_sink_test.go @@ -121,3 +121,67 @@ func TestFlushTimer(t *testing.T) { *m.Metric[0].Histogram.SampleSum == 1.0 }, time.Second, time.Millisecond) } + +func TestFlushResponseTimeConvertsMillisecondsToSeconds(t *testing.T) { + s.FlushTimer("ratelimit_server.ShouldRateLimit.response_time", 1000) + assert.Eventually(t, func() bool { + metricFamilies, err := prometheus.DefaultGatherer.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_response_time_seconds"] + if !ok || len(m.Metric) != 1 { + return false + } + + return *m.Metric[0].Histogram.SampleCount == uint64(1) && + reflect.DeepEqual(toMap(m.Metric[0].Label), map[string]string{ + "grpc_method": "ShouldRateLimit", + }) && + *m.Metric[0].Histogram.SampleSum == 1.0 + }, time.Second, time.Millisecond) +} + +func TestFlushResponseTimeCanUseLegacyMilliseconds(t *testing.T) { + oldRegisterer := prometheus.DefaultRegisterer + oldGatherer := prometheus.DefaultGatherer + reg := prometheus.NewRegistry() + prometheus.DefaultRegisterer = reg + prometheus.DefaultGatherer = reg + defer func() { + prometheus.DefaultRegisterer = oldRegisterer + prometheus.DefaultGatherer = oldGatherer + }() + + legacySink := NewPrometheusSink(WithAddr(":0"), WithPath("/metrics-legacy"), WithResponseTimeAsMilliseconds(true)) + legacySink.FlushTimer("ratelimit_server.ShouldRateLimit.response_time", 1000) + + assert.Eventually(t, func() bool { + metricFamilies, err := reg.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_response_time_seconds"] + if !ok || len(m.Metric) != 1 { + return false + } + + return *m.Metric[0].Histogram.SampleCount == uint64(1) && + reflect.DeepEqual(toMap(m.Metric[0].Label), map[string]string{ + "grpc_method": "ShouldRateLimit", + }) && + *m.Metric[0].Histogram.SampleSum == 1000.0 + }, time.Second, time.Millisecond) +} From 5eac2580fe3c58a9d591a51420dc5a4cf752ae25 Mon Sep 17 00:00:00 2001 From: Ian Kerins Date: Sun, 19 Apr 2026 15:21:49 -0400 Subject: [PATCH 05/29] feat: add zipkin b3 header propagation (#1110) Signed-off-by: Yusof Ganji --- go.mod | 1 + go.sum | 2 ++ src/trace/trace.go | 5 +++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index fcc6d676..18efe1e4 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 + go.opentelemetry.io/contrib/propagators/b3 v1.40.0 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 diff --git a/go.sum b/go.sum index 02be9a68..c22f0f88 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= diff --git a/src/trace/trace.go b/src/trace/trace.go index 6fa49b65..bdf0478f 100644 --- a/src/trace/trace.go +++ b/src/trace/trace.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" logger "github.com/sirupsen/logrus" + "go.opentelemetry.io/contrib/propagators/b3" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" @@ -62,7 +63,7 @@ func InitProductionTraceProvider(protocol string, serviceName string, serviceNam sdktrace.WithResource(resource), ) otel.SetTracerProvider(tp) - otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, b3.New(), propagation.Baggage{})) logger.Infof("TracerProvider initialized with following parameters: protocol: %s, serviceName: %s, serviceNamespace: %s, serviceInstanceId: %s, samplingRate: %f", protocol, serviceName, serviceNamespace, useServiceInstanceId, samplingRate) return tp @@ -100,7 +101,7 @@ func GetTestSpanExporter() *tracetest.InMemoryExporter { sdktrace.WithSyncer(testSpanExporter), ) otel.SetTracerProvider(tp) - otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, b3.New(), propagation.Baggage{})) return testSpanExporter } From 1487aa9f8efccc458ce2575b4974e7aa9048e1be Mon Sep 17 00:00:00 2001 From: bloomenergyguy Date: Thu, 30 Apr 2026 10:27:42 -0700 Subject: [PATCH 06/29] Update third party libraries flagged for vulnerability scans (#1124) Signed-off-by: collin-lee Co-authored-by: collin-lee Signed-off-by: Yusof Ganji --- .github/workflows/main.yaml | 2 +- .github/workflows/pullrequest.yaml | 2 +- Dockerfile | 2 +- Dockerfile.integration | 2 +- examples/xds-sotw-config-server/Dockerfile | 2 +- go.mod | 32 +++++------ go.sum | 64 +++++++++++----------- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a86cfa3b..69cce14b 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -49,7 +49,7 @@ jobs: - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.26.1" + go-version: "1.26.2" - name: run pre-commits run: | diff --git a/.github/workflows/pullrequest.yaml b/.github/workflows/pullrequest.yaml index 71664f53..45f338d4 100644 --- a/.github/workflows/pullrequest.yaml +++ b/.github/workflows/pullrequest.yaml @@ -37,7 +37,7 @@ jobs: - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.26.1" + go-version: "1.26.2" - name: run pre-commits run: | diff --git a/Dockerfile b/Dockerfile index cd89973f..09951c5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.1@sha256:e2ddb153f786ee6210bf8c40f7f35490b3ff7d38be70d1a0d358ba64225f6428 AS build +FROM golang:1.26.2@sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951802350d2ec90e AS build WORKDIR /ratelimit ENV GOPROXY=https://proxy.golang.org diff --git a/Dockerfile.integration b/Dockerfile.integration index 00114bd3..2a9bc51d 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -1,5 +1,5 @@ # Running this docker image runs the integration tests. -FROM golang:1.26.1@sha256:e2ddb153f786ee6210bf8c40f7f35490b3ff7d38be70d1a0d358ba64225f6428 +FROM golang:1.26.2@sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951802350d2ec90e RUN apt-get update -y && apt-get install sudo stunnel4 redis memcached -y && rm -rf /var/lib/apt/lists/* diff --git a/examples/xds-sotw-config-server/Dockerfile b/examples/xds-sotw-config-server/Dockerfile index a04fd088..afee2c88 100644 --- a/examples/xds-sotw-config-server/Dockerfile +++ b/examples/xds-sotw-config-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.1@sha256:e2ddb153f786ee6210bf8c40f7f35490b3ff7d38be70d1a0d358ba64225f6428 AS build +FROM golang:1.26.2@sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951802350d2ec90e AS build WORKDIR /xds-server COPY . . diff --git a/go.mod b/go.mod index 18efe1e4..4a05be8d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/envoyproxy/ratelimit -go 1.26.1 +go 1.26.2 require ( github.com/DataDog/datadog-go/v5 v5.5.0 @@ -29,13 +29,13 @@ require ( github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 go.opentelemetry.io/contrib/propagators/b3 v1.40.0 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 - golang.org/x/net v0.49.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/net v0.52.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 @@ -46,7 +46,7 @@ require ( github.com/Microsoft/go-winio v0.5.0 // indirect github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -55,7 +55,7 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.48.0 // indirect @@ -64,11 +64,11 @@ require ( github.com/tilinna/clock v1.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c22f0f88..55358ed7 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -78,8 +78,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= @@ -157,24 +157,24 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -201,8 +201,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -224,13 +224,13 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -252,10 +252,10 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= -google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= From f321ae2de80a1a1f3832d2e83bc2e0ee9d1695a2 Mon Sep 17 00:00:00 2001 From: Harrison Harris <86658388+harrisonharris-di@users.noreply.github.com> Date: Fri, 8 May 2026 00:23:37 +0100 Subject: [PATCH 07/29] build: pin golang:1.26.2 to multi-arch index digest (#1131) PR #1124 updated the golang base image from 1.26.1 to 1.26.2, but the new digest sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951 802350d2ec90e is a single-arch (linux/amd64) image manifest rather than a multi-arch index. The previous 1.26.1 digest sha256:e2ddb153f7 86ee6210bf8c40f7f35490b3ff7d38be70d1a0d358ba64225f6428 is an OCI image index covering linux/amd64, arm64/v8, arm/v7, 386, ppc64le, riscv64, s390x and windows/amd64. When buildx is asked to produce a non-amd64 variant of the published envoyproxy/ratelimit image, the FROM line resolves to the amd64 base on every platform, so the resulting binary is amd64 regardless of the target. The multi-arch publish then stamps that amd64 binary into the arm64 layer of the released index, producing an image that fails on arm64 nodes with: exec /bin/ratelimit: exec format error Swap to the corresponding multi-arch index digest sha256:b54cbf583d39 0341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716, which contains the existing 7095ad02... amd64 manifest as one of its children plus the arm64/v8 and other platform variants. The amd64 image is unchanged; arm64 builds now produce arm64 binaries. Signed-off-by: Harrison Harris Co-authored-by: Harrison Harris Signed-off-by: Yusof Ganji --- Dockerfile | 2 +- Dockerfile.integration | 2 +- examples/xds-sotw-config-server/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 09951c5f..c1fc11e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.2@sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951802350d2ec90e AS build +FROM golang:1.26.2@sha256:b54cbf583d390341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716 AS build WORKDIR /ratelimit ENV GOPROXY=https://proxy.golang.org diff --git a/Dockerfile.integration b/Dockerfile.integration index 2a9bc51d..50c76d6c 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -1,5 +1,5 @@ # Running this docker image runs the integration tests. -FROM golang:1.26.2@sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951802350d2ec90e +FROM golang:1.26.2@sha256:b54cbf583d390341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716 RUN apt-get update -y && apt-get install sudo stunnel4 redis memcached -y && rm -rf /var/lib/apt/lists/* diff --git a/examples/xds-sotw-config-server/Dockerfile b/examples/xds-sotw-config-server/Dockerfile index afee2c88..8780ad62 100644 --- a/examples/xds-sotw-config-server/Dockerfile +++ b/examples/xds-sotw-config-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.2@sha256:7095ad02810845fa35d1fb090b8e57dd20dce4ca36b29b42951802350d2ec90e AS build +FROM golang:1.26.2@sha256:b54cbf583d390341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716 AS build WORKDIR /xds-server COPY . . From 194b26bca880d21b54cb62c81e0a1bf98d6faf23 Mon Sep 17 00:00:00 2001 From: yanavlasov Date: Fri, 15 May 2026 16:49:03 -0400 Subject: [PATCH 08/29] Add integration test for quota based service selection. (#1114) Signed-off-by: Yan Avlasov Signed-off-by: Yusof Ganji --- examples/envoy/mock.yaml | 30 +++++- examples/envoy/proxy.yaml | 97 +++++++++++++++++++ examples/ratelimit/config/example.yaml | 6 ++ .../docker-compose-integration-test.yml | 5 +- .../scripts/multiservice-tokenquota.sh | 38 ++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100755 integration-test/scripts/multiservice-tokenquota.sh diff --git a/examples/envoy/mock.yaml b/examples/envoy/mock.yaml index e3fe0418..226906ab 100644 --- a/examples/envoy/mock.yaml +++ b/examples/envoy/mock.yaml @@ -23,7 +23,35 @@ static_resources: direct_response: status: "200" body: - inline_string: "Hello World" + inline_string: "Hello World from Service 1" + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + - address: + socket_address: + address: 0.0.0.0 + port_value: 10001 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress + route_config: + name: ingress + virtual_hosts: + - name: backend + domains: + - "*" + routes: + - match: + prefix: "/" + direct_response: + status: "200" + body: + inline_string: "Hello World from Service 2" http_filters: - name: envoy.filters.http.router typed_config: diff --git a/examples/envoy/proxy.yaml b/examples/envoy/proxy.yaml index 09227940..7a722a05 100644 --- a/examples/envoy/proxy.yaml +++ b/examples/envoy/proxy.yaml @@ -34,6 +34,50 @@ static_resources: socket_address: address: envoy-mock port_value: 9999 + - name: multiservice + connect_timeout: 1s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: + - service + load_assignment: + cluster_name: multiservice + endpoints: + - locality: + region: "region_1" + metadata: + filter_metadata: + envoy.cluster.locality: + name: "service_1" + lb_endpoints: + - endpoint: + address: + socket_address: + address: envoy-mock + port_value: 9999 + metadata: + filter_metadata: + envoy.lb: + service: "service_1" + - locality: + region: "region_2" + metadata: + filter_metadata: + envoy.cluster.locality: + name: "service_2" + lb_endpoints: + - endpoint: + address: + socket_address: + address: envoy-mock + port_value: 10001 + metadata: + filter_metadata: + envoy.lb: + service: "service_2" listeners: - address: socket_address: @@ -61,6 +105,26 @@ static_resources: envoy_grpc: cluster_name: ratelimit transport_api_version: V3 + - name: envoy.filters.http.set_header + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.header_mutation.v3.HeaderMutation + mutations: + request_mutations: + - append: + header: + key: "x-envoy-service-with-quota" + value: "%DYNAMIC_METADATA(envoy.filters.http.ratelimit:metadata:service_with_quota:name)%" + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - name: envoy.filters.http.header_to_metadata + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.header_to_metadata.v3.Config + stat_prefix: header_converter + request_rules: + - header: x-envoy-service-with-quota + on_header_present: + metadata_namespace: envoy.lb + key: service + type: STRING - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router @@ -199,3 +263,36 @@ static_resources: hits_addend: number: 1 apply_on_stream_done: true + - match: + prefix: /multiservice-tokenquota + route: + cluster: multiservice + typed_per_filter_config: + envoy.filters.http.ratelimit: + "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimitPerRoute + vh_rate_limits: INCLUDE + override_option: INCLUDE_POLICY + rate_limits: + - actions: + - generic_key: + descriptor_value: "service_1" + descriptor_key: "service" + hits_addend: + number: 0 + - actions: + - generic_key: + descriptor_value: "service_2" + descriptor_key: "service" + hits_addend: + number: 0 + - actions: + - metadata: + descriptor_key: "service" + source: CLUSTER_LOCALITY_ENTRY + metadata_key: + key: "envoy.cluster.locality" + path: + - key: "name" + hits_addend: + number: 1 + apply_on_stream_done: true diff --git a/examples/ratelimit/config/example.yaml b/examples/ratelimit/config/example.yaml index 3c270915..556681b0 100644 --- a/examples/ratelimit/config/example.yaml +++ b/examples/ratelimit/config/example.yaml @@ -90,9 +90,15 @@ descriptors: rate_limit: unit: minute requests_per_unit: 1 + metadata: + service_with_quota: + name: "service_1" - key: service value: service_2 quota_mode: true rate_limit: unit: minute requests_per_unit: 2 + metadata: + service_with_quota: + name: "service_2" diff --git a/integration-test/docker-compose-integration-test.yml b/integration-test/docker-compose-integration-test.yml index d37264cb..46d16a64 100644 --- a/integration-test/docker-compose-integration-test.yml +++ b/integration-test/docker-compose-integration-test.yml @@ -51,9 +51,10 @@ services: - RUNTIME_ROOT=/data - RUNTIME_SUBDIRECTORY=ratelimit - RUNTIME_WATCH_ROOT=false + - RESPONSE_DYNAMIC_METADATA=true envoy-proxy: - image: envoyproxy/envoy-dev:latest + image: envoyproxy/envoy:dev entrypoint: "/usr/local/bin/envoy" command: - "--service-node proxy" @@ -91,8 +92,10 @@ services: - ratelimit-network expose: - "9999" + - "10001" ports: - "9999:9999" + - "10001:10001" tester: build: diff --git a/integration-test/scripts/multiservice-tokenquota.sh b/integration-test/scripts/multiservice-tokenquota.sh new file mode 100755 index 00000000..0e276e48 --- /dev/null +++ b/integration-test/scripts/multiservice-tokenquota.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# +# request to /multiservice-tokenquota will produce the (service: service_1), (service: service_2) descriptor +# with 0 addend on request path and on response path descriptor containing the name of service which served the request with addend 1. +# RL service responds with metadata containing the service name with available quota. +# service_1 has 1 req/min, service_2 has 2 req/min. Since quota is depbited on response path a total of 2 requests/minute for service_1 +# and 3 requests/minute for service_2 is allowed. +# + +response=$(curl -f -s -H "request-no: 1" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 1") +response=$(curl -f -s -H "request-no: 2" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 1") +# service_1 is out of quota and service_2 should now be used +response=$(curl -f -s -H "request-no: 3" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 2") +response=$(curl -f -s -H "request-no: 4" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 2") +response=$(curl -f -s -H "request-no: 5" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 2") + +if [ $? -ne 0 ]; then + echo "Quota limit should not trigger yet" + exit 1 +fi + +# Quota is debited from service_2 bucket on the response path so only the 5th request should be rejected +response=$(curl -f -s -H "request-no: 6" http://envoy-proxy:8888/multiservice-tokenquota | grep "Hello World from Service") + +if [ $? -eq 0 ]; then + echo "Quota limiting should fail the request" + exit 1 +fi + +echo "Waiting 1 minute for quota buckets to be refreshed" +sleep 60 + +response=$(curl -f -s -H "request-no: 7" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 1") +if [ $? -ne 0 ]; then + echo "Quota bucket should be refreshed and service_1 is used" + exit 1 +fi From dfeeb0df39ee30192eb9f3191fbfa8127367eb6d Mon Sep 17 00:00:00 2001 From: zirain Date: Thu, 28 May 2026 01:31:36 +0800 Subject: [PATCH 09/29] feat: add retry in init phase instead of panic directly (#1144) * feat: add retry in init phase instead of panic directly Signed-off-by: zirain * respect signal handling for graceful shutdown Signed-off-by: zirain * fix test Signed-off-by: zirain --------- Signed-off-by: zirain Signed-off-by: Yusof Ganji --- src/redis/cache_impl.go | 11 +-- src/redis/driver_impl.go | 107 ++++++++++++++++++++--------- src/server/health.go | 5 +- src/server/server.go | 3 +- src/server/server_impl.go | 18 ++--- src/service_cmd/runner/runner.go | 53 ++++++++++++--- src/settings/settings.go | 8 +++ test/redis/bench_test.go | 2 +- test/redis/driver_impl_test.go | 23 ++++--- test/server/health_test.go | 111 +++++++++++-------------------- test/service/ratelimit_test.go | 6 ++ 11 files changed, 198 insertions(+), 149 deletions(-) diff --git a/src/redis/cache_impl.go b/src/redis/cache_impl.go index 21db8b6d..266c0a4d 100644 --- a/src/redis/cache_impl.go +++ b/src/redis/cache_impl.go @@ -1,6 +1,7 @@ package redis import ( + "context" "io" "math/rand" @@ -13,19 +14,19 @@ import ( "github.com/envoyproxy/ratelimit/src/utils" ) -func NewRateLimiterCacheImplFromSettings(s settings.Settings, localCache *freecache.Cache, srv server.Server, timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64, statsManager stats.Manager) (limiter.RateLimitCache, io.Closer) { +func NewRateLimiterCacheImplFromSettings(ctx context.Context, s settings.Settings, localCache *freecache.Cache, srv server.Server, timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64, statsManager stats.Manager) (limiter.RateLimitCache, io.Closer) { closer := &utils.MultiCloser{} var perSecondPool Client if s.RedisPerSecond { - perSecondPool = NewClientImpl(srv.Scope().Scope("redis_per_second_pool"), s.RedisPerSecondTls, s.RedisPerSecondAuth, s.RedisPerSecondSocketType, + perSecondPool = NewClientImpl(ctx, srv.Scope().Scope("redis_per_second_pool"), s.RedisPerSecondTls, s.RedisPerSecondAuth, s.RedisPerSecondSocketType, s.RedisPerSecondType, s.RedisPerSecondUrl, s.RedisPerSecondPoolSize, s.RedisPerSecondPipelineWindow, s.RedisPerSecondPipelineLimit, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv, s.RedisPerSecondTimeout, - s.RedisPerSecondPoolOnEmptyBehavior, s.RedisPerSecondSentinelAuth) + s.RedisPerSecondPoolOnEmptyBehavior, s.RedisPerSecondSentinelAuth, s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime) closer.Closers = append(closer.Closers, perSecondPool) } - otherPool := NewClientImpl(srv.Scope().Scope("redis_pool"), s.RedisTls, s.RedisAuth, s.RedisSocketType, s.RedisType, s.RedisUrl, s.RedisPoolSize, + otherPool := NewClientImpl(ctx, srv.Scope().Scope("redis_pool"), s.RedisTls, s.RedisAuth, s.RedisSocketType, s.RedisType, s.RedisUrl, s.RedisPoolSize, s.RedisPipelineWindow, s.RedisPipelineLimit, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv, s.RedisTimeout, - s.RedisPoolOnEmptyBehavior, s.RedisSentinelAuth) + s.RedisPoolOnEmptyBehavior, s.RedisSentinelAuth, s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime) closer.Closers = append(closer.Closers, otherPool) return NewFixedRateLimitCacheImpl( diff --git a/src/redis/driver_impl.go b/src/redis/driver_impl.go index 50cf4466..f65269eb 100644 --- a/src/redis/driver_impl.go +++ b/src/redis/driver_impl.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/jpillora/backoff" stats "github.com/lyft/gostats" "github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4/trace" @@ -119,9 +120,10 @@ func createDialer(timeout time.Duration, useTls bool, tlsConfig *tls.Config, aut return dialer } -func NewClientImpl(scope stats.Scope, useTls bool, auth, redisSocketType, redisType, url string, poolSize int, +func NewClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, redisSocketType, redisType, url string, poolSize int, pipelineWindow time.Duration, pipelineLimit int, tlsConfig *tls.Config, healthCheckActiveConnection bool, srv server.Server, timeout time.Duration, poolOnEmptyBehavior string, sentinelAuth string, + startupInitialInterval, startupMaxInterval, startupMaxElapsedTime time.Duration, ) Client { maskedUrl := utils.MaskCredentialsInUrl(url) logger.Warnf("connecting to redis on %s with pool size %d", maskedUrl, poolSize) @@ -191,47 +193,88 @@ func NewClientImpl(scope stats.Scope, useTls bool, auth, redisSocketType, redisT return poolConfig.New(ctx, network, addr) } - var client redisClient - var err error - ctx := context.Background() - - switch strings.ToLower(redisType) { - case "single": - logger.Warnf("Creating single with urls %v", url) - client, err = poolFunc(ctx, redisSocketType, url) - case "cluster": - urls := strings.Split(url, ",") - logger.Warnf("Creating cluster with urls %v", urls) - clusterConfig := radix.ClusterConfig{ - PoolConfig: poolConfig, - } - client, err = clusterConfig.New(ctx, urls) - case "sentinel": + // Validate sentinel URL format early (before retry loop) since it's a configuration error. + if strings.ToLower(redisType) == "sentinel" { urls := strings.Split(url, ",") if len(urls) < 2 { panic(RedisError("Expected master name and a list of urls for the sentinels, in the format: ,,...,")) } + } - // Create sentinel dialer (may use different auth from Redis master/replica) - // sentinelAuth is for Sentinel nodes, auth is for Redis master/replica - sentinelDialer := createDialer(timeout, useTls, tlsConfig, sentinelAuth, fmt.Sprintf("sentinel(%s)", maskedUrl)) + b := &backoff.Backoff{ + Min: startupInitialInterval, + Max: startupMaxInterval, + Factor: 2, + Jitter: true, + } + + startTime := time.Now() - sentinelConfig := radix.SentinelConfig{ - PoolConfig: poolConfig, - SentinelDialer: sentinelDialer, + retryOrDie := func(lastErr error) { + elapsed := time.Since(startTime) + if startupMaxElapsedTime > 0 && elapsed >= startupMaxElapsedTime { + panic(RedisError(fmt.Sprintf("timed out waiting for Redis connection to %s after %s: %v", maskedUrl, elapsed.Round(time.Millisecond), lastErr))) + } + d := b.Duration() + logger.Warnf("Retrying Redis connection to %s in %s (elapsed: %s): %v", maskedUrl, d, elapsed.Round(time.Millisecond), lastErr) + select { + case <-time.After(d): + case <-ctx.Done(): + panic(RedisError(fmt.Sprintf("context cancelled while waiting for Redis connection to %s: %v", maskedUrl, ctx.Err()))) } - client, err = sentinelConfig.New(ctx, urls[0], urls[1:]) - default: - panic(RedisError("Unrecognized redis type " + redisType)) } - checkError(err) + var client redisClient + for { + var err error + switch strings.ToLower(redisType) { + case "single": + logger.Warnf("Creating single with urls %v", url) + client, err = poolFunc(ctx, redisSocketType, url) + case "cluster": + urls := strings.Split(url, ",") + logger.Warnf("Creating cluster with urls %v", urls) + clusterConfig := radix.ClusterConfig{ + PoolConfig: poolConfig, + } + client, err = clusterConfig.New(ctx, urls) + case "sentinel": + urls := strings.Split(url, ",") + sentinelDialer := createDialer(timeout, useTls, tlsConfig, sentinelAuth, fmt.Sprintf("sentinel(%s)", maskedUrl)) + sentinelConfig := radix.SentinelConfig{ + PoolConfig: poolConfig, + SentinelDialer: sentinelDialer, + } + client, err = sentinelConfig.New(ctx, urls[0], urls[1:]) + default: + panic(RedisError("Unrecognized redis type " + redisType)) + } + + if err != nil { + retryOrDie(err) + continue + } - // Check if connection is good - var pingResponse string - checkError(client.Do(ctx, radix.Cmd(&pingResponse, "PING"))) - if pingResponse != "PONG" { - checkError(fmt.Errorf("connecting redis error: %s", pingResponse)) + var pingResponse string + if pingErr := client.Do(ctx, radix.Cmd(&pingResponse, "PING")); pingErr != nil { + _ = client.Close() + retryOrDie(pingErr) + continue + } + if pingResponse != "PONG" { + _ = client.Close() + retryOrDie(fmt.Errorf("unexpected PING response: %q", pingResponse)) + continue + } + + // Successfully connected. + break + } + + if srv != nil { + if err := srv.HealthChecker().Ok(server.RedisHealthComponentName); err != nil { + logger.Errorf("Unable to update health status after Redis connection: %s", err) + } } return &clientImpl{ diff --git a/src/server/health.go b/src/server/health.go index 244a760e..16ade5ef 100644 --- a/src/server/health.go +++ b/src/server/health.go @@ -49,7 +49,8 @@ func NewHealthChecker(grpcHealthServer *health.Server, name string, healthyWithA ret.healthMap = make(map[string]bool) // Store health states of components into map - ret.healthMap[RedisHealthComponentName] = true + // Redis starts unhealthy; it is marked healthy once the connection is confirmed at startup. + ret.healthMap[RedisHealthComponentName] = false if healthyWithAtLeastOneConfigLoad { // config starts in failed state since we need at least one config loaded to be healthy ret.healthMap[ConfigHealthComponentName] = false @@ -111,7 +112,7 @@ func (hc *HealthChecker) Ok(componentName string) error { // Set component to be healthy hc.healthMap[componentName] = true allComponentsHealthy := areAllComponentsHealthy(hc.healthMap) - + logger.Debugf("Health status of components: %v, all healthy: %t", hc.healthMap, allComponentsHealthy) if allComponentsHealthy { atomic.StoreUint32(&hc.ok, 1) hc.grpc.SetServingStatus(hc.name, healthpb.HealthCheckResponse_SERVING) diff --git a/src/server/server.go b/src/server/server.go index 7202fa2f..d315c2ae 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -1,6 +1,7 @@ package server import ( + "context" "net/http" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" @@ -17,7 +18,7 @@ type Server interface { * all endpoints have been registered through 'AddHttpEndpoint' * and 'GrpcServer'. */ - Start() + Start(ctx context.Context) /** * Returns the root of the stats tree for the server diff --git a/src/server/server_impl.go b/src/server/server_impl.go index e9402da0..a82f2821 100644 --- a/src/server/server_impl.go +++ b/src/server/server_impl.go @@ -8,12 +8,9 @@ import ( "net" "net/http" "net/http/pprof" - "os" - "os/signal" "sort" "strconv" "sync" - "syscall" "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" @@ -166,7 +163,7 @@ func (server *server) GrpcServer() *grpc.Server { return server.grpcServer } -func (server *server) Start() { +func (server *server) Start(ctx context.Context) { go func() { logger.Warnf("Listening for debug on '%s'", server.debugAddress) var err error @@ -184,7 +181,7 @@ func (server *server) Start() { go server.startGrpc() - server.handleGracefulShutdown() + server.handleGracefulShutdown(ctx) logger.Warnf("Listening for HTTP on '%s'", server.httpAddress) list, err := reuseport.Listen("tcp", server.httpAddress) @@ -365,16 +362,11 @@ func (server *server) Stop() { server.provider.Stop() } -func (server *server) handleGracefulShutdown() { - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - +func (server *server) handleGracefulShutdown(ctx context.Context) { go func() { - sig := <-sigs - - logger.Infof("Ratelimit server received %v, shutting down gracefully", sig) + <-ctx.Done() + logger.Infof("Context cancelled, stopping server") server.Stop() - os.Exit(0) }() } diff --git a/src/service_cmd/runner/runner.go b/src/service_cmd/runner/runner.go index a3889a58..d5de0c36 100644 --- a/src/service_cmd/runner/runner.go +++ b/src/service_cmd/runner/runner.go @@ -5,8 +5,11 @@ import ( "io" "math/rand" "net/http" + "os" + "os/signal" "strings" "sync" + "syscall" "time" "github.com/coocood/freecache" @@ -34,6 +37,8 @@ type Runner struct { srv server.Server mu sync.Mutex ratelimitCloser io.Closer + cancel context.CancelFunc + done chan struct{} } func NewRunner(s settings.Settings) Runner { @@ -82,6 +87,7 @@ func NewRunner(s settings.Settings) Runner { return Runner{ statsManager: stats.NewStatManager(store, s), settings: s, + done: make(chan struct{}), } } @@ -89,10 +95,11 @@ func (runner *Runner) GetStatsStore() gostats.Store { return runner.statsManager.GetStatsStore() } -func createLimiter(srv server.Server, s settings.Settings, localCache *freecache.Cache, statsManager stats.Manager) (limiter.RateLimitCache, io.Closer) { +func createLimiter(ctx context.Context, srv server.Server, s settings.Settings, localCache *freecache.Cache, statsManager stats.Manager) (limiter.RateLimitCache, io.Closer) { switch s.BackendType { case "redis", "": return redis.NewRateLimiterCacheImplFromSettings( + ctx, s, localCache, srv, @@ -116,11 +123,33 @@ func createLimiter(srv server.Server, s settings.Settings, localCache *freecache } func (runner *Runner) Run() { + defer close(runner.done) + + ctx, cancel := context.WithCancel(context.Background()) + runner.mu.Lock() + runner.cancel = cancel + runner.mu.Unlock() + defer cancel() + + // Set up signal handling for graceful shutdown + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + go func() { + select { + case sig := <-sigs: + logger.Infof("Received signal %v, initiating shutdown", sig) + cancel() + case <-ctx.Done(): + } + }() + s := runner.settings if s.TracingEnabled { tp := trace.InitProductionTraceProvider(s.TracingExporterProtocol, s.TracingServiceName, s.TracingServiceNamespace, s.TracingServiceInstanceId, s.TracingSamplingRate) defer func() { - if err := tp.Shutdown(context.Background()); err != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := tp.Shutdown(shutdownCtx); err != nil { logger.Printf("Error shutting down tracer provider: %v", err) } }() @@ -156,8 +185,13 @@ func (runner *Runner) Run() { runner.srv = srv runner.mu.Unlock() - limiter, limiterCloser := createLimiter(srv, s, localCache, runner.statsManager) + limiter, limiterCloser := createLimiter(ctx, srv, s, localCache, runner.statsManager) runner.ratelimitCloser = limiterCloser + defer func() { + if err := limiterCloser.Close(); err != nil { + logger.Errorf("Error closing rate limiter resources: %v", err) + } + }() service := ratelimit.NewService( limiter, @@ -186,18 +220,15 @@ func (runner *Runner) Run() { // v2 proto is no longer supported pb.RegisterRateLimitServiceServer(srv.GrpcServer(), service) - srv.Start() + srv.Start(ctx) } func (runner *Runner) Stop() { runner.mu.Lock() - srv := runner.srv + cancel := runner.cancel runner.mu.Unlock() - if srv != nil { - srv.Stop() - } - - if runner.ratelimitCloser != nil { - _ = runner.ratelimitCloser.Close() + if cancel != nil { + cancel() } + <-runner.done } diff --git a/src/settings/settings.go b/src/settings/settings.go index 6a1be618..83d65776 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -191,6 +191,14 @@ type Settings struct { // See RedisPoolOnEmptyBehavior for possible values and details. RedisPerSecondPoolOnEmptyBehavior string `envconfig:"REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR" default:"WAIT"` + // RedisStartupInitialInterval is the initial backoff interval when retrying Redis connection at startup. + RedisStartupInitialInterval time.Duration `envconfig:"REDIS_STARTUP_INITIAL_INTERVAL" default:"1s"` + // RedisStartupMaxInterval is the maximum backoff interval between Redis connection retries at startup. + RedisStartupMaxInterval time.Duration `envconfig:"REDIS_STARTUP_MAX_INTERVAL" default:"30s"` + // RedisStartupMaxElapsedTime is the total time to keep retrying the Redis connection at startup. + // 0 means retry indefinitely until the connection succeeds. + RedisStartupMaxElapsedTime time.Duration `envconfig:"REDIS_STARTUP_MAX_ELAPSED_TIME" default:"0"` + // Memcache settings MemcacheHostPort []string `envconfig:"MEMCACHE_HOST_PORT" default:""` // MemcacheMaxIdleConns sets the maximum number of idle TCP connections per memcached node. diff --git a/test/redis/bench_test.go b/test/redis/bench_test.go index 2c153ba9..b055dc4e 100644 --- a/test/redis/bench_test.go +++ b/test/redis/bench_test.go @@ -44,7 +44,7 @@ func BenchmarkParallelDoLimit(b *testing.B) { return func(b *testing.B) { statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) - client := redis.NewClientImpl(statsStore, false, "", "tcp", "single", "127.0.0.1:6379", poolSize, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "") + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", "127.0.0.1:6379", poolSize, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 0) defer client.Close() cache := redis.NewFixedRateLimitCacheImpl(client, nil, utils.NewTimeSourceImpl(), rand.New(utils.NewLockedSource(time.Now().Unix())), 10, nil, 0.8, "", sm, true) diff --git a/test/redis/driver_impl_test.go b/test/redis/driver_impl_test.go index 8d1b3013..323c51e3 100644 --- a/test/redis/driver_impl_test.go +++ b/test/redis/driver_impl_test.go @@ -1,6 +1,7 @@ package redis_test import ( + "context" "fmt" "strings" "testing" @@ -38,8 +39,9 @@ func testNewClientImpl(t *testing.T, pipelineWindow time.Duration, pipelineLimit redisAuth := "123" statsStore := stats.NewStore(stats.NewNullSink(), false) + // Use a short maxElapsedTime so failing connection tests don't hang in retry loops. mkRedisClient := func(auth, addr string) redis.Client { - return redis.NewClientImpl(statsStore, false, auth, "tcp", "single", addr, 1, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "") + return redis.NewClientImpl(context.Background(), statsStore, false, auth, "tcp", "single", addr, 1, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 100*time.Millisecond) } t.Run("connection refused", func(t *testing.T) { @@ -66,9 +68,8 @@ func testNewClientImpl(t *testing.T, pipelineWindow time.Duration, pipelineLimit redisSrv.RequireAuth(redisAuth) - assert.PanicsWithError(t, "response returned from Conn: NOAUTH Authentication required.", func() { - mkRedisClient("", redisSrv.Addr()) - }) + panicErr := expectPanicError(t, func() { mkRedisClient("", redisSrv.Addr()) }) + assert.Contains(t, panicErr.Error(), "NOAUTH") }) t.Run("auth pass", func(t *testing.T) { @@ -103,9 +104,8 @@ func testNewClientImpl(t *testing.T, pipelineWindow time.Duration, pipelineLimit redisSrv.RequireUserAuth(user, pass) redisAuth := fmt.Sprintf("%s:invalid-password", user) - assert.PanicsWithError(t, "response returned from Conn: WRONGPASS invalid username-password pair", func() { - mkRedisClient(redisAuth, redisSrv.Addr()) - }) + panicErr := expectPanicError(t, func() { mkRedisClient(redisAuth, redisSrv.Addr()) }) + assert.Contains(t, panicErr.Error(), "WRONGPASS") }) } } @@ -119,7 +119,7 @@ func TestDoCmd(t *testing.T) { statsStore := stats.NewStore(stats.NewNullSink(), false) mkRedisClient := func(addr string) redis.Client { - return redis.NewClientImpl(statsStore, false, "", "tcp", "single", addr, 1, 0, 0, nil, false, nil, 10*time.Second, "", "") + return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, 0, 0, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 0) } t.Run("SETGET ok", func(t *testing.T) { @@ -164,7 +164,7 @@ func testPipeDo(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) f statsStore := stats.NewStore(stats.NewNullSink(), false) mkRedisClient := func(addr string) redis.Client { - return redis.NewClientImpl(statsStore, false, "", "tcp", "single", addr, 1, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "") + return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 0) } t.Run("SETGET ok", func(t *testing.T) { @@ -232,7 +232,7 @@ func TestPoolOnEmptyBehavior(t *testing.T) { // Helper to create client with specific on-empty behavior mkRedisClientWithBehavior := func(addr, behavior string) redis.Client { - return redis.NewClientImpl(statsStore, false, "", "tcp", "single", addr, 1, 0, 0, nil, false, nil, 10*time.Second, behavior, "") + return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, 0, 0, nil, false, nil, 10*time.Second, behavior, "", time.Second, 30*time.Second, 0) } t.Run("default behavior (empty string)", func(t *testing.T) { @@ -356,7 +356,8 @@ func TestNewClientImplSentinel(t *testing.T) { mkSentinelClient := func(auth, sentinelAuth, url string, useTls bool, timeout time.Duration) redis.Client { // Pass nil for tlsConfig - we can't test TLS without a real TLS server, // but we can verify the code path is executed (logs will show TLS is enabled) - return redis.NewClientImpl(statsStore, useTls, auth, "tcp", "sentinel", url, 1, 0, 0, nil, false, nil, timeout, "", sentinelAuth) + // Use a short maxElapsedTime so failing connection tests don't hang in retry loops. + return redis.NewClientImpl(context.Background(), statsStore, useTls, auth, "tcp", "sentinel", url, 1, 0, 0, nil, false, nil, timeout, "", sentinelAuth, time.Second, 30*time.Second, 100*time.Millisecond) } t.Run("invalid url format - missing sentinel addresses", func(t *testing.T) { diff --git a/test/server/health_test.go b/test/server/health_test.go index a2238a5c..40e7b518 100644 --- a/test/server/health_test.go +++ b/test/server/health_test.go @@ -18,117 +18,81 @@ import ( func TestHealthCheck(t *testing.T) { defer signal.Reset(syscall.SIGTERM) - recorder := httptest.NewRecorder() - hc := server.NewHealthChecker(health.NewServer(), "ratelimit", false) - r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) + checkHTTP := func(wantCode int) { + recorder := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) + hc.ServeHTTP(recorder, r) + if recorder.Code != wantCode { + t.Errorf("expected code %d actual %d", wantCode, recorder.Code) + } } - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) - } + // Redis starts unhealthy until the connection is confirmed. + checkHTTP(500) - err := hc.Fail(server.RedisHealthComponentName) + err := hc.Ok(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } + checkHTTP(200) - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 500 { - t.Errorf("expected code 500 actual %d", recorder.Code) + err = hc.Fail(server.RedisHealthComponentName) + if err != nil { + t.Errorf("Expected no errors for updating redis health status") } + checkHTTP(500) err = hc.Ok(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } - - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) - } - - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) - } + checkHTTP(200) } func TestHealthyWithAtLeastOneConfigLoaded(t *testing.T) { defer signal.Reset(syscall.SIGTERM) - recorder := httptest.NewRecorder() - hc := server.NewHealthChecker(health.NewServer(), "ratelimit", true) - r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 500 { - t.Errorf("expected code 500 actual %d", recorder.Code) + checkHTTP := func(wantCode int) { + recorder := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) + hc.ServeHTTP(recorder, r) + if recorder.Code != wantCode { + t.Errorf("expected code %d actual %d", wantCode, recorder.Code) + } } + // Both Redis and config start unhealthy. + checkHTTP(500) + err := hc.Ok(server.ConfigHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating config health status") } + // Config is ready but Redis still unhealthy. + checkHTTP(500) - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) - } - - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) + err = hc.Ok(server.RedisHealthComponentName) + if err != nil { + t.Errorf("Expected no errors for updating redis health status") } + // Both ready now. + checkHTTP(200) err = hc.Fail(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } - - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 500 { - t.Errorf("expected code 500 actual %d", recorder.Code) - } + checkHTTP(500) err = hc.Ok(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } - - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) - } - - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) - } + checkHTTP(200) } func TestGrpcHealthCheck(t *testing.T) { @@ -142,9 +106,10 @@ func TestGrpcHealthCheck(t *testing.T) { Service: "ratelimit", } + // Redis starts unhealthy until the connection is confirmed. res, _ := grpcHealthServer.Check(context.Background(), req) - if healthpb.HealthCheckResponse_SERVING != res.Status { - t.Errorf("expected status SERVING actual %v", res.Status) + if healthpb.HealthCheckResponse_NOT_SERVING != res.Status { + t.Errorf("expected status NOT_SERVING actual %v", res.Status) } err := hc.Ok(server.RedisHealthComponentName) diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index c77ba6b8..dc4ab8a7 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -97,6 +97,8 @@ func commonSetup(t *testing.T) rateLimitServiceTestSuite { ret.statStore = gostats.NewStore(gostats.NewNullSink(), false) ret.statsManager = mock_stats.NewMockStatManager(ret.statStore) ret.health = server.NewHealthChecker(health.NewServer(), "ratelimit", false) + // Tests use a mocked cache, so simulate a successful Redis connection. + _ = ret.health.Ok(server.RedisHealthComponentName) return ret } @@ -597,6 +599,8 @@ func TestServiceHealthStatus(test *testing.T) { healthyWithAtLeastOneConfigLoaded := false grpcHealthServer := health.NewServer() hc := server.NewHealthChecker(grpcHealthServer, "ratelimit", healthyWithAtLeastOneConfigLoaded) + // Tests use a mocked cache, so simulate a successful Redis connection. + _ = hc.Ok(server.RedisHealthComponentName) healthpb.RegisterHealthServer(grpc.NewServer(), grpcHealthServer) // Set up the service @@ -623,6 +627,8 @@ func TestServiceHealthStatusAtLeastOneConfigLoaded(test *testing.T) { healthyWithAtLeastOneConfigLoaded := true grpcHealthServer := health.NewServer() hc := server.NewHealthChecker(grpcHealthServer, "ratelimit", healthyWithAtLeastOneConfigLoaded) + // Tests use a mocked cache, so simulate a successful Redis connection. + _ = hc.Ok(server.RedisHealthComponentName) healthpb.RegisterHealthServer(grpc.NewServer(), grpcHealthServer) // Set up the service From 075a4603730b36c909769a7af7f78e08a97e446c Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Sun, 31 May 2026 13:12:55 -0400 Subject: [PATCH 10/29] Add quota mode to rate limit descriptor proto (#1148) Signed-off-by: Yusof Ganji --- api/ratelimit/config/ratelimit/v3/rls_conf.proto | 4 ++++ go.mod | 2 +- go.sum | 4 ++-- src/config/config_xds.go | 1 + test/provider/xds_grpc_sotw_provider_test.go | 3 ++- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/ratelimit/config/ratelimit/v3/rls_conf.proto b/api/ratelimit/config/ratelimit/v3/rls_conf.proto index 31617838..4b62e797 100644 --- a/api/ratelimit/config/ratelimit/v3/rls_conf.proto +++ b/api/ratelimit/config/ratelimit/v3/rls_conf.proto @@ -45,6 +45,10 @@ message RateLimitDescriptor { // Setting the `detailed_metric: true` for a descriptor will extend the metrics that are produced. bool detailed_metric = 6; + + // Mark the descriptor as quota mode. When quota_mode is true, the rate limit service will allow + // requests until all quota descriptors are over the limit. + bool quota_mode = 7; } // Rate-limit policy. diff --git a/go.mod b/go.mod index 4a05be8d..a4ddd12f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/coocood/freecache v1.2.4 github.com/envoyproxy/go-control-plane v0.14.0 github.com/envoyproxy/go-control-plane/envoy v1.36.0 - github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428 + github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260131204543-4ca8b9cded3e github.com/go-kit/log v0.2.1 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 55358ed7..b20cd0d7 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwB github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428 h1:AkYCu5lno5OO4HujsRKIDJ1XnqymM3saXdBoQ+lpWhk= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20250812085011-4cf7e8485428/go.mod h1:EWppLjKDYW2tcLcDYvR1kDWCZWeMVxH/0v6vaYXo0eA= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260131204543-4ca8b9cded3e h1:EHL6eLDhQduyYGEKh+QSXE7s7Yhg/hpeeHFT0ET0gBw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260131204543-4ca8b9cded3e/go.mod h1:buWyXJdrI6ayYbeGm3upu3Qf/qHHrdWfUHKnVrTD+vM= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= diff --git a/src/config/config_xds.go b/src/config/config_xds.go index f6c67ce2..90ab9fb8 100644 --- a/src/config/config_xds.go +++ b/src/config/config_xds.go @@ -22,6 +22,7 @@ func rateLimitDescriptorsPbToYaml(pb []*rls_conf_v3.RateLimitDescriptor) []YamlD Descriptors: rateLimitDescriptorsPbToYaml(d.Descriptors), ShadowMode: d.ShadowMode, DetailedMetric: d.DetailedMetric, + QuotaMode: d.QuotaMode, } } diff --git a/test/provider/xds_grpc_sotw_provider_test.go b/test/provider/xds_grpc_sotw_provider_test.go index 70fdbedd..7e2f48ed 100644 --- a/test/provider/xds_grpc_sotw_provider_test.go +++ b/test/provider/xds_grpc_sotw_provider_test.go @@ -236,6 +236,7 @@ func testDeeperLimitsXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Se RequestsPerUnit: 15, }, ShadowMode: true, + QuotaMode: true, }, }, }, @@ -271,7 +272,7 @@ func testDeeperLimitsXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Se "foo.k1_v1.k2_v2: unit=HOUR requests_per_unit=15, shadow_mode: false, quota_mode: false", "foo.j1_v2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false, quota_mode: false", "foo.j1_v2.j2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false, quota_mode: false", - "foo.j1_v2.j2_v2: unit=DAY requests_per_unit=15, shadow_mode: true, quota_mode: false", + "foo.j1_v2.j2_v2: unit=DAY requests_per_unit=15, shadow_mode: true, quota_mode: true", "bar.k1_v1: unit=MINUTE requests_per_unit=100, shadow_mode: false, quota_mode: false", }, strings.Split(strings.TrimSuffix(config.Dump(), "\n"), "\n")) } From 2dae96f0d5992d97d0d1bc70108f1c5fff568cf9 Mon Sep 17 00:00:00 2001 From: bloomenergyguy Date: Mon, 1 Jun 2026 22:55:42 -0700 Subject: [PATCH 11/29] Update to golang 1.26.3 (#1152) Co-authored-by: collin-lee Signed-off-by: Yusof Ganji --- .github/workflows/main.yaml | 2 +- .github/workflows/pullrequest.yaml | 2 +- Dockerfile | 2 +- Dockerfile.integration | 2 +- examples/xds-sotw-config-server/Dockerfile | 2 +- examples/xds-sotw-config-server/go.mod | 2 +- go.mod | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 69cce14b..a1912fac 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -49,7 +49,7 @@ jobs: - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.26.2" + go-version: "1.26.3" - name: run pre-commits run: | diff --git a/.github/workflows/pullrequest.yaml b/.github/workflows/pullrequest.yaml index 45f338d4..6159fb25 100644 --- a/.github/workflows/pullrequest.yaml +++ b/.github/workflows/pullrequest.yaml @@ -37,7 +37,7 @@ jobs: - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.26.2" + go-version: "1.26.3" - name: run pre-commits run: | diff --git a/Dockerfile b/Dockerfile index c1fc11e9..92d1914c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.2@sha256:b54cbf583d390341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716 AS build +FROM golang:1.26.3@sha256:2d6c80227255c3112a4d08e67ba98e58efd3846daf15d9d7d4c389565d881b1a AS build WORKDIR /ratelimit ENV GOPROXY=https://proxy.golang.org diff --git a/Dockerfile.integration b/Dockerfile.integration index 50c76d6c..c06d125c 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -1,5 +1,5 @@ # Running this docker image runs the integration tests. -FROM golang:1.26.2@sha256:b54cbf583d390341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716 +FROM golang:1.26.3@sha256:2d6c80227255c3112a4d08e67ba98e58efd3846daf15d9d7d4c389565d881b1a RUN apt-get update -y && apt-get install sudo stunnel4 redis memcached -y && rm -rf /var/lib/apt/lists/* diff --git a/examples/xds-sotw-config-server/Dockerfile b/examples/xds-sotw-config-server/Dockerfile index 8780ad62..589d0a17 100644 --- a/examples/xds-sotw-config-server/Dockerfile +++ b/examples/xds-sotw-config-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.2@sha256:b54cbf583d390341599d7bcbc062425c081105cc5ef6d170ced98ef9d047c716 AS build +FROM golang:1.26.3@sha256:2d6c80227255c3112a4d08e67ba98e58efd3846daf15d9d7d4c389565d881b1a AS build WORKDIR /xds-server COPY . . diff --git a/examples/xds-sotw-config-server/go.mod b/examples/xds-sotw-config-server/go.mod index 6e1ad414..323a0faa 100644 --- a/examples/xds-sotw-config-server/go.mod +++ b/examples/xds-sotw-config-server/go.mod @@ -1,6 +1,6 @@ module github.com/envoyproxy/ratelimit/examples/xds-sotw-config-server -go 1.26.1 +go 1.26.3 require ( github.com/envoyproxy/go-control-plane v0.12.1-0.20240123181358-841e293a220b diff --git a/go.mod b/go.mod index a4ddd12f..03a1f6cc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/envoyproxy/ratelimit -go 1.26.2 +go 1.26.3 require ( github.com/DataDog/datadog-go/v5 v5.5.0 From d98171274bfab80458ef67a1d83cedd2ffaaaa35 Mon Sep 17 00:00:00 2001 From: Immanuel Tikhonov <122638311+immanuwell@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:05:56 +0400 Subject: [PATCH 12/29] fix: correct typos in memcache error messages and variable name (#1150) Signed-off-by: Immanuel Tikhonov Signed-off-by: immanuwell Signed-off-by: Yusof Ganji --- src/memcached/cache_impl.go | 7 ++++--- src/redis/fixed_cache_impl.go | 7 ++++--- src/service/ratelimit.go | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/memcached/cache_impl.go b/src/memcached/cache_impl.go index c8bcd077..eb65b63d 100644 --- a/src/memcached/cache_impl.go +++ b/src/memcached/cache_impl.go @@ -97,7 +97,8 @@ func (this *rateLimitMemcacheImpl) DoLimit( } // Generate trace - _, span := tracer.Start(ctx, "Memcached Fetch Execution", + _, span := tracer.Start( + ctx, "Memcached Fetch Execution", trace.WithAttributes( attribute.Int("keysToGet length", len(keysToGet)), ), @@ -202,7 +203,7 @@ func refreshServersPeriodically(serverList *memcache.ServerList, srv string, d t case <-t.C: err := refreshServers(serverList, srv, resolver) if err != nil { - logger.Warn("failed to refresh memcahce hosts") + logger.Warn("failed to refresh memcache hosts") } else { logger.Debug("refreshed memcache hosts") } @@ -246,7 +247,7 @@ func newMemcachedFromSrv(srv string, d time.Duration, resolver srv.SrvResolver) func newMemcacheFromSettings(s settings.Settings) Client { if s.MemcacheSrv != "" && len(s.MemcacheHostPort) > 0 { - panic(MemcacheError("Both MEMCADHE_HOST_PORT and MEMCACHE_SRV are set")) + panic(MemcacheError("Both MEMCACHE_HOST_PORT and MEMCACHE_SRV are set")) } var client *memcache.Client if s.MemcacheSrv != "" { diff --git a/src/redis/fixed_cache_impl.go b/src/redis/fixed_cache_impl.go index 1034d3be..8ebe1e4d 100644 --- a/src/redis/fixed_cache_impl.go +++ b/src/redis/fixed_cache_impl.go @@ -42,7 +42,7 @@ func pipelineAppendtoGet(client Client, pipeline *Pipeline, key string, result * } func (this *fixedRateLimitCacheImpl) getHitsAddend(hitsAddend uint64, isCacheKeyOverlimit, isCacheKeyNearlimit, - isNearLimt bool, + isNearLimit bool, ) uint64 { // If stopCacheKeyIncrementWhenOverlimit is false, then we always increment the cache key. if !this.stopCacheKeyIncrementWhenOverlimit { @@ -64,7 +64,7 @@ func (this *fixedRateLimitCacheImpl) getHitsAddend(hitsAddend uint64, isCacheKey // If stopCacheKeyIncrementWhenOverlimit is true, and some of the keys are near limit, then // we only increment the cache key if the key is near limit. - if isNearLimt { + if isNearLimit { return hitsAddend } @@ -187,7 +187,8 @@ func (this *fixedRateLimitCacheImpl) DoLimit( } // Generate trace - _, span := tracer.Start(ctx, "Redis Pipeline Execution", + _, span := tracer.Start( + ctx, "Redis Pipeline Execution", trace.WithAttributes( attribute.Int("pipeline length", len(pipeline)), attribute.Int("perSecondPipeline length", len(perSecondPipeline)), diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index 48e6a59a..9bd9241d 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -363,7 +363,7 @@ func mergeMetadata(dest *structpb.Struct, src *structpb.Struct) { continue } } - // TODO(yanavlasov): add option to overwrite or add if typoe is a list + // TODO(yanavlasov): add option to overwrite or add if type is a list } else { // Otherwise overwrite or add dest.GetFields()[k] = v @@ -403,7 +403,8 @@ func (this *service) ShouldRateLimit( ) (finalResponse *pb.RateLimitResponse, finalError error) { logger.Debugf("ShouldRateLimit: %+v", request) // Generate trace - _, span := tracer.Start(ctx, "ShouldRateLimit Execution", + _, span := tracer.Start( + ctx, "ShouldRateLimit Execution", trace.WithAttributes( attribute.String("domain", request.Domain), attribute.String("request string", request.String()), From b865a32d8a228fef10fcebe6e0306ad7e03ad567 Mon Sep 17 00:00:00 2001 From: Duong Huynh <51880891+hltduong@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:46:18 +0700 Subject: [PATCH 13/29] feat: bound cluster pipeline parallelism (#1149) * redis: bound cluster pipeline parallelism Signed-off-by: dthuynh * Refactor to address comment: use gRPC request context in PipeDo, cap the parallelism to RedisPoolSize Signed-off-by: dthuynh --------- Signed-off-by: dthuynh Co-authored-by: dthuynh Signed-off-by: Yusof Ganji --- README.md | 1 + go.mod | 1 + go.sum | 2 + src/redis/cache_impl.go | 12 +- src/redis/driver.go | 9 +- src/redis/driver_impl.go | 137 +++++++++++++---- src/redis/driver_impl_test.go | 221 ++++++++++++++++++++++++++++ src/redis/fixed_cache_impl.go | 8 +- src/settings/settings.go | 26 +++- src/settings/settings_test.go | 31 ++++ test/mocks/redis/redis.go | 9 +- test/redis/driver_impl_test.go | 10 +- test/redis/fixed_cache_impl_test.go | 46 +++--- 13 files changed, 434 insertions(+), 79 deletions(-) create mode 100644 src/redis/driver_impl_test.go diff --git a/README.md b/README.md index 6cf2e51c..f2e8d6df 100644 --- a/README.md +++ b/README.md @@ -1341,6 +1341,7 @@ For high throughput scenarios, ratelimit supports write buffering via [radix v4' 1. `REDIS_PIPELINE_WINDOW` & `REDIS_PERSECOND_PIPELINE_WINDOW`: controls how often buffered writes are flushed to the network connection. When set to a non-zero value (e.g., 150us-500us), radix v4 will buffer multiple concurrent write operations and flush them together, reducing system calls and improving throughput. If zero, each write is flushed immediately. **Required for Redis Cluster mode.** 1. `REDIS_PIPELINE_LIMIT` & `REDIS_PERSECOND_PIPELINE_LIMIT`: **DEPRECATED** - These settings have no effect in radix v4. Write buffering is controlled solely by the window settings above. +1. `REDIS_CLUSTER_PIPELINE_PARALLELISM` & `REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM`: controls per-key pipeline group concurrency in Redis Cluster mode. Default: `1`, which preserves the legacy serial behavior. Set to `0` for auto parallelism bounded by the corresponding Redis pool size, or a value greater than `1` to bound concurrent Redis group calls per pipeline execution. Configured values greater than the corresponding Redis pool size are capped to the pool size. Write buffering is disabled by default (window = 0). For optimal performance, set `REDIS_PIPELINE_WINDOW` to 150us-500us depending on your latency requirements and load patterns. diff --git a/go.mod b/go.mod index 03a1f6cc..1a8edfb4 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/net v0.52.0 + golang.org/x/sync v0.20.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index b20cd0d7..d5c46a40 100644 --- a/go.sum +++ b/go.sum @@ -210,6 +210,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/src/redis/cache_impl.go b/src/redis/cache_impl.go index 266c0a4d..f7b7072c 100644 --- a/src/redis/cache_impl.go +++ b/src/redis/cache_impl.go @@ -18,15 +18,19 @@ func NewRateLimiterCacheImplFromSettings(ctx context.Context, s settings.Setting closer := &utils.MultiCloser{} var perSecondPool Client if s.RedisPerSecond { - perSecondPool = NewClientImpl(ctx, srv.Scope().Scope("redis_per_second_pool"), s.RedisPerSecondTls, s.RedisPerSecondAuth, s.RedisPerSecondSocketType, + perSecondPool = newClientImpl(ctx, srv.Scope().Scope("redis_per_second_pool"), s.RedisPerSecondTls, s.RedisPerSecondAuth, s.RedisPerSecondSocketType, s.RedisPerSecondType, s.RedisPerSecondUrl, s.RedisPerSecondPoolSize, s.RedisPerSecondPipelineWindow, s.RedisPerSecondPipelineLimit, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv, s.RedisPerSecondTimeout, - s.RedisPerSecondPoolOnEmptyBehavior, s.RedisPerSecondSentinelAuth, s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime) + s.RedisPerSecondPoolOnEmptyBehavior, s.RedisPerSecondSentinelAuth, + s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime, + s.RedisPerSecondClusterPipelineParallelism) closer.Closers = append(closer.Closers, perSecondPool) } - otherPool := NewClientImpl(ctx, srv.Scope().Scope("redis_pool"), s.RedisTls, s.RedisAuth, s.RedisSocketType, s.RedisType, s.RedisUrl, s.RedisPoolSize, + otherPool := newClientImpl(ctx, srv.Scope().Scope("redis_pool"), s.RedisTls, s.RedisAuth, s.RedisSocketType, s.RedisType, s.RedisUrl, s.RedisPoolSize, s.RedisPipelineWindow, s.RedisPipelineLimit, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv, s.RedisTimeout, - s.RedisPoolOnEmptyBehavior, s.RedisSentinelAuth, s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime) + s.RedisPoolOnEmptyBehavior, s.RedisSentinelAuth, + s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime, + s.RedisClusterPipelineParallelism) closer.Closers = append(closer.Closers, otherPool) return NewFixedRateLimitCacheImpl( diff --git a/src/redis/driver.go b/src/redis/driver.go index 8f52df42..4c6bd4f8 100644 --- a/src/redis/driver.go +++ b/src/redis/driver.go @@ -1,6 +1,10 @@ package redis -import "github.com/mediocregopher/radix/v4" +import ( + "context" + + "github.com/mediocregopher/radix/v4" +) // Errors that may be raised during config parsing. type RedisError string @@ -32,8 +36,9 @@ type Client interface { // a single write, then reads their responses in a single read. This reduces // network delay into a single round-trip. // + // @param ctx supplies the context for Redis I/O. // @param pipeline supplies the queue for pending commands. - PipeDo(pipeline Pipeline) error + PipeDo(ctx context.Context, pipeline Pipeline) error // Once Close() is called all future method calls on the Client will return // an error diff --git a/src/redis/driver_impl.go b/src/redis/driver_impl.go index f65269eb..6b93d02a 100644 --- a/src/redis/driver_impl.go +++ b/src/redis/driver_impl.go @@ -13,6 +13,7 @@ import ( "github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4/trace" logger "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" "github.com/envoyproxy/ratelimit/src/server" "github.com/envoyproxy/ratelimit/src/utils" @@ -69,9 +70,10 @@ type redisClient interface { } type clientImpl struct { - client redisClient - stats poolStats - isCluster bool + client redisClient + stats poolStats + isCluster bool + clusterPipelineParallelism int } func checkError(err error) { @@ -80,6 +82,27 @@ func checkError(err error) { } } +func effectiveClusterPipelineParallelism(configuredParallelism, poolSize int) int { + if configuredParallelism < 0 { + panic(RedisError("redis cluster pipeline parallelism must be >= 0")) + } + + if configuredParallelism == 1 { + return 1 + } + + poolCeiling := poolSize + if poolCeiling < 1 { + poolCeiling = 1 + } + + if configuredParallelism == 0 || configuredParallelism > poolCeiling { + return poolCeiling + } + + return configuredParallelism +} + // createDialer creates a radix.Dialer with timeout, TLS, and auth configuration // targetName is used for logging to identify the connection target (e.g., URL, "sentinel(url)") func createDialer(timeout time.Duration, useTls bool, tlsConfig *tls.Config, auth string, targetName string) radix.Dialer { @@ -124,6 +147,18 @@ func NewClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, re pipelineWindow time.Duration, pipelineLimit int, tlsConfig *tls.Config, healthCheckActiveConnection bool, srv server.Server, timeout time.Duration, poolOnEmptyBehavior string, sentinelAuth string, startupInitialInterval, startupMaxInterval, startupMaxElapsedTime time.Duration, +) Client { + return newClientImpl(ctx, scope, useTls, auth, redisSocketType, redisType, url, poolSize, + pipelineWindow, pipelineLimit, tlsConfig, healthCheckActiveConnection, srv, + timeout, poolOnEmptyBehavior, sentinelAuth, + startupInitialInterval, startupMaxInterval, startupMaxElapsedTime, 1) +} + +func newClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, redisSocketType, redisType, url string, poolSize int, + pipelineWindow time.Duration, pipelineLimit int, tlsConfig *tls.Config, healthCheckActiveConnection bool, srv server.Server, + timeout time.Duration, poolOnEmptyBehavior string, sentinelAuth string, + startupInitialInterval, startupMaxInterval, startupMaxElapsedTime time.Duration, + clusterPipelineParallelism int, ) Client { maskedUrl := utils.MaskCredentialsInUrl(url) logger.Warnf("connecting to redis on %s with pool size %d", maskedUrl, poolSize) @@ -156,6 +191,21 @@ func NewClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, re logger.Debugf("Cluster mode: setting WriteFlushInterval to %v", pipelineWindow) } + effectivePipelineParallelism := clusterPipelineParallelism + if isCluster { + effectivePipelineParallelism = effectiveClusterPipelineParallelism(clusterPipelineParallelism, poolSize) + switch { + case clusterPipelineParallelism == 0: + logger.Warnf("Redis cluster pipeline parallelism: auto bounded to Redis pool size (%d concurrent groups)", effectivePipelineParallelism) + case clusterPipelineParallelism != effectivePipelineParallelism: + logger.Warnf("Redis cluster pipeline parallelism: configured value %d exceeds Redis pool size %d; bounded to %d concurrent groups", clusterPipelineParallelism, poolSize, effectivePipelineParallelism) + case effectivePipelineParallelism == 1: + logger.Warnf("Redis cluster pipeline parallelism: disabled (serial legacy behavior)") + default: + logger.Warnf("Redis cluster pipeline parallelism: bounded to %d concurrent groups", effectivePipelineParallelism) + } + } + // IMPORTANT: radix v4 pool behavior changes from v3 // // v4 uses a FIXED pool size and BLOCKS when all connections are in use. @@ -278,9 +328,10 @@ func NewClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, re } return &clientImpl{ - client: client, - stats: stats, - isCluster: isCluster, + client: client, + stats: stats, + isCluster: isCluster, + clusterPipelineParallelism: effectivePipelineParallelism, } } @@ -312,8 +363,7 @@ func (c *clientImpl) PipeAppend(pipeline Pipeline, rcv interface{}, cmd, key str }) } -func (c *clientImpl) PipeDo(pipeline Pipeline) error { - ctx := context.Background() +func (c *clientImpl) PipeDo(ctx context.Context, pipeline Pipeline) error { if c.isCluster { // Cluster mode: group commands by key and execute each group as a pipeline. // This ensures INCRBY + EXPIRE for the same key are pipelined together (same slot), @@ -329,13 +379,25 @@ func (c *clientImpl) PipeDo(pipeline Pipeline) error { return c.client.Do(ctx, p) } -// executeGroupedPipeline groups pipeline actions by key and executes each group -// as a separate pipeline. This allows same-key commands (like INCRBY + EXPIRE) -// to be pipelined together even in cluster mode. +// executeGroupedPipeline routes a pipeline of Redis actions in cluster mode +// via a three-tier dispatch: +// +// 1. Single-action fast path (len==1): skip grouping entirely. +// 2. Serial compatibility path: clusterPipelineParallelism == 1 preserves +// the pre-parallelization behavior. +// 3. General path: group actions by key (same-key commands like INCRBY + +// EXPIRE are still pipelined together) and execute groups concurrently +// via errgroup with clusterPipelineParallelism as the max concurrent group +// count. func (c *clientImpl) executeGroupedPipeline(ctx context.Context, pipeline Pipeline) error { - // Group actions by key, preserving first-occurrence order - var groups [][]radix.Action - keyToIndex := make(map[string]int) + // Tier 1: single action — skip grouping, skip map alloc. + if len(pipeline) == 1 { + return c.client.Do(ctx, pipeline[0].Action) + } + + // Tier 2: group by key, preserving first-occurrence order. + groups := make([][]radix.Action, 0, len(pipeline)) + keyToIndex := make(map[string]int, len(pipeline)) for _, pa := range pipeline { if idx, exists := keyToIndex[pa.Key]; exists { @@ -346,23 +408,40 @@ func (c *clientImpl) executeGroupedPipeline(ctx context.Context, pipeline Pipeli } } - // Execute each group + if c.clusterPipelineParallelism == 1 { + return c.doPipelineGroupsSerial(ctx, groups) + } + + // Execute groups concurrently. + eg, egCtx := errgroup.WithContext(ctx) + if c.clusterPipelineParallelism > 1 { + eg.SetLimit(c.clusterPipelineParallelism) + } for _, actions := range groups { - if len(actions) == 1 { - if err := c.client.Do(ctx, actions[0]); err != nil { - return err - } - } else { - // Multiple commands for same key: pipeline them together - p := radix.NewPipeline() - for _, action := range actions { - p.Append(action) - } - if err := c.client.Do(ctx, p); err != nil { - return err - } - } + actions := actions + eg.Go(func() error { + return c.doPipelineGroup(egCtx, actions) + }) } + return eg.Wait() +} +func (c *clientImpl) doPipelineGroupsSerial(ctx context.Context, groups [][]radix.Action) error { + for _, actions := range groups { + if err := c.doPipelineGroup(ctx, actions); err != nil { + return err + } + } return nil } + +func (c *clientImpl) doPipelineGroup(ctx context.Context, actions []radix.Action) error { + if len(actions) == 1 { + return c.client.Do(ctx, actions[0]) + } + p := radix.NewPipeline() + for _, action := range actions { + p.Append(action) + } + return c.client.Do(ctx, p) +} diff --git a/src/redis/driver_impl_test.go b/src/redis/driver_impl_test.go new file mode 100644 index 00000000..57a6864c --- /dev/null +++ b/src/redis/driver_impl_test.go @@ -0,0 +1,221 @@ +package redis + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/mediocregopher/radix/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testAction struct { + key string + delay time.Duration + err error +} + +func (a *testAction) Properties() radix.ActionProperties { + return radix.ActionProperties{ + Keys: []string{a.key}, + CanRetry: true, + CanPipeline: true, + } +} + +func (a *testAction) Perform(ctx context.Context, _ radix.Conn) error { + if a.delay > 0 { + timer := time.NewTimer(a.delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + } + return a.err +} + +type recordingRedisClient struct { + mu sync.Mutex + calls []radix.Action + inFlight int + maxInFlight int +} + +func (c *recordingRedisClient) Do(ctx context.Context, action radix.Action) error { + c.mu.Lock() + c.calls = append(c.calls, action) + c.inFlight++ + if c.inFlight > c.maxInFlight { + c.maxInFlight = c.inFlight + } + c.mu.Unlock() + + defer func() { + c.mu.Lock() + c.inFlight-- + c.mu.Unlock() + }() + + if action, ok := action.(*testAction); ok { + return action.Perform(ctx, nil) + } + return nil +} + +func (c *recordingRedisClient) Close() error { + return nil +} + +func (c *recordingRedisClient) callCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.calls) +} + +func (c *recordingRedisClient) maxConcurrentCalls() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.maxInFlight +} + +func TestEffectiveClusterPipelineParallelism(t *testing.T) { + tests := []struct { + name string + configuredParallelism int + poolSize int + want int + }{ + { + name: "serial legacy behavior", + configuredParallelism: 1, + poolSize: 10, + want: 1, + }, + { + name: "auto uses pool size", + configuredParallelism: 0, + poolSize: 10, + want: 10, + }, + { + name: "bounded below pool size", + configuredParallelism: 8, + poolSize: 10, + want: 8, + }, + { + name: "configured value is capped to pool size", + configuredParallelism: 20, + poolSize: 10, + want: 10, + }, + { + name: "pool ceiling never produces zero", + configuredParallelism: 0, + poolSize: 0, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, effectiveClusterPipelineParallelism(tt.configuredParallelism, tt.poolSize)) + }) + } +} + +func TestEffectiveClusterPipelineParallelismRejectsNegativeConfig(t *testing.T) { + assert.Panics(t, func() { + effectiveClusterPipelineParallelism(-1, 10) + }) +} + +func TestExecuteGroupedPipelineSingleActionFastPath(t *testing.T) { + fakeClient := &recordingRedisClient{} + client := &clientImpl{ + client: fakeClient, + clusterPipelineParallelism: 1, + } + + err := client.executeGroupedPipeline(context.Background(), Pipeline{ + {Key: "a", Action: &testAction{key: "a"}}, + }) + + require.NoError(t, err) + assert.Equal(t, 1, fakeClient.callCount()) +} + +func TestExecuteGroupedPipelineSerialCompatibilityStopsOnFirstError(t *testing.T) { + expectedErr := errors.New("redis failed") + fakeClient := &recordingRedisClient{} + client := &clientImpl{ + client: fakeClient, + clusterPipelineParallelism: 1, + } + + err := client.executeGroupedPipeline(context.Background(), Pipeline{ + {Key: "a", Action: &testAction{key: "a", err: expectedErr}}, + {Key: "b", Action: &testAction{key: "b"}}, + }) + + require.ErrorIs(t, err, expectedErr) + assert.Equal(t, 1, fakeClient.callCount()) + assert.Equal(t, 1, fakeClient.maxConcurrentCalls()) +} + +func TestExecuteGroupedPipelineGroupsSameKeyActions(t *testing.T) { + fakeClient := &recordingRedisClient{} + client := &clientImpl{ + client: fakeClient, + clusterPipelineParallelism: 2, + } + + err := client.executeGroupedPipeline(context.Background(), Pipeline{ + {Key: "a", Action: &testAction{key: "a"}}, + {Key: "a", Action: &testAction{key: "a"}}, + }) + + require.NoError(t, err) + assert.Equal(t, 1, fakeClient.callCount()) +} + +func TestExecuteGroupedPipelineParallelismAllowsConcurrentGroups(t *testing.T) { + fakeClient := &recordingRedisClient{} + client := &clientImpl{ + client: fakeClient, + clusterPipelineParallelism: 3, + } + + err := client.executeGroupedPipeline(context.Background(), Pipeline{ + {Key: "a", Action: &testAction{key: "a", delay: 100 * time.Millisecond}}, + {Key: "b", Action: &testAction{key: "b", delay: 100 * time.Millisecond}}, + {Key: "c", Action: &testAction{key: "c", delay: 100 * time.Millisecond}}, + }) + + require.NoError(t, err) + assert.Equal(t, 3, fakeClient.callCount()) + assert.Equal(t, 3, fakeClient.maxConcurrentCalls()) +} + +func TestExecuteGroupedPipelineBoundedParallelism(t *testing.T) { + fakeClient := &recordingRedisClient{} + client := &clientImpl{ + client: fakeClient, + clusterPipelineParallelism: 2, + } + + err := client.executeGroupedPipeline(context.Background(), Pipeline{ + {Key: "a", Action: &testAction{key: "a", delay: 100 * time.Millisecond}}, + {Key: "b", Action: &testAction{key: "b", delay: 100 * time.Millisecond}}, + {Key: "c", Action: &testAction{key: "c", delay: 100 * time.Millisecond}}, + }) + + require.NoError(t, err) + assert.Equal(t, 3, fakeClient.callCount()) + assert.Equal(t, 2, fakeClient.maxConcurrentCalls()) +} diff --git a/src/redis/fixed_cache_impl.go b/src/redis/fixed_cache_impl.go index 8ebe1e4d..9e891859 100644 --- a/src/redis/fixed_cache_impl.go +++ b/src/redis/fixed_cache_impl.go @@ -134,10 +134,10 @@ func (this *fixedRateLimitCacheImpl) DoLimit( } if pipelineToGet != nil { - checkError(this.client.PipeDo(pipelineToGet)) + checkError(this.client.PipeDo(ctx, pipelineToGet)) } if perSecondPipelineToGet != nil { - checkError(this.perSecondClient.PipeDo(perSecondPipelineToGet)) + checkError(this.perSecondClient.PipeDo(ctx, perSecondPipelineToGet)) } for i, cacheKey := range cacheKeys { @@ -197,10 +197,10 @@ func (this *fixedRateLimitCacheImpl) DoLimit( defer span.End() if pipeline != nil { - checkError(this.client.PipeDo(pipeline)) + checkError(this.client.PipeDo(ctx, pipeline)) } if perSecondPipeline != nil { - checkError(this.perSecondClient.PipeDo(perSecondPipeline)) + checkError(this.perSecondClient.PipeDo(ctx, perSecondPipeline)) } // Now fetch the pipeline. diff --git a/src/settings/settings.go b/src/settings/settings.go index 83d65776..2129cf80 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -149,14 +149,20 @@ type Settings struct { // RedisPipelineLimit is DEPRECATED and unused in radix v4. // This setting has no effect. Radix v4 does not support explicit pipeline size limits. // Write buffering is controlled solely by RedisPipelineWindow (WriteFlushInterval). - RedisPipelineLimit int `envconfig:"REDIS_PIPELINE_LIMIT" default:"0"` - RedisPerSecond bool `envconfig:"REDIS_PERSECOND" default:"false"` - RedisPerSecondSocketType string `envconfig:"REDIS_PERSECOND_SOCKET_TYPE" default:"unix"` - RedisPerSecondType string `envconfig:"REDIS_PERSECOND_TYPE" default:"SINGLE"` - RedisPerSecondUrl string `envconfig:"REDIS_PERSECOND_URL" default:"/var/run/nutcracker/ratelimitpersecond.sock"` - RedisPerSecondPoolSize int `envconfig:"REDIS_PERSECOND_POOL_SIZE" default:"10"` - RedisPerSecondAuth string `envconfig:"REDIS_PERSECOND_AUTH" default:""` - RedisPerSecondTls bool `envconfig:"REDIS_PERSECOND_TLS" default:"false"` + RedisPipelineLimit int `envconfig:"REDIS_PIPELINE_LIMIT" default:"0"` + // RedisClusterPipelineParallelism controls how many per-key pipeline groups + // can be executed concurrently in Redis Cluster mode. + // - 0: auto, bounded to REDIS_POOL_SIZE + // - 1: serial legacy behavior (default) + // - >1: bounded parallelism, capped to REDIS_POOL_SIZE + RedisClusterPipelineParallelism int `envconfig:"REDIS_CLUSTER_PIPELINE_PARALLELISM" default:"1"` + RedisPerSecond bool `envconfig:"REDIS_PERSECOND" default:"false"` + RedisPerSecondSocketType string `envconfig:"REDIS_PERSECOND_SOCKET_TYPE" default:"unix"` + RedisPerSecondType string `envconfig:"REDIS_PERSECOND_TYPE" default:"SINGLE"` + RedisPerSecondUrl string `envconfig:"REDIS_PERSECOND_URL" default:"/var/run/nutcracker/ratelimitpersecond.sock"` + RedisPerSecondPoolSize int `envconfig:"REDIS_PERSECOND_POOL_SIZE" default:"10"` + RedisPerSecondAuth string `envconfig:"REDIS_PERSECOND_AUTH" default:""` + RedisPerSecondTls bool `envconfig:"REDIS_PERSECOND_TLS" default:"false"` // RedisSentinelAuth is the password for authenticating to Redis Sentinel nodes (not the Redis master/replica). // This is separate from RedisAuth which is used for authenticating to the Redis master/replica nodes. // If empty, no authentication will be attempted when connecting to Sentinel nodes. @@ -171,6 +177,10 @@ type Settings struct { // RedisPerSecondPipelineLimit is DEPRECATED and unused in radix v4. // See comments of RedisPipelineLimit for details. RedisPerSecondPipelineLimit int `envconfig:"REDIS_PERSECOND_PIPELINE_LIMIT" default:"0"` + // RedisPerSecondClusterPipelineParallelism controls per-key pipeline group + // concurrency for per-second Redis Cluster mode. + // See comments of RedisClusterPipelineParallelism for details. + RedisPerSecondClusterPipelineParallelism int `envconfig:"REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM" default:"1"` // Enable healthcheck to check Redis Connection. If there is no active connection, healthcheck failed. RedisHealthCheckActiveConnection bool `envconfig:"REDIS_HEALTH_CHECK_ACTIVE_CONNECTION" default:"false"` // RedisTimeout sets the timeout for Redis connection and I/O operations. diff --git a/src/settings/settings_test.go b/src/settings/settings_test.go index f15fbea2..c6ab2fd0 100644 --- a/src/settings/settings_test.go +++ b/src/settings/settings_test.go @@ -97,6 +97,37 @@ func TestRedisPerSecondPoolOnEmptyBehavior_Error(t *testing.T) { assert.Equal(t, "ERROR", settings.RedisPerSecondPoolOnEmptyBehavior) } +func TestRedisClusterPipelineParallelism_Default(t *testing.T) { + os.Unsetenv("REDIS_CLUSTER_PIPELINE_PARALLELISM") + os.Unsetenv("REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM") + + settings := NewSettings() + + assert.Equal(t, 1, settings.RedisClusterPipelineParallelism) + assert.Equal(t, 1, settings.RedisPerSecondClusterPipelineParallelism) +} + +func TestRedisClusterPipelineParallelism_Configured(t *testing.T) { + os.Setenv("REDIS_CLUSTER_PIPELINE_PARALLELISM", "8") + os.Setenv("REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM", "4") + defer os.Unsetenv("REDIS_CLUSTER_PIPELINE_PARALLELISM") + defer os.Unsetenv("REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM") + + settings := NewSettings() + + assert.Equal(t, 8, settings.RedisClusterPipelineParallelism) + assert.Equal(t, 4, settings.RedisPerSecondClusterPipelineParallelism) +} + +func TestRedisClusterPipelineParallelism_Auto(t *testing.T) { + os.Setenv("REDIS_CLUSTER_PIPELINE_PARALLELISM", "0") + defer os.Unsetenv("REDIS_CLUSTER_PIPELINE_PARALLELISM") + + settings := NewSettings() + + assert.Equal(t, 0, settings.RedisClusterPipelineParallelism) +} + // Test both pools can be configured independently func TestRedisPoolOnEmptyBehavior_IndependentConfiguration(t *testing.T) { os.Setenv("REDIS_POOL_ON_EMPTY_BEHAVIOR", "ERROR") diff --git a/test/mocks/redis/redis.go b/test/mocks/redis/redis.go index 192b41c2..65410f2e 100644 --- a/test/mocks/redis/redis.go +++ b/test/mocks/redis/redis.go @@ -5,6 +5,7 @@ package mock_redis import ( + context "context" reflect "reflect" gomock "github.com/golang/mock/gomock" @@ -102,15 +103,15 @@ func (mr *MockClientMockRecorder) PipeAppend(arg0, arg1, arg2, arg3 interface{}, } // PipeDo mocks base method -func (m *MockClient) PipeDo(arg0 redis.Pipeline) error { +func (m *MockClient) PipeDo(arg0 context.Context, arg1 redis.Pipeline) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PipeDo", arg0) + ret := m.ctrl.Call(m, "PipeDo", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // PipeDo indicates an expected call of PipeDo -func (mr *MockClientMockRecorder) PipeDo(arg0 interface{}) *gomock.Call { +func (mr *MockClientMockRecorder) PipeDo(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipeDo", reflect.TypeOf((*MockClient)(nil).PipeDo), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipeDo", reflect.TypeOf((*MockClient)(nil).PipeDo), arg0, arg1) } diff --git a/test/redis/driver_impl_test.go b/test/redis/driver_impl_test.go index 323c51e3..ecfa5f69 100644 --- a/test/redis/driver_impl_test.go +++ b/test/redis/driver_impl_test.go @@ -178,7 +178,7 @@ func testPipeDo(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) f pipeline = client.PipeAppend(pipeline, nil, "SET", "foo", "bar") pipeline = client.PipeAppend(pipeline, &res, "GET", "foo") - assert.Nil(t, client.PipeDo(pipeline)) + assert.Nil(t, client.PipeDo(context.Background(), pipeline)) assert.Equal(t, "bar", res) }) @@ -190,10 +190,10 @@ func testPipeDo(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) f var res uint32 hits := uint32(1) - assert.Nil(t, client.PipeDo(client.PipeAppend(redis.Pipeline{}, &res, "INCRBY", "a", hits))) + assert.Nil(t, client.PipeDo(context.Background(), client.PipeAppend(redis.Pipeline{}, &res, "INCRBY", "a", hits))) assert.Equal(t, hits, res) - assert.Nil(t, client.PipeDo(client.PipeAppend(redis.Pipeline{}, &res, "INCRBY", "a", hits))) + assert.Nil(t, client.PipeDo(context.Background(), client.PipeAppend(redis.Pipeline{}, &res, "INCRBY", "a", hits))) assert.Equal(t, uint32(2), res) }) @@ -201,7 +201,7 @@ func testPipeDo(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) f redisSrv := mustNewRedisServer() client := mkRedisClient(redisSrv.Addr()) - assert.Nil(t, nil, client.PipeDo(client.PipeAppend(redis.Pipeline{}, nil, "SET", "foo", "bar"))) + assert.Nil(t, nil, client.PipeDo(context.Background(), client.PipeAppend(redis.Pipeline{}, nil, "SET", "foo", "bar"))) redisSrv.Close() @@ -216,7 +216,7 @@ func testPipeDo(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) f assert.True(t, hasConnectionError, "expected connection error, got: %s", errMsg) } - expectErrContainEOF(t, client.PipeDo(client.PipeAppend(redis.Pipeline{}, nil, "GET", "foo"))) + expectErrContainEOF(t, client.PipeDo(context.Background(), client.PipeAppend(redis.Pipeline{}, nil, "GET", "foo"))) }) } } diff --git a/test/redis/fixed_cache_impl_test.go b/test/redis/fixed_cache_impl_test.go index e52abb68..1de91d01 100644 --- a/test/redis/fixed_cache_impl_test.go +++ b/test/redis/fixed_cache_impl_test.go @@ -69,7 +69,7 @@ func testRedis(usePerSecondRedis bool) func(*testing.T) { clientUsed.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key_value_1234", uint64(1)).SetArg(1, uint64(5)).DoAndReturn(pipeAppend) clientUsed.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key_value_1234", int64(1)).DoAndReturn(pipeAppend) - clientUsed.EXPECT().PipeDo(gomock.Any()).Return(nil) + clientUsed.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} @@ -87,7 +87,7 @@ func testRedis(usePerSecondRedis bool) func(*testing.T) { clientUsed.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key2_value2_subkey2_subvalue2_1200", uint64(1)).SetArg(1, uint64(11)).DoAndReturn(pipeAppend) clientUsed.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key2_value2_subkey2_subvalue2_1200", int64(60)).DoAndReturn(pipeAppend) - clientUsed.EXPECT().PipeDo(gomock.Any()).Return(nil) + clientUsed.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequestWithPerDescriptorHitsAddend( "domain", @@ -118,7 +118,7 @@ func testRedis(usePerSecondRedis bool) func(*testing.T) { clientUsed.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key3_value3_subkey3_subvalue3_950400", uint64(1)).SetArg(1, uint64(13)).DoAndReturn(pipeAppend) clientUsed.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key3_value3_subkey3_subvalue3_950400", int64(86400)).DoAndReturn(pipeAppend) - clientUsed.EXPECT().PipeDo(gomock.Any()).Return(nil) + clientUsed.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequestWithPerDescriptorHitsAddend( "domain", @@ -206,7 +206,7 @@ func TestOverLimitWithLocalCache(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(11)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) @@ -233,7 +233,7 @@ func TestOverLimitWithLocalCache(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(13)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ @@ -254,7 +254,7 @@ func TestOverLimitWithLocalCache(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(16)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ @@ -306,7 +306,7 @@ func TestNearLimit(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(11)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) @@ -329,7 +329,7 @@ func TestNearLimit(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(13)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ @@ -347,7 +347,7 @@ func TestNearLimit(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(16)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ @@ -364,7 +364,7 @@ func TestNearLimit(t *testing.T) { timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key5_value5_1234", uint64(3)).SetArg(1, uint64(5)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key5_value5_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key5", "value5"}}}, 3) limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key5_value5"), false, false, false, "", nil, false)} @@ -381,7 +381,7 @@ func TestNearLimit(t *testing.T) { timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key6_value6_1234", uint64(2)).SetArg(1, uint64(7)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key6_value6_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key6", "value6"}}}, 2) limits = []*config.RateLimit{config.NewRateLimit(8, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key6_value6"), false, false, false, "", nil, false)} @@ -398,7 +398,7 @@ func TestNearLimit(t *testing.T) { timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key7_value7_1234", uint64(3)).SetArg(1, uint64(19)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key7_value7_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key7", "value7"}}}, 3) limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key7_value7"), false, false, false, "", nil, false)} @@ -415,7 +415,7 @@ func TestNearLimit(t *testing.T) { timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key8_value8_1234", uint64(3)).SetArg(1, uint64(22)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key8_value8_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key8", "value8"}}}, 3) limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key8_value8"), false, false, false, "", nil, false)} @@ -432,7 +432,7 @@ func TestNearLimit(t *testing.T) { timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key9_value9_1234", uint64(7)).SetArg(1, uint64(22)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key9_value9_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key9", "value9"}}}, 7) limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key9_value9"), false, false, false, "", nil, false)} @@ -449,7 +449,7 @@ func TestNearLimit(t *testing.T) { timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key10_value10_1234", uint64(3)).SetArg(1, uint64(30)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key10_value10_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key10", "value10"}}}, 3) limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key10_value10"), false, false, false, "", nil, false)} @@ -479,7 +479,7 @@ func TestRedisWithJitter(t *testing.T) { jitterSource.EXPECT().Int63().Return(int64(100)) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key_value_1234", uint64(1)).SetArg(1, uint64(5)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key_value_1234", int64(101)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} @@ -514,7 +514,7 @@ func TestOverLimitWithLocalCacheShadowRule(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(11)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) @@ -541,7 +541,7 @@ func TestOverLimitWithLocalCacheShadowRule(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(13)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ @@ -562,7 +562,7 @@ func TestOverLimitWithLocalCacheShadowRule(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key4_value4_997200", uint64(1)).SetArg(1, uint64(16)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key4_value4_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) // The result should be OK since limit is in ShadowMode assert.Equal( @@ -624,7 +624,7 @@ func TestRedisTracer(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key_value_1234", uint64(1)).SetArg(1, uint64(5)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key_value_1234", int64(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} @@ -664,7 +664,7 @@ func TestOverLimitWithStopCacheKeyIncrementWhenOverlimitConfig(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key5_value5_997200", uint64(1)).SetArg(1, uint64(11)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key5_value5_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil).Times(2) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil).Times(2) request := common.NewRateLimitRequestWithPerDescriptorHitsAddend("domain", [][][2]string{{{"key4", "value4"}}, {{"key5", "value5"}}}, []uint64{1, 1}) @@ -703,7 +703,7 @@ func TestOverLimitWithStopCacheKeyIncrementWhenOverlimitConfig(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key5_value5_997200", uint64(2)).SetArg(1, uint64(13)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key5_value5_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil).Times(2) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil).Times(2) request = common.NewRateLimitRequestWithPerDescriptorHitsAddend("domain", [][][2]string{{{"key4", "value4"}}, {{"key5", "value5"}}}, []uint64{2, 2}) @@ -737,7 +737,7 @@ func TestOverLimitWithStopCacheKeyIncrementWhenOverlimitConfig(t *testing.T) { client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "INCRBY", "domain_key5_value5_997200", uint64(2)).SetArg(1, uint64(15)).DoAndReturn(pipeAppend) client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "EXPIRE", "domain_key5_value5_997200", int64(3600)).DoAndReturn(pipeAppend) - client.EXPECT().PipeDo(gomock.Any()).Return(nil).Times(2) + client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil).Times(2) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ From e4779329b06c2823e3e8e39e536868c1979c192d Mon Sep 17 00:00:00 2001 From: bloomenergyguy Date: Fri, 5 Jun 2026 10:46:56 -0700 Subject: [PATCH 14/29] Update to golang-1.26.4 and update golang.org/x/net to 0.55.0 (#1154) Signed-off-by: Fred Dafunk Co-authored-by: collin-lee Signed-off-by: Yusof Ganji --- .github/workflows/main.yaml | 2 +- .github/workflows/pullrequest.yaml | 2 +- Dockerfile | 2 +- Dockerfile.integration | 2 +- examples/xds-sotw-config-server/Dockerfile | 2 +- examples/xds-sotw-config-server/go.mod | 2 +- go.mod | 8 ++++---- go.sum | 12 ++++++------ 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a1912fac..0a421096 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -49,7 +49,7 @@ jobs: - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: run pre-commits run: | diff --git a/.github/workflows/pullrequest.yaml b/.github/workflows/pullrequest.yaml index 6159fb25..df7b27e5 100644 --- a/.github/workflows/pullrequest.yaml +++ b/.github/workflows/pullrequest.yaml @@ -37,7 +37,7 @@ jobs: - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.26.3" + go-version: "1.26.4" - name: run pre-commits run: | diff --git a/Dockerfile b/Dockerfile index 92d1914c..48b06dfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.3@sha256:2d6c80227255c3112a4d08e67ba98e58efd3846daf15d9d7d4c389565d881b1a AS build +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS build WORKDIR /ratelimit ENV GOPROXY=https://proxy.golang.org diff --git a/Dockerfile.integration b/Dockerfile.integration index c06d125c..af75cd9c 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -1,5 +1,5 @@ # Running this docker image runs the integration tests. -FROM golang:1.26.3@sha256:2d6c80227255c3112a4d08e67ba98e58efd3846daf15d9d7d4c389565d881b1a +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 RUN apt-get update -y && apt-get install sudo stunnel4 redis memcached -y && rm -rf /var/lib/apt/lists/* diff --git a/examples/xds-sotw-config-server/Dockerfile b/examples/xds-sotw-config-server/Dockerfile index 589d0a17..ce473bcf 100644 --- a/examples/xds-sotw-config-server/Dockerfile +++ b/examples/xds-sotw-config-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.3@sha256:2d6c80227255c3112a4d08e67ba98e58efd3846daf15d9d7d4c389565d881b1a AS build +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS build WORKDIR /xds-server COPY . . diff --git a/examples/xds-sotw-config-server/go.mod b/examples/xds-sotw-config-server/go.mod index 323a0faa..1eeba064 100644 --- a/examples/xds-sotw-config-server/go.mod +++ b/examples/xds-sotw-config-server/go.mod @@ -1,6 +1,6 @@ module github.com/envoyproxy/ratelimit/examples/xds-sotw-config-server -go 1.26.3 +go 1.26.4 require ( github.com/envoyproxy/go-control-plane v0.12.1-0.20240123181358-841e293a220b diff --git a/go.mod b/go.mod index 1a8edfb4..15a7760b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/envoyproxy/ratelimit -go 1.26.3 +go 1.26.4 require ( github.com/DataDog/datadog-go/v5 v5.5.0 @@ -35,7 +35,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 - golang.org/x/net v0.52.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 @@ -67,8 +67,8 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d5c46a40..ee8c9b6d 100644 --- a/go.sum +++ b/go.sum @@ -201,8 +201,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -226,13 +226,13 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= From 220e676b6a6b6a4ffafe0c1e031a6f8e12328fee Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 16:24:54 -0700 Subject: [PATCH 15/29] Add design spec for RequestHeadersToAdd feature Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- ...026-06-04-request-headers-to-add-design.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md diff --git a/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md b/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md new file mode 100644 index 00000000..57e49a5a --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md @@ -0,0 +1,90 @@ +# Design: `RequestHeadersToAdd` Support + +**Date:** 2026-06-04 +**Repo:** github.com/envoyproxy/ratelimit +**Scope:** Open source contribution + +## Problem + +The ratelimit service currently supports injecting rate limit headers into the **downstream response** via `ResponseHeadersToAdd` (enabled with `LIMIT_RESPONSE_HEADERS_ENABLED`). These headers go back to the caller, not to the upstream service. + +The Envoy `RateLimitResponse` proto also has a `RequestHeadersToAdd` field — headers Envoy injects into the **forwarded request** before sending it upstream. The ratelimit service never populates this field today. + +Upstream services (e.g. API gateways, sidecars) that sit behind Envoy have no per-request signal from the rate limiter. They cannot make routing decisions based on quota state without this. + +## Goal + +Add a `RequestHeadersToAdd` feature that mirrors `ResponseHeadersToAdd` exactly: same 3 standard headers (`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`), same `minimumDescriptor` logic, independently enabled via env vars. + +## Non-Goals + +- Per-domain or per-descriptor scoping (would require XDS proto changes; deferred) +- Arbitrary custom header injection (out of scope for this change) +- Filtering headers to OVER_LIMIT-only responses (operators can inspect `RateLimit-Remaining: 0`) + +## Design + +### Settings + +Four new env vars added to `src/settings/settings.go`, parallel to the existing response header settings: + +| Env var | Default | Purpose | +|---|---|---| +| `LIMIT_REQUEST_HEADERS_ENABLED` | `false` | Master on/off switch | +| `LIMIT_REQUEST_LIMIT_HEADER` | `RateLimit-Limit` | Header name for the limit value | +| `LIMIT_REQUEST_REMAINING_HEADER` | `RateLimit-Remaining` | Header name for remaining count | +| `LIMIT_REQUEST_RESET_HEADER` | `RateLimit-Reset` | Header name for reset seconds | + +Default names match the response header defaults. Operators can set different names to distinguish upstream vs downstream headers (e.g. `x-grls-ratelimit-remaining` upstream vs `RateLimit-Remaining` downstream). + +The two features (`LIMIT_RESPONSE_HEADERS_ENABLED` and `LIMIT_REQUEST_HEADERS_ENABLED`) are independently controlled — operators can enable one, both, or neither. + +### Service struct (`src/service/ratelimit.go`) + +Four new fields added to the `service` struct, parallel to `customHeaders*`: + +```go +requestHeadersEnabled bool +requestHeaderLimitHeader string +requestHeaderRemainingHeader string +requestHeaderResetHeader string +``` + +Wired in `SetConfig()` alongside the existing response header fields (currently lines 94–101). + +### Response construction (`shouldRateLimitWorker`) + +After the existing `ResponseHeadersToAdd` block (currently lines 258–264), add: + +```go +if this.requestHeadersEnabled && minimumDescriptor != nil { + response.RequestHeadersToAdd = []*core.HeaderValue{ + {Key: this.requestHeaderLimitHeader, Value: }, + {Key: this.requestHeaderRemainingHeader, Value: }, + {Key: this.requestHeaderResetHeader, Value: }, + } +} +``` + +The `minimumDescriptor` (the descriptor closest to its limit) is reused as-is. Headers are always populated when enabled, regardless of whether the overall response is `OK` or `OVER_LIMIT` — mirroring response header behavior exactly. + +### Tests (`test/service/ratelimit_test.go`) + +Parallel test cases for `RequestHeadersToAdd` alongside the existing response header tests: + +1. **Headers present when enabled + within limit** — `RequestHeadersToAdd` populated correctly +2. **Headers present when enabled + over limit** — `RequestHeadersToAdd` populated correctly +3. **Headers absent when disabled** — `RequestHeadersToAdd` is nil +4. **Both enabled simultaneously with different header names** — `RequestHeadersToAdd` and `ResponseHeadersToAdd` independently populated with their respective configured names; no interference + +### README + +Add a `Global Rate Limit Request Headers` section immediately after the existing `Global Rate Limit Response Headers` section, documenting the 4 env vars and the use case: upstream services behind Envoy can read these headers to make per-request routing decisions (e.g. skip hot path on quota exhaustion) without the rate limit service blocking the request. + +## Backwards Compatibility + +Fully additive. `LIMIT_REQUEST_HEADERS_ENABLED` defaults to `false`. No YAML schema changes. No XDS proto changes. Existing deployments see no behavior change. + +## Future Work + +Per-domain scoping would require adding a field to `RateLimitConfig` in the XDS proto (`api/ratelimit/config/ratelimit/v3/rls_conf.proto`) and a coordinated change to `go-control-plane`. This is a natural follow-on once the base feature is established. From dea6a7c589c0fe48d8cf62708a70b6716aab38d5 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 16:34:53 -0700 Subject: [PATCH 16/29] Add implementation plan for RequestHeadersToAdd feature Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- .../2026-06-04-request-headers-to-add.md | 564 ++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-request-headers-to-add.md diff --git a/docs/superpowers/plans/2026-06-04-request-headers-to-add.md b/docs/superpowers/plans/2026-06-04-request-headers-to-add.md new file mode 100644 index 00000000..6f7e2b24 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-request-headers-to-add.md @@ -0,0 +1,564 @@ +# RequestHeadersToAdd Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `RequestHeadersToAdd` support so the ratelimit service can inject RateLimit-* headers into the forwarded request (upstream direction), mirroring the existing `ResponseHeadersToAdd` feature. + +**Architecture:** Four new env vars (`LIMIT_REQUEST_HEADERS_ENABLED`, `LIMIT_REQUEST_LIMIT_HEADER`, `LIMIT_REQUEST_REMAINING_HEADER`, `LIMIT_REQUEST_RESET_HEADER`) control the feature. When enabled, `shouldRateLimitWorker` populates `response.RequestHeadersToAdd` using the same `minimumDescriptor` logic already used for `ResponseHeadersToAdd`. The two features are independently controlled. + +**Tech Stack:** Go, `github.com/envoyproxy/go-control-plane` (proto types for `RateLimitResponse` and `HeaderValue`), `github.com/kelseyhightower/envconfig` (settings), `github.com/golang/mock` (test mocks), `github.com/stretchr/testify` (assertions). + +--- + +## File Map + +| File | Change | +|---|---| +| `src/settings/settings.go` | Add 4 new env var fields | +| `src/service/ratelimit.go` | Add 4 struct fields, wire in `SetConfig`, populate `RequestHeadersToAdd` in `shouldRateLimitWorker` | +| `test/service/ratelimit_test.go` | Add 4 new test functions | +| `README.md` | Add `Global Rate Limit Request Headers` section after existing `Custom headers` section | + +--- + +## Task 1: Add settings fields + +**Files:** +- Modify: `src/settings/settings.go:114-121` + +- [ ] **Step 1: Write a failing test that reads the new settings fields** + +In `test/service/ratelimit_test.go`, before `TestServiceWithCustomRatelimitHeaders`, add: + +```go +func TestRequestHeadersSettingsDefaults(test *testing.T) { + s := settings.NewSettings() + assert.False(test, s.RateLimitRequestHeadersEnabled) + assert.Equal(test, "RateLimit-Limit", s.HeaderRequestRatelimitLimit) + assert.Equal(test, "RateLimit-Remaining", s.HeaderRequestRatelimitRemaining) + assert.Equal(test, "RateLimit-Reset", s.HeaderRequestRatelimitReset) +} +``` + +Add `"github.com/envoyproxy/ratelimit/src/settings"` to the import block if not already present. Check with: + +```bash +head -30 /Users/yganji/workplace/ratelimit-fork/test/service/ratelimit_test.go +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestRequestHeadersSettingsDefaults -v +``` + +Expected: compile error — `s.RateLimitRequestHeadersEnabled` undefined. + +- [ ] **Step 3: Add the four new fields to `src/settings/settings.go`** + +After line 121 (`HeaderRatelimitReset string ...`), insert: + +```go +// Settings for optional returning of custom request headers +RateLimitRequestHeadersEnabled bool `envconfig:"LIMIT_REQUEST_HEADERS_ENABLED" default:"false"` +// value: the current limit +HeaderRequestRatelimitLimit string `envconfig:"LIMIT_REQUEST_LIMIT_HEADER" default:"RateLimit-Limit"` +// value: remaining count +HeaderRequestRatelimitRemaining string `envconfig:"LIMIT_REQUEST_REMAINING_HEADER" default:"RateLimit-Remaining"` +// value: remaining seconds +HeaderRequestRatelimitReset string `envconfig:"LIMIT_REQUEST_RESET_HEADER" default:"RateLimit-Reset"` +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestRequestHeadersSettingsDefaults -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && git add src/settings/settings.go test/service/ratelimit_test.go +git commit -m "feat: add RequestHeaders settings fields" +``` + +--- + +## Task 2: Wire settings into service struct + +**Files:** +- Modify: `src/service/ratelimit.go:42-57` (struct), `src/service/ratelimit.go:89-103` (SetConfig) + +- [ ] **Step 1: Write a failing test — request headers disabled by default** + +In `test/service/ratelimit_test.go`, add after `TestRequestHeadersSettingsDefaults`: + +```go +func TestRequestHeadersDisabledByDefault(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + t.assert.Nil(err) + t.assert.Nil(response.RequestHeadersToAdd) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestRequestHeadersDisabledByDefault -v +``` + +Expected: compile error — `response.RequestHeadersToAdd` doesn't exist yet on the struct... actually this test will pass immediately since `RequestHeadersToAdd` already exists on the proto. Confirm by running — if it passes, proceed to step 3. + +- [ ] **Step 3: Add four fields to the `service` struct** + +In `src/service/ratelimit.go`, after line 53 (`customHeaderClock utils.TimeSource`), add: + +```go +requestHeadersEnabled bool +requestHeaderLimitHeader string +requestHeaderRemainingHeader string +requestHeaderResetHeader string +``` + +- [ ] **Step 4: Wire the fields in `SetConfig`** + +In `src/service/ratelimit.go`, after the closing brace of the `if rlSettings.RateLimitResponseHeadersEnabled` block (after line 102), add: + +```go +if rlSettings.RateLimitRequestHeadersEnabled { + this.requestHeadersEnabled = true + this.requestHeaderLimitHeader = rlSettings.HeaderRequestRatelimitLimit + this.requestHeaderRemainingHeader = rlSettings.HeaderRequestRatelimitRemaining + this.requestHeaderResetHeader = rlSettings.HeaderRequestRatelimitReset +} +``` + +- [ ] **Step 5: Run tests to verify nothing is broken** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run "TestRequestHeadersDisabledByDefault|TestRequestHeadersSettingsDefaults" -v +``` + +Expected: both PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && git add src/service/ratelimit.go test/service/ratelimit_test.go +git commit -m "feat: wire request headers settings into service struct" +``` + +--- + +## Task 3: Populate RequestHeadersToAdd in shouldRateLimitWorker + +**Files:** +- Modify: `src/service/ratelimit.go:258-264` + +- [ ] **Step 1: Write failing test — default header names, over limit** + +In `test/service/ratelimit_test.go`, add: + +```go +func TestServiceWithDefaultRequestHeaders(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + nil, + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "RateLimit-Limit", Value: "10"}, + {Key: "RateLimit-Remaining", Value: "0"}, + {Key: "RateLimit-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} +``` + +Note: `Value: "58"` is the reset seconds computed from `MockClock{now: 2222}` with a MINUTE unit — same value used in the existing response header tests. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestServiceWithDefaultRequestHeaders -v +``` + +Expected: FAIL — `RequestHeadersToAdd` is nil. + +- [ ] **Step 3: Populate RequestHeadersToAdd in shouldRateLimitWorker** + +In `src/service/ratelimit.go`, after the `ResponseHeadersToAdd` block (after line 264, the closing `}`), add: + +```go +// Add request headers if requested +if this.requestHeadersEnabled && minimumDescriptor != nil { + response.RequestHeadersToAdd = []*core.HeaderValue{ + {Key: this.requestHeaderLimitHeader, Value: strconv.FormatUint(uint64(minimumDescriptor.CurrentLimit.RequestsPerUnit), 10)}, + {Key: this.requestHeaderRemainingHeader, Value: strconv.FormatUint(uint64(minimumDescriptor.LimitRemaining), 10)}, + {Key: this.requestHeaderResetHeader, Value: strconv.FormatInt(utils.CalculateReset(&minimumDescriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10)}, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestServiceWithDefaultRequestHeaders -v +``` + +Expected: PASS. + +- [ ] **Step 5: Run all existing tests to verify no regressions** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -v 2>&1 | tail -20 +``` + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && git add src/service/ratelimit.go test/service/ratelimit_test.go +git commit -m "feat: populate RequestHeadersToAdd in shouldRateLimitWorker" +``` + +--- + +## Task 4: Add remaining test cases + +**Files:** +- Modify: `test/service/ratelimit_test.go` + +- [ ] **Step 1: Add test — custom header names** + +In `test/service/ratelimit_test.go`, add after `TestServiceWithDefaultRequestHeaders`: + +```go +func TestServiceWithCustomRequestHeaders(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + os.Setenv("LIMIT_REQUEST_LIMIT_HEADER", "X-RateLimit-Limit") + os.Setenv("LIMIT_REQUEST_REMAINING_HEADER", "X-RateLimit-Remaining") + os.Setenv("LIMIT_REQUEST_RESET_HEADER", "X-RateLimit-Reset") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + os.Unsetenv("LIMIT_REQUEST_LIMIT_HEADER") + os.Unsetenv("LIMIT_REQUEST_REMAINING_HEADER") + os.Unsetenv("LIMIT_REQUEST_RESET_HEADER") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + nil, + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "X-RateLimit-Limit", Value: "10"}, + {Key: "X-RateLimit-Remaining", Value: "0"}, + {Key: "X-RateLimit-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} +``` + +- [ ] **Step 2: Add test — headers present when within limit (not over limit)** + +```go +func TestServiceWithRequestHeadersWithinLimit(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 8}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 8}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "RateLimit-Limit", Value: "10"}, + {Key: "RateLimit-Remaining", Value: "8"}, + {Key: "RateLimit-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} +``` + +- [ ] **Step 3: Add test — both request and response headers enabled simultaneously with different names** + +```go +func TestServiceWithBothRequestAndResponseHeaders(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + os.Setenv("LIMIT_REQUEST_LIMIT_HEADER", "X-Upstream-Limit") + os.Setenv("LIMIT_REQUEST_REMAINING_HEADER", "X-Upstream-Remaining") + os.Setenv("LIMIT_REQUEST_RESET_HEADER", "X-Upstream-Reset") + os.Setenv("LIMIT_RESPONSE_HEADERS_ENABLED", "true") + os.Setenv("LIMIT_LIMIT_HEADER", "X-Downstream-Limit") + os.Setenv("LIMIT_REMAINING_HEADER", "X-Downstream-Remaining") + os.Setenv("LIMIT_RESET_HEADER", "X-Downstream-Reset") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + os.Unsetenv("LIMIT_REQUEST_LIMIT_HEADER") + os.Unsetenv("LIMIT_REQUEST_REMAINING_HEADER") + os.Unsetenv("LIMIT_REQUEST_RESET_HEADER") + os.Unsetenv("LIMIT_RESPONSE_HEADERS_ENABLED") + os.Unsetenv("LIMIT_LIMIT_HEADER") + os.Unsetenv("LIMIT_REMAINING_HEADER") + os.Unsetenv("LIMIT_RESET_HEADER") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "X-Upstream-Limit", Value: "10"}, + {Key: "X-Upstream-Remaining", Value: "0"}, + {Key: "X-Upstream-Reset", Value: "58"}, + }, + ResponseHeadersToAdd: []*core.HeaderValue{ + {Key: "X-Downstream-Limit", Value: "10"}, + {Key: "X-Downstream-Remaining", Value: "0"}, + {Key: "X-Downstream-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} +``` + +- [ ] **Step 4: Run all four new tests** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run "TestServiceWithDefaultRequestHeaders|TestServiceWithCustomRequestHeaders|TestServiceWithRequestHeadersWithinLimit|TestServiceWithBothRequestAndResponseHeaders" -v +``` + +Expected: all 4 PASS. + +- [ ] **Step 5: Run the full test suite** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./... 2>&1 | tail -20 +``` + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && git add test/service/ratelimit_test.go +git commit -m "test: add RequestHeadersToAdd test cases" +``` + +--- + +## Task 5: Update README + +**Files:** +- Modify: `README.md:1406-1416` + +- [ ] **Step 1: Insert the new section after the existing `Custom headers` section** + +In `README.md`, after line 1415 (`1. \`LIMIT_RESET_HEADER\` ...`) and before line 1417 (`# Tracing`), insert: + +```markdown + +The following environment variables control the custom request header feature (headers injected into the forwarded request sent to upstream services by Envoy): + +1. `LIMIT_REQUEST_HEADERS_ENABLED` - Enables the custom request headers. When enabled, Envoy injects these headers into the forwarded request, allowing upstream services to inspect rate limit state and make per-request routing decisions (e.g. skip a hot path when quota is exhausted) without the request being blocked. +1. `LIMIT_REQUEST_LIMIT_HEADER` - The default value is "RateLimit-Limit", setting the environment variable will specify an alternative header name +1. `LIMIT_REQUEST_REMAINING_HEADER` - The default value is "RateLimit-Remaining", setting the environment variable will specify an alternative header name +1. `LIMIT_REQUEST_RESET_HEADER` - The default value is "RateLimit-Reset", setting the environment variable will specify an alternative header name +``` + +- [ ] **Step 2: Verify the section reads correctly** + +```bash +grep -A 10 "LIMIT_REQUEST_HEADERS_ENABLED" /Users/yganji/workplace/ratelimit-fork/README.md +``` + +Expected: the 4 env vars appear with their descriptions. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && git add README.md +git commit -m "docs: add RequestHeadersToAdd documentation to README" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: Run the full test suite one more time** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go test ./... 2>&1 | grep -E "FAIL|ok" +``` + +Expected: all lines show `ok`, no `FAIL`. + +- [ ] **Step 2: Check the build** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && go build ./... +``` + +Expected: exits with code 0, no output. + +- [ ] **Step 3: Review the diff** + +```bash +cd /Users/yganji/workplace/ratelimit-fork && git diff origin/main..HEAD --stat +``` + +Confirm only these files changed: `src/settings/settings.go`, `src/service/ratelimit.go`, `test/service/ratelimit_test.go`, `README.md`, `docs/`. From c12e806e340045a760bd694602786741b3005a4f Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 16:48:50 -0700 Subject: [PATCH 17/29] feat: add RequestHeaders settings fields Adds RateLimitRequestHeadersEnabled and three HeaderRequestRatelimit* fields to Settings, mirroring the existing response-header block, so the service can later inject rate-limit headers into upstream requests. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- src/settings/settings.go | 9 +++++++++ test/service/ratelimit_test.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/settings/settings.go b/src/settings/settings.go index 2129cf80..4fc3b7a5 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -120,6 +120,15 @@ type Settings struct { // value: remaining seconds HeaderRatelimitReset string `envconfig:"LIMIT_RESET_HEADER" default:"RateLimit-Reset"` + // Settings for optional returning of custom request headers + RateLimitRequestHeadersEnabled bool `envconfig:"LIMIT_REQUEST_HEADERS_ENABLED" default:"false"` + // value: the current limit + HeaderRequestRatelimitLimit string `envconfig:"LIMIT_REQUEST_LIMIT_HEADER" default:"RateLimit-Limit"` + // value: remaining count + HeaderRequestRatelimitRemaining string `envconfig:"LIMIT_REQUEST_REMAINING_HEADER" default:"RateLimit-Remaining"` + // value: remaining seconds + HeaderRequestRatelimitReset string `envconfig:"LIMIT_REQUEST_RESET_HEADER" default:"RateLimit-Reset"` + // Health-check settings HealthyWithAtLeastOneConfigLoaded bool `envconfig:"HEALTHY_WITH_AT_LEAST_ONE_CONFIG_LOADED" default:"false"` diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index dc4ab8a7..14f3ef3c 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -30,6 +30,7 @@ import ( "github.com/envoyproxy/ratelimit/src/redis" server "github.com/envoyproxy/ratelimit/src/server" ratelimit "github.com/envoyproxy/ratelimit/src/service" + "github.com/envoyproxy/ratelimit/src/settings" "github.com/envoyproxy/ratelimit/test/common" mock_config "github.com/envoyproxy/ratelimit/test/mocks/config" mock_limiter "github.com/envoyproxy/ratelimit/test/mocks/limiter" @@ -348,6 +349,14 @@ func TestMixedRuleShadowMode(test *testing.T) { t.assert.EqualValues(0, t.statStore.NewCounter("global_shadow_mode").Value()) } +func TestRequestHeadersSettingsDefaults(test *testing.T) { + s := settings.NewSettings() + assert.False(test, s.RateLimitRequestHeadersEnabled) + assert.Equal(test, "RateLimit-Limit", s.HeaderRequestRatelimitLimit) + assert.Equal(test, "RateLimit-Remaining", s.HeaderRequestRatelimitRemaining) + assert.Equal(test, "RateLimit-Reset", s.HeaderRequestRatelimitReset) +} + func TestServiceWithCustomRatelimitHeaders(test *testing.T) { os.Setenv("LIMIT_RESPONSE_HEADERS_ENABLED", "true") os.Setenv("LIMIT_LIMIT_HEADER", "A-Ratelimit-Limit") From 595c149539edcd1cc2de5313c3c43f99e7795933 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 16:52:11 -0700 Subject: [PATCH 18/29] chore: clarify request header settings comments Signed-off-by: Yusof Ganji --- src/settings/settings.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/settings/settings.go b/src/settings/settings.go index 4fc3b7a5..ed28fd2a 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -120,13 +120,13 @@ type Settings struct { // value: remaining seconds HeaderRatelimitReset string `envconfig:"LIMIT_RESET_HEADER" default:"RateLimit-Reset"` - // Settings for optional returning of custom request headers + // Settings for optional injection of rate limit headers into the upstream request (request_headers_to_add) RateLimitRequestHeadersEnabled bool `envconfig:"LIMIT_REQUEST_HEADERS_ENABLED" default:"false"` - // value: the current limit + // header name for the current limit value injected into the upstream request HeaderRequestRatelimitLimit string `envconfig:"LIMIT_REQUEST_LIMIT_HEADER" default:"RateLimit-Limit"` - // value: remaining count + // header name for the remaining count injected into the upstream request HeaderRequestRatelimitRemaining string `envconfig:"LIMIT_REQUEST_REMAINING_HEADER" default:"RateLimit-Remaining"` - // value: remaining seconds + // header name for the reset seconds injected into the upstream request HeaderRequestRatelimitReset string `envconfig:"LIMIT_REQUEST_RESET_HEADER" default:"RateLimit-Reset"` // Health-check settings From 4e822d6c87bfd68d6d5bc40a21a540706f624cae Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 16:55:32 -0700 Subject: [PATCH 19/29] feat: wire request headers settings into service struct Add four requestHeaders* fields to the service struct and populate them from settings in SetConfig, mirroring the existing customHeaders pattern for response headers. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- src/service/ratelimit.go | 11 +++++++++++ test/service/ratelimit_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index 9bd9241d..f80589b6 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -51,6 +51,10 @@ type service struct { customHeaderRemainingHeader string customHeaderResetHeader string customHeaderClock utils.TimeSource + requestHeadersEnabled bool + requestHeaderLimitHeader string + requestHeaderRemainingHeader string + requestHeaderResetHeader string globalShadowMode bool globalQuotaMode bool responseDynamicMetadataEnabled bool @@ -100,6 +104,13 @@ func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWi this.customHeaderResetHeader = rlSettings.HeaderRatelimitReset } + + if rlSettings.RateLimitRequestHeadersEnabled { + this.requestHeadersEnabled = true + this.requestHeaderLimitHeader = rlSettings.HeaderRequestRatelimitLimit + this.requestHeaderRemainingHeader = rlSettings.HeaderRequestRatelimitRemaining + this.requestHeaderResetHeader = rlSettings.HeaderRequestRatelimitReset + } this.configLock.Unlock() logger.Info("Successfully loaded new configuration") } diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index 14f3ef3c..0eb442c4 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -357,6 +357,35 @@ func TestRequestHeadersSettingsDefaults(test *testing.T) { assert.Equal(test, "RateLimit-Reset", s.HeaderRequestRatelimitReset) } +func TestRequestHeadersDisabledByDefault(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + t.assert.Nil(err) + t.assert.Nil(response.RequestHeadersToAdd) +} + func TestServiceWithCustomRatelimitHeaders(test *testing.T) { os.Setenv("LIMIT_RESPONSE_HEADERS_ENABLED", "true") os.Setenv("LIMIT_LIMIT_HEADER", "A-Ratelimit-Limit") From 45d576e9fc5f745b4b8a84c8e9e7144a6134255f Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 20:31:29 -0700 Subject: [PATCH 20/29] fix: reset requestHeadersEnabled to false on config reload when disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assign rlSettings.RateLimitRequestHeadersEnabled unconditionally so that a hot-reload with LIMIT_REQUEST_HEADERS_ENABLED unset/false properly clears the flag. Previously the if-only guard meant the field could only ever transition from false → true, making the disable path a no-op. Also adds a white-box test (src/service/ratelimit_test.go) that directly exercises the SetConfig toggle path and a black-box test stub in test/service/ratelimit_test.go that was superseded by the white-box test. Signed-off-by: Yusof Ganji --- src/service/ratelimit.go | 2 +- src/service/ratelimit_test.go | 58 ++++++++++++++++++++++++++++++++++ test/service/ratelimit_test.go | 38 ++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index f80589b6..104aa7c9 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -105,8 +105,8 @@ func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWi this.customHeaderResetHeader = rlSettings.HeaderRatelimitReset } + this.requestHeadersEnabled = rlSettings.RateLimitRequestHeadersEnabled if rlSettings.RateLimitRequestHeadersEnabled { - this.requestHeadersEnabled = true this.requestHeaderLimitHeader = rlSettings.HeaderRequestRatelimitLimit this.requestHeaderRemainingHeader = rlSettings.HeaderRequestRatelimitRemaining this.requestHeaderResetHeader = rlSettings.HeaderRequestRatelimitReset diff --git a/src/service/ratelimit_test.go b/src/service/ratelimit_test.go index bb0234d7..ab5ece87 100644 --- a/src/service/ratelimit_test.go +++ b/src/service/ratelimit_test.go @@ -1,17 +1,24 @@ package ratelimit import ( + "context" + "os" + "sync" "testing" ratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" "github.com/google/go-cmp/cmp" + gostats "github.com/lyft/gostats" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" "github.com/envoyproxy/ratelimit/src/config" + mock_stats "github.com/envoyproxy/ratelimit/test/mocks/stats" ) func TestRatelimitToMetadata(t *testing.T) { @@ -221,3 +228,54 @@ func TestRatelimitToMetadata(t *testing.T) { }) } } + +// stubConfigEvent is a minimal ConfigUpdateEvent that always returns a no-op config +// with no error, allowing SetConfig to proceed to the settings-reading section. +type stubConfigEvent struct{} + +func (s stubConfigEvent) GetConfig() (config.RateLimitConfig, any) { + return &stubRLConfig{}, nil +} + +// stubRLConfig satisfies config.RateLimitConfig with no-op methods. +type stubRLConfig struct{} + +func (s *stubRLConfig) Dump() string { return "" } +func (s *stubRLConfig) GetLimit(_ context.Context, _ string, _ *pb_struct.RateLimitDescriptor) *config.RateLimit { + return nil +} +func (s *stubRLConfig) IsEmptyDomains() bool { return false } + +func newMinimalService(t *testing.T) *service { + t.Helper() + store := gostats.NewStore(gostats.NewNullSink(), false) + mgr := mock_stats.NewMockStatManager(store) + return &service{ + configLock: sync.RWMutex{}, + stats: mgr.NewServiceStats(), + } +} + +// TestRequestHeadersEnabledResetToFalseOnHotReload verifies that requestHeadersEnabled +// is unconditionally assigned on every SetConfig call, not only set to true. +// Without the fix, the flag is only ever set to true and never cleared, so a +// hot-reload that turns off LIMIT_REQUEST_HEADERS_ENABLED has no effect. +func TestRequestHeadersEnabledResetToFalseOnHotReload(t *testing.T) { + // Start with the feature enabled. + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + defer os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + + svc := newMinimalService(t) + + // First load: env var ON → flag must become true. + svc.SetConfig(stubConfigEvent{}, false) + assert.True(t, svc.requestHeadersEnabled, "requestHeadersEnabled should be true after initial load with env var on") + + // Operator turns off the env var; next hot-reload must clear the flag. + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + svc.SetConfig(stubConfigEvent{}, false) + + // Bug: without the fix, this assertion fails because the if-only guard never + // resets the field to false. + assert.False(t, svc.requestHeadersEnabled, "requestHeadersEnabled should be false after reload with env var off") +} diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index 0eb442c4..787ee3bd 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -386,6 +386,44 @@ func TestRequestHeadersDisabledByDefault(test *testing.T) { t.assert.Nil(response.RequestHeadersToAdd) } +// TestRequestHeadersDisabledOnHotReload verifies that requestHeadersEnabled is reset to +// false on config hot-reload when LIMIT_REQUEST_HEADERS_ENABLED is turned off. Without +// the fix, the flag is only ever set to true and never cleared, so headers persist after +// the env var is removed. +func TestRequestHeadersDisabledOnHotReload(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() // service starts with requestHeadersEnabled = true + + // Now turn off the env var and trigger a config reload. + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // After the reload with LIMIT_REQUEST_HEADERS_ENABLED unset, request headers must be absent. + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + t.assert.Nil(err) + t.assert.Nil(response.RequestHeadersToAdd) +} + func TestServiceWithCustomRatelimitHeaders(test *testing.T) { os.Setenv("LIMIT_RESPONSE_HEADERS_ENABLED", "true") os.Setenv("LIMIT_LIMIT_HEADER", "A-Ratelimit-Limit") From fd41d21a28dd145152f1f1e7bff2e459c6626cb0 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Fri, 5 Jun 2026 20:34:33 -0700 Subject: [PATCH 21/29] feat: populate RequestHeadersToAdd in shouldRateLimitWorker When LIMIT_REQUEST_HEADERS_ENABLED is true, attach RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset as RequestHeadersToAdd on the response, mirroring the existing ResponseHeadersToAdd logic. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- src/service/ratelimit.go | 11 +++++++- test/service/ratelimit_test.go | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index 104aa7c9..c84e18a4 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -225,7 +225,7 @@ func (this *service) shouldRateLimitWorker( for i, descriptorStatus := range responseDescriptorStatuses { // Keep track of the descriptor closest to hit the ratelimit - if this.customHeadersEnabled && + if (this.customHeadersEnabled || this.requestHeadersEnabled) && descriptorStatus.CurrentLimit != nil && descriptorStatus.LimitRemaining < minLimitRemaining { minimumDescriptor = descriptorStatus @@ -274,6 +274,15 @@ func (this *service) shouldRateLimitWorker( } } + // Add request headers if requested + if this.requestHeadersEnabled && minimumDescriptor != nil { + response.RequestHeadersToAdd = []*core.HeaderValue{ + {Key: this.requestHeaderLimitHeader, Value: strconv.FormatUint(uint64(minimumDescriptor.CurrentLimit.RequestsPerUnit), 10)}, + {Key: this.requestHeaderRemainingHeader, Value: strconv.FormatUint(uint64(minimumDescriptor.LimitRemaining), 10)}, + {Key: this.requestHeaderResetHeader, Value: strconv.FormatInt(utils.CalculateReset(&minimumDescriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10)}, + } + } + // If there is a global shadow_mode, it should always return OK if finalCode == pb.RateLimitResponse_OVER_LIMIT && globalShadowMode { finalCode = pb.RateLimitResponse_OK diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index 787ee3bd..e9d252e1 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -536,6 +536,57 @@ func TestServiceWithDefaultRatelimitHeaders(test *testing.T) { t.assert.Nil(err) } +func TestServiceWithDefaultRequestHeaders(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + nil, + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "RateLimit-Limit", Value: "10"}, + {Key: "RateLimit-Remaining", Value: "0"}, + {Key: "RateLimit-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} + func TestEmptyDomain(test *testing.T) { t := commonSetup(test) defer t.controller.Finish() From dc18f42e0073a3e00d1869db3a82a4e95e732a73 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Sat, 6 Jun 2026 08:34:41 -0700 Subject: [PATCH 22/29] test: add RequestHeadersToAdd test cases Add 3 test functions after TestServiceWithDefaultRequestHeaders covering custom header names, within-limit behaviour, and simultaneous request+response headers with different names. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- test/service/ratelimit_test.go | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index e9d252e1..7cbc76c1 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -587,6 +587,176 @@ func TestServiceWithDefaultRequestHeaders(test *testing.T) { t.assert.Nil(err) } +func TestServiceWithCustomRequestHeaders(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + os.Setenv("LIMIT_REQUEST_LIMIT_HEADER", "X-RateLimit-Limit") + os.Setenv("LIMIT_REQUEST_REMAINING_HEADER", "X-RateLimit-Remaining") + os.Setenv("LIMIT_REQUEST_RESET_HEADER", "X-RateLimit-Reset") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + os.Unsetenv("LIMIT_REQUEST_LIMIT_HEADER") + os.Unsetenv("LIMIT_REQUEST_REMAINING_HEADER") + os.Unsetenv("LIMIT_REQUEST_RESET_HEADER") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + nil, + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "X-RateLimit-Limit", Value: "10"}, + {Key: "X-RateLimit-Remaining", Value: "0"}, + {Key: "X-RateLimit-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} + +func TestServiceWithRequestHeadersWithinLimit(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 8}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 8}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "RateLimit-Limit", Value: "10"}, + {Key: "RateLimit-Remaining", Value: "8"}, + {Key: "RateLimit-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} + +func TestServiceWithBothRequestAndResponseHeaders(test *testing.T) { + os.Setenv("LIMIT_REQUEST_HEADERS_ENABLED", "true") + os.Setenv("LIMIT_REQUEST_LIMIT_HEADER", "X-Upstream-Limit") + os.Setenv("LIMIT_REQUEST_REMAINING_HEADER", "X-Upstream-Remaining") + os.Setenv("LIMIT_REQUEST_RESET_HEADER", "X-Upstream-Reset") + os.Setenv("LIMIT_RESPONSE_HEADERS_ENABLED", "true") + os.Setenv("LIMIT_LIMIT_HEADER", "X-Downstream-Limit") + os.Setenv("LIMIT_REMAINING_HEADER", "X-Downstream-Remaining") + os.Setenv("LIMIT_RESET_HEADER", "X-Downstream-Reset") + defer func() { + os.Unsetenv("LIMIT_REQUEST_HEADERS_ENABLED") + os.Unsetenv("LIMIT_REQUEST_LIMIT_HEADER") + os.Unsetenv("LIMIT_REQUEST_REMAINING_HEADER") + os.Unsetenv("LIMIT_REQUEST_RESET_HEADER") + os.Unsetenv("LIMIT_RESPONSE_HEADERS_ENABLED") + os.Unsetenv("LIMIT_LIMIT_HEADER") + os.Unsetenv("LIMIT_REMAINING_HEADER") + os.Unsetenv("LIMIT_RESET_HEADER") + }() + + t := commonSetup(test) + defer t.controller.Finish() + service := t.setupBasicService() + + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "different-domain", [][][2]string{{{"foo", "bar"}}}, 1) + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }) + + response, err := service.ShouldRateLimit(context.Background(), request) + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + }, + RequestHeadersToAdd: []*core.HeaderValue{ + {Key: "X-Upstream-Limit", Value: "10"}, + {Key: "X-Upstream-Remaining", Value: "0"}, + {Key: "X-Upstream-Reset", Value: "58"}, + }, + ResponseHeadersToAdd: []*core.HeaderValue{ + {Key: "X-Downstream-Limit", Value: "10"}, + {Key: "X-Downstream-Remaining", Value: "0"}, + {Key: "X-Downstream-Reset", Value: "58"}, + }, + }, + response) + t.assert.Nil(err) +} + func TestEmptyDomain(test *testing.T) { t := commonSetup(test) defer t.controller.Finish() From 8e557a5d5e1e5dd7a9ad766a3ae113e87f34d5c1 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Sat, 6 Jun 2026 13:07:33 -0700 Subject: [PATCH 23/29] fix: reset customHeadersEnabled to false on config reload when disabled Assign customHeadersEnabled unconditionally from rlSettings, matching the existing pattern for requestHeadersEnabled. Previously the field was only ever set to true inside an if-block, so a hot-reload with LIMIT_RESPONSE_HEADERS_ENABLED=false had no effect and response headers continued to be added indefinitely. Signed-off-by: Yusof Ganji --- src/service/ratelimit.go | 3 +-- src/service/ratelimit_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index c84e18a4..29f97181 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -95,9 +95,8 @@ func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWi this.globalQuotaMode = rlSettings.GlobalQuotaMode this.responseDynamicMetadataEnabled = rlSettings.ResponseDynamicMetadata + this.customHeadersEnabled = rlSettings.RateLimitResponseHeadersEnabled if rlSettings.RateLimitResponseHeadersEnabled { - this.customHeadersEnabled = true - this.customHeaderLimitHeader = rlSettings.HeaderRatelimitLimit this.customHeaderRemainingHeader = rlSettings.HeaderRatelimitRemaining diff --git a/src/service/ratelimit_test.go b/src/service/ratelimit_test.go index ab5ece87..02250f1a 100644 --- a/src/service/ratelimit_test.go +++ b/src/service/ratelimit_test.go @@ -256,6 +256,30 @@ func newMinimalService(t *testing.T) *service { } } +// TestResponseHeadersEnabledResetToFalseOnHotReload verifies that customHeadersEnabled +// is unconditionally assigned on every SetConfig call, not only set to true. +// Without the fix, the flag is only ever set to true and never cleared, so a +// hot-reload that turns off LIMIT_RESPONSE_HEADERS_ENABLED has no effect. +func TestResponseHeadersEnabledResetToFalseOnHotReload(t *testing.T) { + // Start with the feature enabled. + os.Setenv("LIMIT_RESPONSE_HEADERS_ENABLED", "true") + defer os.Unsetenv("LIMIT_RESPONSE_HEADERS_ENABLED") + + svc := newMinimalService(t) + + // First load: env var ON → flag must become true. + svc.SetConfig(stubConfigEvent{}, false) + assert.True(t, svc.customHeadersEnabled, "customHeadersEnabled should be true after initial load with env var on") + + // Operator turns off the env var; next hot-reload must clear the flag. + os.Unsetenv("LIMIT_RESPONSE_HEADERS_ENABLED") + svc.SetConfig(stubConfigEvent{}, false) + + // Bug: without the fix, this assertion fails because the if-only guard never + // resets the field to false. + assert.False(t, svc.customHeadersEnabled, "customHeadersEnabled should be false after reload with env var off") +} + // TestRequestHeadersEnabledResetToFalseOnHotReload verifies that requestHeadersEnabled // is unconditionally assigned on every SetConfig call, not only set to true. // Without the fix, the flag is only ever set to true and never cleared, so a From 79f8107e2f68dab6a8147e15c0a102812c400d8c Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Sat, 6 Jun 2026 13:08:24 -0700 Subject: [PATCH 24/29] docs: add RequestHeadersToAdd documentation to README Signed-off-by: Yusof Ganji --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index f2e8d6df..d41fe43f 100644 --- a/README.md +++ b/README.md @@ -1414,6 +1414,15 @@ The following environment variables control the custom response feature: 1. `LIMIT_REMAINING_HEADER` - The default value is "RateLimit-Remaining", setting the environment variable will specify an alternative header name 1. `LIMIT_RESET_HEADER` - The default value is "RateLimit-Reset", setting the environment variable will specify an alternative header name +## RequestHeadersToAdd + +The following environment variables control the custom request header feature. When enabled, Envoy injects these headers into the forwarded request before it reaches the upstream service, allowing upstream services to inspect rate limit state and make per-request routing decisions (e.g. skip a hot path when quota is exhausted) without the request being blocked. + +1. `LIMIT_REQUEST_HEADERS_ENABLED` - Enables the custom request headers +1. `LIMIT_REQUEST_LIMIT_HEADER` - The default value is "RateLimit-Limit", setting the environment variable will specify an alternative header name +1. `LIMIT_REQUEST_REMAINING_HEADER` - The default value is "RateLimit-Remaining", setting the environment variable will specify an alternative header name +1. `LIMIT_REQUEST_RESET_HEADER` - The default value is "RateLimit-Reset", setting the environment variable will specify an alternative header name + # Tracing Ratelimit service supports exporting spans in OLTP format. See [OpenTelemetry](https://opentelemetry.io/) for more information. From aa9fe010ecb1e02d255565dbdf9fb8f02b14fdc4 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Mon, 8 Jun 2026 11:04:30 -0700 Subject: [PATCH 25/29] refactor: extract helper methods for RequestHeadersToAdd values Mirrors the existing rateLimitLimitHeader/rateLimitRemainingHeader/rateLimitResetHeader pattern used for ResponseHeadersToAdd. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- src/service/ratelimit.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index 29f97181..dacec818 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -276,9 +276,9 @@ func (this *service) shouldRateLimitWorker( // Add request headers if requested if this.requestHeadersEnabled && minimumDescriptor != nil { response.RequestHeadersToAdd = []*core.HeaderValue{ - {Key: this.requestHeaderLimitHeader, Value: strconv.FormatUint(uint64(minimumDescriptor.CurrentLimit.RequestsPerUnit), 10)}, - {Key: this.requestHeaderRemainingHeader, Value: strconv.FormatUint(uint64(minimumDescriptor.LimitRemaining), 10)}, - {Key: this.requestHeaderResetHeader, Value: strconv.FormatInt(utils.CalculateReset(&minimumDescriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10)}, + this.rateLimitRequestLimitHeader(minimumDescriptor), + this.rateLimitRequestRemainingHeader(minimumDescriptor), + this.rateLimitRequestResetHeader(minimumDescriptor), } } @@ -416,6 +416,27 @@ func (this *service) rateLimitResetHeader( } } +func (this *service) rateLimitRequestLimitHeader(descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { + return &core.HeaderValue{ + Key: this.requestHeaderLimitHeader, + Value: strconv.FormatUint(uint64(descriptor.CurrentLimit.RequestsPerUnit), 10), + } +} + +func (this *service) rateLimitRequestRemainingHeader(descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { + return &core.HeaderValue{ + Key: this.requestHeaderRemainingHeader, + Value: strconv.FormatUint(uint64(descriptor.LimitRemaining), 10), + } +} + +func (this *service) rateLimitRequestResetHeader(descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { + return &core.HeaderValue{ + Key: this.requestHeaderResetHeader, + Value: strconv.FormatInt(utils.CalculateReset(&descriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10), + } +} + func (this *service) ShouldRateLimit( ctx context.Context, request *pb.RateLimitRequest, From 9be189f1aba30e06717e41f18a721625479f4d25 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Mon, 8 Jun 2026 15:25:04 -0700 Subject: [PATCH 26/29] fix: use PWD for all volume paths in integration test compose Relative paths (./examples/...) resolve relative to the compose file location (integration-test/) rather than the repo root, causing mount failures. Use ${PWD} consistently, matching the existing ratelimit and tester service mounts. Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- integration-test/docker-compose-integration-test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration-test/docker-compose-integration-test.yml b/integration-test/docker-compose-integration-test.yml index 46d16a64..c3d4ef21 100644 --- a/integration-test/docker-compose-integration-test.yml +++ b/integration-test/docker-compose-integration-test.yml @@ -23,7 +23,7 @@ services: networks: - ratelimit-network volumes: - - ./examples/prom-statsd-exporter/conf.yaml:/etc/statsd-exporter/conf.yaml + - ${PWD}/examples/prom-statsd-exporter/conf.yaml:/etc/statsd-exporter/conf.yaml ratelimit: build: @@ -40,7 +40,7 @@ services: networks: - ratelimit-network volumes: - - ./examples/ratelimit/config:/data/ratelimit/config + - ${PWD}/examples/ratelimit/config:/data/ratelimit/config environment: - USE_STATSD=true - STATSD_HOST=statsd @@ -66,7 +66,7 @@ services: depends_on: - ratelimit volumes: - - ./examples/envoy/proxy.yaml:/etc/envoy/envoy.yaml + - ${PWD}/examples/envoy/proxy.yaml:/etc/envoy/envoy.yaml networks: - ratelimit-network expose: @@ -87,7 +87,7 @@ services: - "--mode serve" - "--log-level info" volumes: - - ./examples/envoy/mock.yaml:/etc/envoy/envoy.yaml + - ${PWD}/examples/envoy/mock.yaml:/etc/envoy/envoy.yaml networks: - ratelimit-network expose: From e877b7063933da0130ff33f4a5f53b7192eed99b Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Tue, 9 Jun 2026 08:35:18 -0700 Subject: [PATCH 27/29] test: add request header logging to mock and enable feature in integration tests - Add access log format to envoy-mock that logs RateLimit-* request headers, allowing end-to-end verification that Envoy injects request_headers_to_add onto the upstream request - Enable LIMIT_REQUEST_HEADERS_ENABLED in integration test compose Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- examples/envoy/mock.yaml | 7 +++++++ integration-test/docker-compose-integration-test.yml | 1 + 2 files changed, 8 insertions(+) diff --git a/examples/envoy/mock.yaml b/examples/envoy/mock.yaml index 226906ab..02696424 100644 --- a/examples/envoy/mock.yaml +++ b/examples/envoy/mock.yaml @@ -11,6 +11,13 @@ static_resources: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager codec_type: AUTO stat_prefix: ingress + access_log: + - name: envoy.access_loggers.stdout + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + log_format: + text_format_source: + inline_string: "[mock-upstream] method=%REQ(:METHOD)% path=%REQ(:PATH)% RateLimit-Limit=%REQ(RateLimit-Limit)% RateLimit-Remaining=%REQ(RateLimit-Remaining)% RateLimit-Reset=%REQ(RateLimit-Reset)%\n" route_config: name: ingress virtual_hosts: diff --git a/integration-test/docker-compose-integration-test.yml b/integration-test/docker-compose-integration-test.yml index c3d4ef21..775b8e42 100644 --- a/integration-test/docker-compose-integration-test.yml +++ b/integration-test/docker-compose-integration-test.yml @@ -52,6 +52,7 @@ services: - RUNTIME_SUBDIRECTORY=ratelimit - RUNTIME_WATCH_ROOT=false - RESPONSE_DYNAMIC_METADATA=true + - LIMIT_REQUEST_HEADERS_ENABLED=true envoy-proxy: image: envoyproxy/envoy:dev From 54e9ae016f363ce7e604baa6c3fbe910a9f39440 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Thu, 11 Jun 2026 11:03:33 -0700 Subject: [PATCH 28/29] fix: address PR review comments - Remove duplicate pb_struct import alias in src/service/ratelimit_test.go; use existing ratelimitv3 alias throughout - Add comment to examples/envoy/mock.yaml noting the hardcoded default header names and how to update them if custom LIMIT_REQUEST_*_HEADER env vars are used - Remove local absolute paths from implementation plan docs Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Yusof Ganji --- .../2026-06-04-request-headers-to-add.md | 38 +++++++++---------- examples/envoy/mock.yaml | 4 ++ src/service/ratelimit_test.go | 3 +- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-request-headers-to-add.md b/docs/superpowers/plans/2026-06-04-request-headers-to-add.md index 6f7e2b24..34e18e95 100644 --- a/docs/superpowers/plans/2026-06-04-request-headers-to-add.md +++ b/docs/superpowers/plans/2026-06-04-request-headers-to-add.md @@ -43,13 +43,13 @@ func TestRequestHeadersSettingsDefaults(test *testing.T) { Add `"github.com/envoyproxy/ratelimit/src/settings"` to the import block if not already present. Check with: ```bash -head -30 /Users/yganji/workplace/ratelimit-fork/test/service/ratelimit_test.go +head -30 test/service/ratelimit_test.go ``` - [ ] **Step 2: Run test to verify it fails** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestRequestHeadersSettingsDefaults -v +go test ./test/service/... -run TestRequestHeadersSettingsDefaults -v ``` Expected: compile error — `s.RateLimitRequestHeadersEnabled` undefined. @@ -72,7 +72,7 @@ HeaderRequestRatelimitReset string `envconfig:"LIMIT_REQUEST_RESET_HEADER" defau - [ ] **Step 4: Run test to verify it passes** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestRequestHeadersSettingsDefaults -v +go test ./test/service/... -run TestRequestHeadersSettingsDefaults -v ``` Expected: PASS. @@ -80,7 +80,7 @@ Expected: PASS. - [ ] **Step 5: Commit** ```bash -cd /Users/yganji/workplace/ratelimit-fork && git add src/settings/settings.go test/service/ratelimit_test.go +git add src/settings/settings.go test/service/ratelimit_test.go git commit -m "feat: add RequestHeaders settings fields" ``` @@ -129,7 +129,7 @@ func TestRequestHeadersDisabledByDefault(test *testing.T) { - [ ] **Step 2: Run test to verify it fails** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestRequestHeadersDisabledByDefault -v +go test ./test/service/... -run TestRequestHeadersDisabledByDefault -v ``` Expected: compile error — `response.RequestHeadersToAdd` doesn't exist yet on the struct... actually this test will pass immediately since `RequestHeadersToAdd` already exists on the proto. Confirm by running — if it passes, proceed to step 3. @@ -161,7 +161,7 @@ if rlSettings.RateLimitRequestHeadersEnabled { - [ ] **Step 5: Run tests to verify nothing is broken** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run "TestRequestHeadersDisabledByDefault|TestRequestHeadersSettingsDefaults" -v +go test ./test/service/... -run "TestRequestHeadersDisabledByDefault|TestRequestHeadersSettingsDefaults" -v ``` Expected: both PASS. @@ -169,7 +169,7 @@ Expected: both PASS. - [ ] **Step 6: Commit** ```bash -cd /Users/yganji/workplace/ratelimit-fork && git add src/service/ratelimit.go test/service/ratelimit_test.go +git add src/service/ratelimit.go test/service/ratelimit_test.go git commit -m "feat: wire request headers settings into service struct" ``` @@ -242,7 +242,7 @@ Note: `Value: "58"` is the reset seconds computed from `MockClock{now: 2222}` wi - [ ] **Step 2: Run test to verify it fails** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestServiceWithDefaultRequestHeaders -v +go test ./test/service/... -run TestServiceWithDefaultRequestHeaders -v ``` Expected: FAIL — `RequestHeadersToAdd` is nil. @@ -265,7 +265,7 @@ if this.requestHeadersEnabled && minimumDescriptor != nil { - [ ] **Step 4: Run test to verify it passes** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run TestServiceWithDefaultRequestHeaders -v +go test ./test/service/... -run TestServiceWithDefaultRequestHeaders -v ``` Expected: PASS. @@ -273,7 +273,7 @@ Expected: PASS. - [ ] **Step 5: Run all existing tests to verify no regressions** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -v 2>&1 | tail -20 +go test ./test/service/... -v 2>&1 | tail -20 ``` Expected: all PASS. @@ -281,7 +281,7 @@ Expected: all PASS. - [ ] **Step 6: Commit** ```bash -cd /Users/yganji/workplace/ratelimit-fork && git add src/service/ratelimit.go test/service/ratelimit_test.go +git add src/service/ratelimit.go test/service/ratelimit_test.go git commit -m "feat: populate RequestHeadersToAdd in shouldRateLimitWorker" ``` @@ -479,7 +479,7 @@ func TestServiceWithBothRequestAndResponseHeaders(test *testing.T) { - [ ] **Step 4: Run all four new tests** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./test/service/... -run "TestServiceWithDefaultRequestHeaders|TestServiceWithCustomRequestHeaders|TestServiceWithRequestHeadersWithinLimit|TestServiceWithBothRequestAndResponseHeaders" -v +go test ./test/service/... -run "TestServiceWithDefaultRequestHeaders|TestServiceWithCustomRequestHeaders|TestServiceWithRequestHeadersWithinLimit|TestServiceWithBothRequestAndResponseHeaders" -v ``` Expected: all 4 PASS. @@ -487,7 +487,7 @@ Expected: all 4 PASS. - [ ] **Step 5: Run the full test suite** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./... 2>&1 | tail -20 +go test ./... 2>&1 | tail -20 ``` Expected: all PASS. @@ -495,7 +495,7 @@ Expected: all PASS. - [ ] **Step 6: Commit** ```bash -cd /Users/yganji/workplace/ratelimit-fork && git add test/service/ratelimit_test.go +git add test/service/ratelimit_test.go git commit -m "test: add RequestHeadersToAdd test cases" ``` @@ -523,7 +523,7 @@ The following environment variables control the custom request header feature (h - [ ] **Step 2: Verify the section reads correctly** ```bash -grep -A 10 "LIMIT_REQUEST_HEADERS_ENABLED" /Users/yganji/workplace/ratelimit-fork/README.md +grep -A 10 "LIMIT_REQUEST_HEADERS_ENABLED" README.md ``` Expected: the 4 env vars appear with their descriptions. @@ -531,7 +531,7 @@ Expected: the 4 env vars appear with their descriptions. - [ ] **Step 3: Commit** ```bash -cd /Users/yganji/workplace/ratelimit-fork && git add README.md +git add README.md git commit -m "docs: add RequestHeadersToAdd documentation to README" ``` @@ -542,7 +542,7 @@ git commit -m "docs: add RequestHeadersToAdd documentation to README" - [ ] **Step 1: Run the full test suite one more time** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go test ./... 2>&1 | grep -E "FAIL|ok" +go test ./... 2>&1 | grep -E "FAIL|ok" ``` Expected: all lines show `ok`, no `FAIL`. @@ -550,7 +550,7 @@ Expected: all lines show `ok`, no `FAIL`. - [ ] **Step 2: Check the build** ```bash -cd /Users/yganji/workplace/ratelimit-fork && go build ./... +go build ./... ``` Expected: exits with code 0, no output. @@ -558,7 +558,7 @@ Expected: exits with code 0, no output. - [ ] **Step 3: Review the diff** ```bash -cd /Users/yganji/workplace/ratelimit-fork && git diff origin/main..HEAD --stat +git diff origin/main..HEAD --stat ``` Confirm only these files changed: `src/settings/settings.go`, `src/service/ratelimit.go`, `test/service/ratelimit_test.go`, `README.md`, `docs/`. diff --git a/examples/envoy/mock.yaml b/examples/envoy/mock.yaml index 02696424..bd46116e 100644 --- a/examples/envoy/mock.yaml +++ b/examples/envoy/mock.yaml @@ -11,6 +11,10 @@ static_resources: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager codec_type: AUTO stat_prefix: ingress + # Access log for integration testing: logs the default RateLimit-* request headers + # injected by Envoy when LIMIT_REQUEST_HEADERS_ENABLED=true. + # If you configure custom header names via LIMIT_REQUEST_*_HEADER env vars, + # update the %REQ(...)% references below accordingly. access_log: - name: envoy.access_loggers.stdout typed_config: diff --git a/src/service/ratelimit_test.go b/src/service/ratelimit_test.go index 02250f1a..7d94580b 100644 --- a/src/service/ratelimit_test.go +++ b/src/service/ratelimit_test.go @@ -8,7 +8,6 @@ import ( ratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" - pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" "github.com/google/go-cmp/cmp" gostats "github.com/lyft/gostats" "github.com/stretchr/testify/assert" @@ -241,7 +240,7 @@ func (s stubConfigEvent) GetConfig() (config.RateLimitConfig, any) { type stubRLConfig struct{} func (s *stubRLConfig) Dump() string { return "" } -func (s *stubRLConfig) GetLimit(_ context.Context, _ string, _ *pb_struct.RateLimitDescriptor) *config.RateLimit { +func (s *stubRLConfig) GetLimit(_ context.Context, _ string, _ *ratelimitv3.RateLimitDescriptor) *config.RateLimit { return nil } func (s *stubRLConfig) IsEmptyDomains() bool { return false } From d11ec377366248bee07ae2bba247240941fece68 Mon Sep 17 00:00:00 2001 From: Yusof Ganji Date: Tue, 11 Aug 2026 14:50:32 -0700 Subject: [PATCH 29/29] style: apply prettier formatting to superpowers docs Signed-off-by: Yusof Ganji --- .../2026-06-04-request-headers-to-add.md | 20 +++++++++++-------- ...026-06-04-request-headers-to-add-design.md | 14 ++++++------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-06-04-request-headers-to-add.md b/docs/superpowers/plans/2026-06-04-request-headers-to-add.md index 34e18e95..5ece32d8 100644 --- a/docs/superpowers/plans/2026-06-04-request-headers-to-add.md +++ b/docs/superpowers/plans/2026-06-04-request-headers-to-add.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add `RequestHeadersToAdd` support so the ratelimit service can inject RateLimit-* headers into the forwarded request (upstream direction), mirroring the existing `ResponseHeadersToAdd` feature. +**Goal:** Add `RequestHeadersToAdd` support so the ratelimit service can inject RateLimit-\* headers into the forwarded request (upstream direction), mirroring the existing `ResponseHeadersToAdd` feature. **Architecture:** Four new env vars (`LIMIT_REQUEST_HEADERS_ENABLED`, `LIMIT_REQUEST_LIMIT_HEADER`, `LIMIT_REQUEST_REMAINING_HEADER`, `LIMIT_REQUEST_RESET_HEADER`) control the feature. When enabled, `shouldRateLimitWorker` populates `response.RequestHeadersToAdd` using the same `minimumDescriptor` logic already used for `ResponseHeadersToAdd`. The two features are independently controlled. @@ -12,18 +12,19 @@ ## File Map -| File | Change | -|---|---| -| `src/settings/settings.go` | Add 4 new env var fields | -| `src/service/ratelimit.go` | Add 4 struct fields, wire in `SetConfig`, populate `RequestHeadersToAdd` in `shouldRateLimitWorker` | -| `test/service/ratelimit_test.go` | Add 4 new test functions | -| `README.md` | Add `Global Rate Limit Request Headers` section after existing `Custom headers` section | +| File | Change | +| -------------------------------- | --------------------------------------------------------------------------------------------------- | +| `src/settings/settings.go` | Add 4 new env var fields | +| `src/service/ratelimit.go` | Add 4 struct fields, wire in `SetConfig`, populate `RequestHeadersToAdd` in `shouldRateLimitWorker` | +| `test/service/ratelimit_test.go` | Add 4 new test functions | +| `README.md` | Add `Global Rate Limit Request Headers` section after existing `Custom headers` section | --- ## Task 1: Add settings fields **Files:** + - Modify: `src/settings/settings.go:114-121` - [ ] **Step 1: Write a failing test that reads the new settings fields** @@ -89,6 +90,7 @@ git commit -m "feat: add RequestHeaders settings fields" ## Task 2: Wire settings into service struct **Files:** + - Modify: `src/service/ratelimit.go:42-57` (struct), `src/service/ratelimit.go:89-103` (SetConfig) - [ ] **Step 1: Write a failing test — request headers disabled by default** @@ -178,6 +180,7 @@ git commit -m "feat: wire request headers settings into service struct" ## Task 3: Populate RequestHeadersToAdd in shouldRateLimitWorker **Files:** + - Modify: `src/service/ratelimit.go:258-264` - [ ] **Step 1: Write failing test — default header names, over limit** @@ -290,6 +293,7 @@ git commit -m "feat: populate RequestHeadersToAdd in shouldRateLimitWorker" ## Task 4: Add remaining test cases **Files:** + - Modify: `test/service/ratelimit_test.go` - [ ] **Step 1: Add test — custom header names** @@ -504,6 +508,7 @@ git commit -m "test: add RequestHeadersToAdd test cases" ## Task 5: Update README **Files:** + - Modify: `README.md:1406-1416` - [ ] **Step 1: Insert the new section after the existing `Custom headers` section** @@ -511,7 +516,6 @@ git commit -m "test: add RequestHeadersToAdd test cases" In `README.md`, after line 1415 (`1. \`LIMIT_RESET_HEADER\` ...`) and before line 1417 (`# Tracing`), insert: ```markdown - The following environment variables control the custom request header feature (headers injected into the forwarded request sent to upstream services by Envoy): 1. `LIMIT_REQUEST_HEADERS_ENABLED` - Enables the custom request headers. When enabled, Envoy injects these headers into the forwarded request, allowing upstream services to inspect rate limit state and make per-request routing decisions (e.g. skip a hot path when quota is exhausted) without the request being blocked. diff --git a/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md b/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md index 57e49a5a..89765598 100644 --- a/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md +++ b/docs/superpowers/specs/2026-06-04-request-headers-to-add-design.md @@ -1,7 +1,7 @@ # Design: `RequestHeadersToAdd` Support -**Date:** 2026-06-04 -**Repo:** github.com/envoyproxy/ratelimit +**Date:** 2026-06-04 +**Repo:** github.com/envoyproxy/ratelimit **Scope:** Open source contribution ## Problem @@ -28,12 +28,12 @@ Add a `RequestHeadersToAdd` feature that mirrors `ResponseHeadersToAdd` exactly: Four new env vars added to `src/settings/settings.go`, parallel to the existing response header settings: -| Env var | Default | Purpose | -|---|---|---| -| `LIMIT_REQUEST_HEADERS_ENABLED` | `false` | Master on/off switch | -| `LIMIT_REQUEST_LIMIT_HEADER` | `RateLimit-Limit` | Header name for the limit value | +| Env var | Default | Purpose | +| -------------------------------- | --------------------- | ------------------------------- | +| `LIMIT_REQUEST_HEADERS_ENABLED` | `false` | Master on/off switch | +| `LIMIT_REQUEST_LIMIT_HEADER` | `RateLimit-Limit` | Header name for the limit value | | `LIMIT_REQUEST_REMAINING_HEADER` | `RateLimit-Remaining` | Header name for remaining count | -| `LIMIT_REQUEST_RESET_HEADER` | `RateLimit-Reset` | Header name for reset seconds | +| `LIMIT_REQUEST_RESET_HEADER` | `RateLimit-Reset` | Header name for reset seconds | Default names match the response header defaults. Operators can set different names to distinguish upstream vs downstream headers (e.g. `x-grls-ratelimit-remaining` upstream vs `RateLimit-Remaining` downstream).