diff --git a/api/v1alpha1/clienttrafficpolicy_types.go b/api/v1alpha1/clienttrafficpolicy_types.go index cae9efbbf0a..9ee80621fcb 100644 --- a/api/v1alpha1/clienttrafficpolicy_types.go +++ b/api/v1alpha1/clienttrafficpolicy_types.go @@ -140,6 +140,11 @@ type HeaderSettings struct { // +optional DisableRateLimitHeaders *bool `json:"disableRateLimitHeaders,omitempty"` + // EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header + // on rate-limited 429 responses. + // +optional + EnableRetryAfterHeader *bool `json:"enableRetryAfterHeader,omitempty"` + // XForwardedClientCert configures how Envoy Proxy handle the x-forwarded-client-cert (XFCC) HTTP header. // // x-forwarded-client-cert (XFCC) is an HTTP header used to forward the certificate diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 6af742f16fc..0a5b4955849 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5014,6 +5014,11 @@ func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) { *out = new(bool) **out = **in } + if in.EnableRetryAfterHeader != nil { + in, out := &in.EnableRetryAfterHeader, &out.EnableRetryAfterHeader + *out = new(bool) + **out = **in + } if in.XForwardedClientCert != nil { in, out := &in.XForwardedClientCert, &out.XForwardedClientCert *out = new(XForwardedClientCert) diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml index 0d70b99aa41..1a9eb7b50e8 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml @@ -499,6 +499,11 @@ spec: EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests and responses. type: boolean + enableRetryAfterHeader: + description: |- + EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header + on rate-limited 429 responses. + type: boolean host: description: Host enables managing how the Host/Authority header set by clients can be normalized. diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml index 79dda3f2794..29ef1f4db20 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_clienttrafficpolicies.yaml @@ -498,6 +498,11 @@ spec: EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests and responses. type: boolean + enableRetryAfterHeader: + description: |- + EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header + on rate-limited 429 responses. + type: boolean host: description: Host enables managing how the Host/Authority header set by clients can be normalized. diff --git a/examples/extension-server/go.mod b/examples/extension-server/go.mod index af7719c729a..db585313e87 100644 --- a/examples/extension-server/go.mod +++ b/examples/extension-server/go.mod @@ -5,7 +5,7 @@ go 1.27.0 require ( github.com/envoyproxy/gateway v1.3.1 github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908 - github.com/envoyproxy/go-control-plane/envoy v1.39.0 + github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369 github.com/urfave/cli/v2 v2.27.7 google.golang.org/grpc v1.83.1 google.golang.org/protobuf v1.36.12 diff --git a/examples/extension-server/go.sum b/examples/extension-server/go.sum index 488139a357c..6e7576be9f5 100644 --- a/examples/extension-server/go.sum +++ b/examples/extension-server/go.sum @@ -23,8 +23,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908 h1:Ymr4e9UMtsAYCBiuHV2JF8BxaMZu49yEFpTXBqgaqUE= github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908/go.mod h1:rcDQvjX9fwNQvh7l9pxwfGBufBWU6xQ0zK5mcsH8/Lg= -github.com/envoyproxy/go-control-plane/envoy v1.39.0 h1:1uwRDYPYG8BIBU9Mj1sUAebNmlM6beu/ZKKweSLDxk8= -github.com/envoyproxy/go-control-plane/envoy v1.39.0/go.mod h1:5e4ylfTZO723MEEFsCpSW4ZEBWR8mwkEyXfwJBTCZ9c= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369 h1:ZJjjlzVZoUwYJtlGzPjVC+gbG/v16XUJRHE0pQ1FFz4= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369/go.mod h1:PYEOlng9XcrulfyWpm49jECTPV0LT4q8cO7fLW/xwgk= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= diff --git a/go.mod b/go.mod index 731a76d21f8..100bfe2d00c 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/dominikbraun/graph v0.23.0 github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908 github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260729145720-a2d8c7492908 - github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260729145720-a2d8c7492908 + github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369 github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260729145720-a2d8c7492908 github.com/envoyproxy/ratelimit v1.4.1-0.20260122083618-3fb702589d36 github.com/evanphx/json-patch v5.9.11+incompatible diff --git a/go.sum b/go.sum index 4a53197ed2d..e94b66efd6b 100644 --- a/go.sum +++ b/go.sum @@ -163,8 +163,8 @@ github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908 h1: github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908/go.mod h1:rcDQvjX9fwNQvh7l9pxwfGBufBWU6xQ0zK5mcsH8/Lg= github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260729145720-a2d8c7492908 h1:+7FtQqQ7Yfo5dgsAyTAIe8OhmUtWUw6VjeQ3by2Xdm4= github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260729145720-a2d8c7492908/go.mod h1:pdjA+146jsWRsJ0M1jgfRrvJq1HPWeyvaT/Va+nV2OY= -github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260729145720-a2d8c7492908 h1:yp1HVjmLFpwwN3Ik82LUTAZ6giMb6NAuhA3fralKvRQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260729145720-a2d8c7492908/go.mod h1:rYoRI6N8FcjZVmmeKqH8/ykAhfro9Bg8+of2IpBseyQ= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369 h1:ZJjjlzVZoUwYJtlGzPjVC+gbG/v16XUJRHE0pQ1FFz4= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369/go.mod h1:PYEOlng9XcrulfyWpm49jECTPV0LT4q8cO7fLW/xwgk= github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260729145720-a2d8c7492908 h1:dNsOk1pDOqRl4SzUdAWDPXQ05VASaD5CYYaVpfZr7os= github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260729145720-a2d8c7492908/go.mod h1:kFrXkz+a+gF/F56IcDXtBE0QfpZfFTyf92okqDzIBu0= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= diff --git a/internal/gatewayapi/clienttrafficpolicy.go b/internal/gatewayapi/clienttrafficpolicy.go index 05bc761d180..611fd320660 100644 --- a/internal/gatewayapi/clienttrafficpolicy.go +++ b/internal/gatewayapi/clienttrafficpolicy.go @@ -1020,6 +1020,7 @@ func translateListenerHeaderSettings(headerSettings *egv1a1.HeaderSettings, http httpIR.Headers = &ir.HeaderSettings{ EnableEnvoyHeaders: ptr.Deref(headerSettings.EnableEnvoyHeaders, false), DisableRateLimitHeaders: ptr.Deref(headerSettings.DisableRateLimitHeaders, false), + EnableRetryAfterHeader: ptr.Deref(headerSettings.EnableRetryAfterHeader, false), WithUnderscoresAction: ir.WithUnderscoresAction(ptr.Deref(headerSettings.WithUnderscoresAction, egv1a1.WithUnderscoresActionRejectRequest)), } if headerSettings.RequestID != nil { diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-retryafterheader.in.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-retryafterheader.in.yaml new file mode 100644 index 00000000000..e2e55c6da34 --- /dev/null +++ b/internal/gatewayapi/testdata/clienttrafficpolicy-retryafterheader.in.yaml @@ -0,0 +1,34 @@ +clientTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: ClientTrafficPolicy + metadata: + namespace: envoy-gateway + name: target-gateway-1 + spec: + headers: + enableRetryAfterHeader: true + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http-1 + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same + - name: http-2 + protocol: HTTP + port: 8080 + allowedRoutes: + namespaces: + from: Same diff --git a/internal/gatewayapi/testdata/clienttrafficpolicy-retryafterheader.out.yaml b/internal/gatewayapi/testdata/clienttrafficpolicy-retryafterheader.out.yaml new file mode 100644 index 00000000000..2f7a454ce8d --- /dev/null +++ b/internal/gatewayapi/testdata/clienttrafficpolicy-retryafterheader.out.yaml @@ -0,0 +1,192 @@ +clientTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: ClientTrafficPolicy + metadata: + name: target-gateway-1 + namespace: envoy-gateway + spec: + headers: + enableRetryAfterHeader: true + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: spec.targetRef is deprecated, use spec.targetRefs instead + reason: DeprecatedField + status: "True" + type: Warning + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: Same + name: http-1 + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: Same + name: http-2 + port: 8080 + protocol: HTTP + status: + listeners: + - attachedRoutes: 0 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http-1 + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + - attachedRoutes: 0 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http-2 + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - name: envoy-gateway/gateway-1/http-1 + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + - name: envoy-gateway/gateway-1/http-2 + ports: + - containerPort: 8080 + name: http-8080 + protocol: HTTP + servicePort: 8080 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + headers: + enableRetryAfterHeader: true + withUnderscoresAction: RejectRequest + hostnames: + - '*' + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-1 + name: envoy-gateway/gateway-1/http-1 + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + - address: 0.0.0.0 + externalPort: 8080 + headers: + enableRetryAfterHeader: true + withUnderscoresAction: RejectRequest + hostnames: + - '*' + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-2 + name: envoy-gateway/gateway-1/http-2 + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 8080 + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 5c364eb6283..bbb3aa0d5eb 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -948,6 +948,11 @@ type HeaderSettings struct { // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ratelimit/v3/rate_limit.proto#extensions-filters-http-ratelimit-v3-ratelimit DisableRateLimitHeaders bool `json:"disableRateLimitHeaders,omitempty" yaml:"disableRateLimitHeaders,omitempty"` + // EnableRetryAfterHeader controls if a "Retry-After" header is added to rate-limited 429 responses. + // The default is to omit this header. + // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ratelimit/v3/rate_limit.proto#extensions-filters-http-ratelimit-v3-ratelimit + EnableRetryAfterHeader bool `json:"enableRetryAfterHeader,omitempty" yaml:"enableRetryAfterHeader,omitempty"` + // Configure Envoy proxy how to handle the x-forwarded-client-cert (XFCC) HTTP header. // refer to https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto#envoy-v3-api-enum-extensions-filters-network-http-connection-manager-v3-httpconnectionmanager-forwardclientcertdetails XForwardedClientCert *XForwardedClientCert `json:"xForwardedClientCert,omitempty" yaml:"xForwardedClientCert,omitempty"` diff --git a/internal/xds/extensions/extensions.gen.go b/internal/xds/extensions/extensions.gen.go index 4a38cf546a6..690899c373d 100644 --- a/internal/xds/extensions/extensions.gen.go +++ b/internal/xds/extensions/extensions.gen.go @@ -116,6 +116,7 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/early_data/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/dependency/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/fault/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/jwks/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/matcher/action/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/common/set_filter_state/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/a2a/v3" @@ -130,6 +131,7 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/bandwidth_limit/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/bandwidth_share/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/body_size_limit/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/buffer/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cache/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cache_v2/v3" @@ -281,6 +283,7 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/envelope/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/header/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/allow_listed_routes/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/filter_state/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/previous_routes/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/safe_cross_scheme/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/key_value/file_based/v3" diff --git a/internal/xds/translator/local_ratelimit.go b/internal/xds/translator/local_ratelimit.go index f527740123c..9fbf932d38e 100644 --- a/internal/xds/translator/local_ratelimit.go +++ b/internal/xds/translator/local_ratelimit.go @@ -185,6 +185,9 @@ func (*localRateLimit) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute, h if httpListener.Headers != nil && httpListener.Headers.DisableRateLimitHeaders { localRl.EnableXRatelimitHeaders = rlv3.XRateLimitHeadersRFCVersion_OFF } + if httpListener.Headers != nil && httpListener.Headers.EnableRetryAfterHeader { + localRl.EnableRetryAfterHeader = true + } localRlAny, err := anypb.New(localRl) if err != nil { diff --git a/internal/xds/translator/ratelimit.go b/internal/xds/translator/ratelimit.go index ed862a344a1..87bbffd6796 100644 --- a/internal/xds/translator/ratelimit.go +++ b/internal/xds/translator/ratelimit.go @@ -149,6 +149,11 @@ func createRateLimitFilter(t *Translator, irListener *ir.HTTPListener, domain, f rateLimitFilterProto.EnableXRatelimitHeaders = ratelimitfilterv3.RateLimit_DRAFT_VERSION_03 } + // Emit the Retry-After header on rate-limited 429 responses. + if irListener.Headers != nil && irListener.Headers.EnableRetryAfterHeader { + rateLimitFilterProto.EnableRetryAfterHeader = true + } + // Set the failure mode to deny if the global rate limit is configured to fail closed if t.GlobalRateLimit.FailClosed { rateLimitFilterProto.FailureModeDeny = t.GlobalRateLimit.FailClosed diff --git a/internal/xds/translator/testdata/in/xds-ir/local-ratelimit-retry-after-header.yaml b/internal/xds/translator/testdata/in/xds-ir/local-ratelimit-retry-after-header.yaml new file mode 100644 index 00000000000..862a01f18d0 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/local-ratelimit-retry-after-header.yaml @@ -0,0 +1,36 @@ +http: +- name: "first-listener" + address: "::" + port: 10080 + hostnames: + - "*" + path: + mergeSlashes: true + escapedSlashesAction: UnescapeAndRedirect + headers: + enableRetryAfterHeader: true + routes: + - name: "first-route-ratelimit-single-rule" + hostname: "*" + traffic: + rateLimit: + local: + default: + requests: 10 + unit: Minute + rules: + - headerMatches: + - name: x-user-id + exact: one + limit: + requests: 10 + unit: Hour + pathMatch: + exact: "foo/bar" + destination: + name: "first-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + name: "first-route-dest/backend/0" diff --git a/internal/xds/translator/testdata/in/xds-ir/ratelimit-retry-after-header.yaml b/internal/xds/translator/testdata/in/xds-ir/ratelimit-retry-after-header.yaml new file mode 100644 index 00000000000..c16d555f9f8 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/ratelimit-retry-after-header.yaml @@ -0,0 +1,38 @@ +globalResources: + envoyClientCertificate: + name: envoy-gateway-system/envoy + privateKey: [107, 101, 121, 45, 100, 97, 116, 97] + certificate: [99, 101, 114, 116, 45, 100, 97, 116, 97] +http: + - name: "first-listener" + address: "::" + port: 10080 + hostnames: + - "*" + path: + mergeSlashes: true + escapedSlashesAction: UnescapeAndRedirect + headers: + enableRetryAfterHeader: true + routes: + - name: "first-route" + hostname: "*" + traffic: + rateLimit: + global: + rules: + - headerMatches: + - name: "x-user-id" + exact: "one" + limit: + requests: 5 + unit: second + pathMatch: + exact: "foo/bar" + destination: + name: "first-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + name: "first-route-dest/backend/0" diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.clusters.yaml new file mode 100644 index 00000000000..886a7d9f6cd --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.clusters.yaml @@ -0,0 +1,23 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: first-route-dest + ignoreHealthOnHostRemoval: true + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: first-route-dest + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.endpoints.yaml new file mode 100644 index 00000000000..3b3f2d09076 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.endpoints.yaml @@ -0,0 +1,12 @@ +- clusterName: first-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: first-route-dest/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.listeners.yaml new file mode 100644 index 00000000000..f7e6055c385 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.listeners.yaml @@ -0,0 +1,41 @@ +- address: + socketAddress: + address: '::' + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.local_ratelimit + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit + maxDynamicDescriptors: 10000 + statPrefix: http_local_rate_limiter + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + initialFetchTimeout: 0s + resourceApiVersion: V3 + routeConfigName: first-listener + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: first-listener + maxConnectionsToAcceptPerSocketEvent: 1 + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.routes.yaml new file mode 100644 index 00000000000..eded53c3256 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-retry-after-header.routes.yaml @@ -0,0 +1,49 @@ +- ignorePortInHostMatching: true + name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener/* + routes: + - match: + path: foo/bar + name: first-route-ratelimit-single-rule + route: + cluster: first-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.local_ratelimit: + '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit + alwaysConsumeDefaultTokenBucket: false + descriptors: + - entries: + - key: rule-0-match-0 + value: rule-0-match-0 + tokenBucket: + fillInterval: 3600s + maxTokens: 10 + tokensPerFill: 10 + enableRetryAfterHeader: true + enableXRatelimitHeaders: DRAFT_VERSION_03 + filterEnabled: + defaultValue: + numerator: 100 + filterEnforced: + defaultValue: + numerator: 100 + rateLimits: + - actions: + - headerValueMatch: + descriptorKey: rule-0-match-0 + descriptorValue: rule-0-match-0 + expectMatch: true + headers: + - name: x-user-id + stringMatch: + exact: one + statPrefix: http_local_rate_limiter + tokenBucket: + fillInterval: 60s + maxTokens: 10 + tokensPerFill: 10 diff --git a/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.clusters.yaml new file mode 100644 index 00000000000..1ee16ebe4b8 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.clusters.yaml @@ -0,0 +1,83 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: first-route-dest + ignoreHealthOnHostRemoval: true + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: first-route-dest + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + clusterType: + name: envoy.cluster.dns + typedConfig: + '@type': type.googleapis.com/envoy.extensions.clusters.dns.v3.DnsCluster + dnsLookupFamily: V4_PREFERRED + dnsRefreshRate: 30s + respectDnsTtl: true + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + ignoreHealthOnHostRemoval: true + loadAssignment: + clusterName: ratelimit_cluster + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: envoy-ratelimit.envoy-gateway-system.svc.cluster.local + portValue: 8081 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: ratelimit_cluster/backend/-1 + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: ratelimit_cluster + perConnectionBufferLimitBytes: 32768 + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + tlsCertificateSdsSecretConfigs: + - name: envoy-gateway-system/envoy + sdsConfig: + ads: {} + initialFetchTimeout: 0s + resourceApiVersion: V3 + tlsParams: + tlsMaximumProtocolVersion: TLSv1_3 + validationContext: + trustedCa: + filename: /certs/ca.crt + typedExtensionProtocolOptions: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicitHttpConfig: + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 diff --git a/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.endpoints.yaml new file mode 100644 index 00000000000..3b3f2d09076 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.endpoints.yaml @@ -0,0 +1,12 @@ +- clusterName: first-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: first-route-dest/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.listeners.yaml new file mode 100644 index 00000000000..e310d2c485b --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.listeners.yaml @@ -0,0 +1,48 @@ +- address: + socketAddress: + address: '::' + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.ratelimit + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit + disableXEnvoyRatelimitedHeader: true + domain: first-listener + enableRetryAfterHeader: true + enableXRatelimitHeaders: DRAFT_VERSION_03 + rateLimitService: + grpcService: + envoyGrpc: + clusterName: ratelimit_cluster + transportApiVersion: V3 + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + initialFetchTimeout: 0s + resourceApiVersion: V3 + routeConfigName: first-listener + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: first-listener + maxConnectionsToAcceptPerSocketEvent: 1 + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.routes.yaml new file mode 100644 index 00000000000..3a75966d934 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.routes.yaml @@ -0,0 +1,31 @@ +- ignorePortInHostMatching: true + name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener/* + routes: + - match: + path: foo/bar + name: first-route + route: + cluster: first-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.ratelimit: + '@type': type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimitPerRoute + domain: first-listener + rateLimits: + - actions: + - genericKey: + descriptorKey: first-route + descriptorValue: first-route + - headerValueMatch: + descriptorKey: rule-0-match-0 + descriptorValue: rule-0-match-0 + expectMatch: true + headers: + - name: x-user-id + stringMatch: + exact: one diff --git a/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.secrets.yaml b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.secrets.yaml new file mode 100644 index 00000000000..fb089151187 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/ratelimit-retry-after-header.secrets.yaml @@ -0,0 +1,6 @@ +- name: envoy-gateway-system/envoy + tlsCertificate: + certificateChain: + inlineBytes: Y2VydC1kYXRh + privateKey: + inlineBytes: a2V5LWRhdGE= diff --git a/release-notes/current/new_features/9769-client-traffic-policy-retry-after-header.md b/release-notes/current/new_features/9769-client-traffic-policy-retry-after-header.md new file mode 100644 index 00000000000..15cb20b49ca --- /dev/null +++ b/release-notes/current/new_features/9769-client-traffic-policy-retry-after-header.md @@ -0,0 +1 @@ +Added `ClientTrafficPolicy.spec.headers.enableRetryAfterHeader` to emit a `Retry-After` header on rate-limited 429 responses, for both the global and local rate limit filters. Defaults to `false`. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index a7fdb75c863..a4c6bf53f5c 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -3458,6 +3458,7 @@ _Appears in:_ | --- | --- | --- | --- | --- | | `enableEnvoyHeaders` | _boolean_ | false | | EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests
and responses. | | `disableRateLimitHeaders` | _boolean_ | false | | DisableRateLimitHeaders configures Envoy Proxy to omit the "X-RateLimit-" response headers
when rate limiting is enabled. | +| `enableRetryAfterHeader` | _boolean_ | false | | EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header
on rate-limited 429 responses. | | `xForwardedClientCert` | _[XForwardedClientCert](#xforwardedclientcert)_ | false | | XForwardedClientCert configures how Envoy Proxy handle the x-forwarded-client-cert (XFCC) HTTP header.
x-forwarded-client-cert (XFCC) is an HTTP header used to forward the certificate
information of part or all of the clients or proxies that a request has flowed through,
on its way from the client to the server.
Envoy proxy may choose to sanitize/append/forward the XFCC header before proxying the request.
If not set, the default behavior is sanitizing the XFCC header. | | `withUnderscoresAction` | _[WithUnderscoresAction](#withunderscoresaction)_ | false | | WithUnderscoresAction configures the action to take when an HTTP header with underscores
is encountered. The default action is to reject the request. | | `preserveXRequestID` | _boolean_ | false | | PreserveXRequestID configures Envoy to keep the X-Request-ID header if passed for a request that is edge
(Edge request is the request from external clients to front Envoy) and not reset it, which is the current Envoy behaviour.
Defaults to false and cannot be combined with RequestID.
Deprecated: use RequestID=PreserveOrGenerate instead | diff --git a/test/e2e/testdata/local-ratelimit-retry-after-header.yaml b/test/e2e/testdata/local-ratelimit-retry-after-header.yaml new file mode 100644 index 00000000000..57d9a719e8a --- /dev/null +++ b/test/e2e/testdata/local-ratelimit-retry-after-header.yaml @@ -0,0 +1,47 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: ClientTrafficPolicy +metadata: + name: local-ratelimit-retry-after-header-ctp + namespace: gateway-conformance-infra +spec: + headers: + enableRetryAfterHeader: true + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: same-namespace +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: local-ratelimit-retry-after-header-btp + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: local-ratelimit-retry-after-header + rateLimit: + type: Local + local: + rules: + - limit: + requests: 3 + unit: Hour +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: local-ratelimit-retry-after-header + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /retry-after-local + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/test/e2e/testdata/ratelimit-retry-after-header.yaml b/test/e2e/testdata/ratelimit-retry-after-header.yaml new file mode 100644 index 00000000000..8bab52c6ecb --- /dev/null +++ b/test/e2e/testdata/ratelimit-retry-after-header.yaml @@ -0,0 +1,52 @@ +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: ClientTrafficPolicy +metadata: + name: ratelimit-retry-after-header-ctp + namespace: gateway-conformance-infra +spec: + headers: + enableRetryAfterHeader: true + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: same-namespace +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: ratelimit-retry-after-header-btp + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: ratelimit-retry-after-header + rateLimit: + type: Global + global: + rules: + - clientSelectors: + - headers: + - name: x-user-id + type: Exact + value: retry-after-global + limit: + requests: 3 + unit: Hour +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ratelimit-retry-after-header + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /retry-after-global + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/test/e2e/tests/local_ratelimit.go b/test/e2e/tests/local_ratelimit.go index 124961e5dcd..a3ac17abcde 100644 --- a/test/e2e/tests/local_ratelimit.go +++ b/test/e2e/tests/local_ratelimit.go @@ -27,6 +27,7 @@ func init() { ConformanceTests = append(ConformanceTests, LocalRateLimitTest, LocalRateLimitQueryParametersTest, + LocalRateLimitRetryAfterHeaderTest, ) } @@ -34,6 +35,7 @@ const ( RatelimitLimitHeaderName = "x-ratelimit-limit" RatelimitRemainingHeaderName = "x-ratelimit-remaining" RatelimitResetHeaderName = "x-ratelimit-reset" + RetryAfterHeaderName = "retry-after" ) var allRateLimitHeaders = []string{ @@ -671,3 +673,50 @@ func runQueryParametersRateLimitTest(t *testing.T, suite *suite.ConformanceTestS MakeRequestAndExpectEventuallyConsistentResponseExceptErrors(t, suite.RoundTripper, &suite.TimeoutConfig, gwAddr, &okResponse3) }) } + +var LocalRateLimitRetryAfterHeaderTest = suite.ConformanceTest{ + ShortName: "LocalRateLimitRetryAfterHeader", + Description: "Emit Retry-After header on local rate-limited 429 responses", + Manifests: []string{"testdata/local-ratelimit-retry-after-header.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := gatewayAndHTTPRoutesMustBeAccepted(t, suite, gwNN) + + ancestorRef := gwapiv1.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + } + BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "local-ratelimit-retry-after-header-btp", Namespace: ns}, suite.ControllerName, ancestorRef) + + okResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/retry-after-local", + }, + Response: http.Response{ + StatusCodes: []int{200}, + AbsentHeaders: []string{RetryAfterHeaderName}, + }, + Namespace: ns, + } + MakeRequestAndExpectEventuallyConsistentResponseExceptErrors(t, suite.RoundTripper, &suite.TimeoutConfig, gwAddr, &okResponse) + + limitResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/retry-after-local", + }, + Response: http.Response{ + StatusCodes: []int{429}, + // The value counts down from the 3600s (1 Hour) window, so allow a small + // tolerance rather than asserting an exact, time-dependent value. + ValidHeaderValues: map[string][]string{ + RetryAfterHeaderName: {"3600", "3599", "3598"}, + }, + }, + Namespace: ns, + } + MakeRequestAndExpectEventuallyConsistentResponseExceptErrors(t, suite.RoundTripper, &suite.TimeoutConfig, gwAddr, &limitResponse) + }, +} diff --git a/test/e2e/tests/ratelimit.go b/test/e2e/tests/ratelimit.go index ff92404d3a1..8a8918091d6 100644 --- a/test/e2e/tests/ratelimit.go +++ b/test/e2e/tests/ratelimit.go @@ -50,6 +50,7 @@ func init() { RateLimitPathMatchTest, GlobalRateLimitHeaderInvertMatchTest, RateLimitHeadersDisabled, + RateLimitRetryAfterHeaderTest, RateLimitBasedJwtClaimsTest, RateLimitMultipleListenersTest, RateLimitHeadersAndCIDRMatchTest, @@ -744,6 +745,74 @@ var RateLimitHeadersDisabled = suite.ConformanceTest{ }, } +var RateLimitRetryAfterHeaderTest = suite.ConformanceTest{ + ShortName: "RateLimitRetryAfterHeader", + Description: "Emit Retry-After header on rate-limited 429 responses", + Manifests: []string{"testdata/ratelimit-retry-after-header.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "ratelimit-retry-after-header", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, routeNN) + + BackendTrafficPolicyMustBeAccepted(t, suite.Client, + types.NamespacedName{Name: "ratelimit-retry-after-header-btp", Namespace: ns}, + suite.ControllerName, gwapiv1.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + }) + + requestHeaders := map[string]string{ + "x-user-id": "retry-after-global", + } + + expectOkResp := http.ExpectedResponse{ + Request: http.Request{ + Path: "/retry-after-global", + Headers: requestHeaders, + }, + Response: http.Response{ + StatusCodes: []int{200}, + AbsentHeaders: []string{RetryAfterHeaderName}, + }, + Namespace: ns, + } + expectOkReq := http.MakeRequest(t, &expectOkResp, gwAddr, "HTTP", "http") + + expectLimitResp := http.ExpectedResponse{ + Request: http.Request{ + Path: "/retry-after-global", + Headers: requestHeaders, + }, + Response: http.Response{ + StatusCodes: []int{429}, + // The value counts down from the 3600s (1 Hour) window, so allow a small + // tolerance rather than asserting an exact, time-dependent value. + ValidHeaderValues: map[string][]string{ + RetryAfterHeaderName: {"3600", "3599", "3598"}, + }, + }, + Namespace: ns, + } + expectLimitReq := http.MakeRequest(t, &expectLimitResp, gwAddr, "HTTP", "http") + + // should just send exactly 4 requests, and expect 429 with a Retry-After header on the last one + + // keep sending requests till get 200 first, that will cost one 200 + MakeRequestAndExpectEventuallyConsistentResponseExceptErrors(t, suite.RoundTripper, &suite.TimeoutConfig, gwAddr, &expectOkResp) + + // fire the rest of the requests + if err := GotExactExpectedResponseExceptErrors(t, 2, suite.RoundTripper, expectOkReq, expectOkResp); err != nil { + t.Errorf("failed to get expected response for the first three requests: %v", err) + } + if err := GotExactExpectedResponseExceptErrors(t, 1, suite.RoundTripper, expectLimitReq, expectLimitResp); err != nil { + t.Errorf("failed to get expected response for the last (fourth) request: %v", err) + } + }, +} + var RateLimitBasedJwtClaimsTest = suite.ConformanceTest{ ShortName: "RateLimitBasedJwtClaims", Description: "Limit based jwt claims", diff --git a/test/go.mod b/test/go.mod index af35fff27de..9a484edbdb9 100644 --- a/test/go.mod +++ b/test/go.mod @@ -114,7 +114,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908 // indirect github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260729145720-a2d8c7492908 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260729145720-a2d8c7492908 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369 // indirect github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260729145720-a2d8c7492908 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/ratelimit v1.4.1-0.20260122083618-3fb702589d36 // indirect diff --git a/test/go.sum b/test/go.sum index 6aa02db5712..3ba58b6796e 100644 --- a/test/go.sum +++ b/test/go.sum @@ -193,8 +193,8 @@ github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908 h1: github.com/envoyproxy/go-control-plane v0.14.1-0.20260729145720-a2d8c7492908/go.mod h1:rcDQvjX9fwNQvh7l9pxwfGBufBWU6xQ0zK5mcsH8/Lg= github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260729145720-a2d8c7492908 h1:+7FtQqQ7Yfo5dgsAyTAIe8OhmUtWUw6VjeQ3by2Xdm4= github.com/envoyproxy/go-control-plane/contrib v1.36.1-0.20260729145720-a2d8c7492908/go.mod h1:pdjA+146jsWRsJ0M1jgfRrvJq1HPWeyvaT/Va+nV2OY= -github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260729145720-a2d8c7492908 h1:yp1HVjmLFpwwN3Ik82LUTAZ6giMb6NAuhA3fralKvRQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260729145720-a2d8c7492908/go.mod h1:rYoRI6N8FcjZVmmeKqH8/ykAhfro9Bg8+of2IpBseyQ= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369 h1:ZJjjlzVZoUwYJtlGzPjVC+gbG/v16XUJRHE0pQ1FFz4= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260816030012-a266ce994369/go.mod h1:PYEOlng9XcrulfyWpm49jECTPV0LT4q8cO7fLW/xwgk= github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260729145720-a2d8c7492908 h1:dNsOk1pDOqRl4SzUdAWDPXQ05VASaD5CYYaVpfZr7os= github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260729145720-a2d8c7492908/go.mod h1:kFrXkz+a+gF/F56IcDXtBE0QfpZfFTyf92okqDzIBu0= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index f806aa6b276..fe69129b7cc 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -29022,6 +29022,11 @@ spec: EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests and responses. type: boolean + enableRetryAfterHeader: + description: |- + EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header + on rate-limited 429 responses. + type: boolean host: description: Host enables managing how the Host/Authority header set by clients can be normalized. diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index 4820e230a85..03a04bc1f52 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -4960,6 +4960,11 @@ spec: EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests and responses. type: boolean + enableRetryAfterHeader: + description: |- + EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header + on rate-limited 429 responses. + type: boolean host: description: Host enables managing how the Host/Authority header set by clients can be normalized. diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index 1a13a748173..1ee27d05e24 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -4960,6 +4960,11 @@ spec: EnableEnvoyHeaders configures Envoy Proxy to add the "X-Envoy-" headers to requests and responses. type: boolean + enableRetryAfterHeader: + description: |- + EnableRetryAfterHeader configures Envoy Proxy to emit the "Retry-After" response header + on rate-limited 429 responses. + type: boolean host: description: Host enables managing how the Host/Authority header set by clients can be normalized.