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.