diff --git a/api/v1alpha/httpproxy_types.go b/api/v1alpha/httpproxy_types.go
index dac3aceb..5e4c45c0 100644
--- a/api/v1alpha/httpproxy_types.go
+++ b/api/v1alpha/httpproxy_types.go
@@ -266,8 +266,23 @@ type HTTPProxyRule struct {
// +kubebuilder:validation:MinItems=0
// +kubebuilder:validation:MaxItems=16
Backends []HTTPProxyRuleBackend `json:"backends,omitempty"`
+
+ // Timeouts bound how long a request matching this rule may take.
+ //
+ // The request timeout runs from the request reaching the edge until the
+ // last byte of the response is sent, so a streaming response or a large
+ // download must finish within it. It defaults to one hour, and the operator
+ // caps it at a configured maximum. It cannot be disabled.
+ //
+ // See documentation for the `timeouts` field in the `HTTPRouteRule` type at
+ // https://gateway-api.sigs.k8s.io/reference/spec/#httprouterule
+ //
+ // +kubebuilder:default={request: "1h"}
+ Timeouts *gatewayv1.HTTPRouteTimeouts `json:"timeouts,omitempty"`
}
+const HTTPProxyDefaultRequestTimeout = "1h"
+
// +kubebuilder:validation:XValidation:message="endpoint is required unless instance or networkService is set; instance and networkService are mutually exclusive with each other and with endpoint and connector",rule="has(self.instance) ? (!has(self.endpoint) && !has(self.connector) && !has(self.networkService)) : (has(self.networkService) ? (!has(self.endpoint) && !has(self.connector)) : has(self.endpoint))"
// +kubebuilder:validation:XValidation:message="backend TLS is not supported for networkService backends",rule="has(self.networkService) ? !has(self.tls) : true"
type HTTPProxyRuleBackend struct {
diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go
index 7da540a5..77bca37a 100644
--- a/api/v1alpha/zz_generated.deepcopy.go
+++ b/api/v1alpha/zz_generated.deepcopy.go
@@ -561,6 +561,11 @@ func (in *HTTPProxyRule) DeepCopyInto(out *HTTPProxyRule) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
+ if in.Timeouts != nil {
+ in, out := &in.Timeouts, &out.Timeouts
+ *out = new(apisv1.HTTPRouteTimeouts)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPProxyRule.
diff --git a/claude-plugin/skills/alb/SKILL.md b/claude-plugin/skills/alb/SKILL.md
index 8fc0864c..71d10f85 100644
--- a/claude-plugin/skills/alb/SKILL.md
+++ b/claude-plugin/skills/alb/SKILL.md
@@ -20,7 +20,7 @@ An Application Load Balancer is a **product built from several objects**, not a
- Add routes and the origins behind them, by URL or by network service
- Split a route's traffic across several origins by weight, choose the load balancing algorithm, and turn on passive health checks
- Configure traffic protection (WAF), request headers and basic authentication
-- Raise the 15-second response limit for streams and large downloads
+- Set how long a response may run, up to an hour, per route
- Read access logs for one load balancer
- Preview every change before applying it
- Attach advanced Envoy Gateway policies the plugin does not cover
@@ -233,43 +233,36 @@ Datum attaches a `BackendTrafficPolicy` of its own to the load balancer's routin
A Gateway takes one `BackendTrafficPolicy`. Put rate limiting, circuit breaking and the response limit below in the same policy.
-### Responses that run longer than 15 seconds
+### Responses that run longer than an hour
-A response must finish within about 15 seconds of the request reaching the edge. After that the edge ends it:
+Every route rule carries `timeouts.request`: the time from the request reaching the edge to the last byte of the response. It defaults to `1h`, so streams, large downloads and slow APIs run for up to an hour before the edge ends them:
- If the origin has not started answering, the client gets a `504`.
-- If the origin has started, the response is cut off mid-transfer with whatever status it began with. Large downloads stop partway, and Server-Sent Events, LLM token streams, log tails and long polls close.
+- If the origin has started, the response is cut off mid-transfer with whatever status it began with.
-WebSockets are not cut. In `alb logs`, a cut response shows a `DURATION` of about 15000 ms.
+WebSockets are not cut. In `alb logs`, a cut response shows a `DURATION` equal to the rule's timeout.
-`HTTPProxy` has no timeout field. Raise the limit, up to `1h`, with `timeout.http.requestTimeout` on the load balancer's Gateway:
+The plugin does not set the timeout. Set it per rule in the `HTTPProxy` manifest, between `1ns` and `1h`. A lower value makes a hung origin fail fast; `0s` is refused, since it would also switch off the idle timeout and leave abandoned streams open:
```yaml
-apiVersion: gateway.envoyproxy.io/v1alpha1
-kind: BackendTrafficPolicy
+apiVersion: networking.datumapis.com/v1alpha
+kind: HTTPProxy
metadata:
name: my-app
namespace: default
spec:
- targetRefs:
- - group: gateway.networking.k8s.io
- kind: Gateway
- name: my-app
- timeout:
- http:
- requestTimeout: 1h
+ rules:
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /
+ backends:
+ - endpoint: https://origin.example.com
+ timeouts:
+ request: 10m
```
-**Target the Gateway, never the HTTPRoute.** An HTTPRoute takes one `BackendTrafficPolicy`, and Datum attaches its own to the HTTPRoute when the load balancer has a network service origin, an algorithm or health checks. The older of the two wins and the other is rejected as `Conflicted`:
-
-- If Datum's is older, yours does nothing and responses are still cut at 15 seconds.
-- If yours is older, Datum's is rejected and the load balancer silently loses its algorithm and health checks, while still reporting `Programmed`.
-
-A Gateway-level policy merges with Datum's, so both apply. Editing `timeouts` on the generated HTTPRoute does not work either: Datum rewrites that route from the `HTTPProxy` and the edit is reverted.
-
-`requestTimeout` covers everything from the request arriving to the last byte sent, so set it longer than the longest stream or download. A value above `1h` is rejected with `duration must be less than or equal to 1h0m0s`.
-
-To confirm it worked, check that `datumctl describe backendtrafficpolicy my-app` shows `Accepted` and not `Conflicted`, then make a request that runs past 15 seconds and check that it completes. To restore the default, delete the policy, or remove the `timeout` block if the policy carries other settings.
+A value above `1h` is rejected with `duration must be less than or equal to 1h0m0s`. A `BackendTrafficPolicy` `requestTimeout` does not change a load balancer's limit any more: the rule's timeout takes precedence. Remove one you attached for that purpose.
### Authentication beyond basic auth
@@ -302,7 +295,7 @@ Failures print a named exit code — `exit status 6 # ALB_INVALID`. The names ar
- The portal manages one route: the pool of origins on `/`, with weights, the algorithm and health checks. Those are the same fields `datumctl alb` writes, so either can change them. Once a load balancer has a second route, a path match or a per-origin filter, the portal locks its origins, weights, algorithm, health checks, TLS, redirect and Host override and points to `datumctl`; hostnames, protection and auth stay editable there. Tell the user to expect that lock after you add a route
- Traffic protection takes paranoia 1 to 4 here; the portal offers only 1 and 2
- Attach a `BackendTrafficPolicy` to the load balancer's Gateway, never its HTTPRoute. A route-level policy collides with the one Datum attaches for health checks and load balancing
-- Responses end after about 15 seconds unless a `BackendTrafficPolicy` raises the limit, up to 1 hour
+- Responses end after 1 hour by default. Lower it per rule with `timeouts.request` in the manifest; it cannot go above 1 hour or be switched off
- Run `datumctl diff -f` before `apply`, and validate with `--dry-run=server`
- `delete` has no confirmation prompt — verify the name first. Deleting a load balancer also removes its traffic protection policy and basic auth
- Start traffic protection in `mode: Observe` on a live site, watch real traffic, then move to `Enforce`. Raise paranoia one level at a time; above 2 the false-positive rate climbs sharply
diff --git a/claude-plugin/skills/httproute/SKILL.md b/claude-plugin/skills/httproute/SKILL.md
index 34472b20..35a559e7 100644
--- a/claude-plugin/skills/httproute/SKILL.md
+++ b/claude-plugin/skills/httproute/SKILL.md
@@ -334,7 +334,7 @@ datumctl apply -f header-injection.yaml --project my-project
- Use `sectionName` in `spec.parentRefs[]` to bind a route to a specific listener when a Gateway has both HTTP and HTTPS listeners; binding only to `http` is the standard pattern for redirect routes
- `gateway.networking.k8s.io/v1` is stable GA — unlike the `gateway.envoyproxy.io` policy CRDs it is not subject to breaking alpha changes
- Policy attachments from the alb skill (`SecurityPolicy`, `BackendTrafficPolicy`, `TrafficProtectionPolicy`) reference `HTTPRoute` resources by name via `targetRefs` — create the HTTPRoute first before attaching policies
-- This skill is for HTTPRoutes you write yourself. An Application Load Balancer's HTTPRoute is generated from its `HTTPProxy` and rewritten on every change, so edits to it, `timeouts` included, are reverted. To let a load balancer's responses run longer than 15 seconds, attach a `BackendTrafficPolicy` to its Gateway as the [alb skill](../alb/SKILL.md) describes
+- This skill is for HTTPRoutes you write yourself. An Application Load Balancer's HTTPRoute is generated from its `HTTPProxy` and rewritten on every change, so edits to it, `timeouts` included, are reverted. Set a load balancer's response limit with `timeouts.request` on the `HTTPProxy` rule instead, as the [alb skill](../alb/SKILL.md) describes
- Run `datumctl diff -f` before `apply` for any changes
- `--dry-run=server` validates the manifest against the API before committing
- `delete` has no confirmation prompt — always verify the resource name first
diff --git a/config/crd/bases/networking.datumapis.com_httpproxies.yaml b/config/crd/bases/networking.datumapis.com_httpproxies.yaml
index 0ae6b601..9708a34e 100644
--- a/config/crd/bases/networking.datumapis.com_httpproxies.yaml
+++ b/config/crd/bases/networking.datumapis.com_httpproxies.yaml
@@ -3458,6 +3458,76 @@ spec:
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
+ timeouts:
+ default:
+ request: 1h
+ description: |-
+ Timeouts bound how long a request matching this rule may take.
+
+ The request timeout runs from the request reaching the edge until the
+ last byte of the response is sent, so a streaming response or a large
+ download must finish within it. It defaults to one hour, and the operator
+ caps it at a configured maximum. It cannot be disabled.
+
+ See documentation for the `timeouts` field in the `HTTPRouteRule` type at
+ https://gateway-api.sigs.k8s.io/reference/spec/#httprouterule
+ properties:
+ backendRequest:
+ description: |-
+ BackendRequest specifies a timeout for an individual request from the gateway
+ to a backend. This covers the time from when the request first starts being
+ sent from the gateway to when the full response has been received from the backend.
+
+ Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout
+ completely. Implementations that cannot completely disable the timeout MUST
+ instead interpret the zero duration as the longest possible value to which
+ the timeout can be set.
+
+ An entire client HTTP transaction with a gateway, covered by the Request timeout,
+ may result in more than one call from the gateway to the destination backend,
+ for example, if automatic retries are supported.
+
+ The value of BackendRequest must be a Gateway API Duration string as defined by
+ GEP-2257. When this field is unspecified, its behavior is implementation-specific;
+ when specified, the value of BackendRequest must be no more than the value of the
+ Request timeout (since the Request timeout encompasses the BackendRequest timeout).
+
+ Support: Extended
+ pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$
+ type: string
+ request:
+ description: |-
+ Request specifies the maximum duration for a gateway to respond to an HTTP request.
+ If the gateway has not been able to respond before this deadline is met, the gateway
+ MUST return a timeout error.
+
+ For example, setting the `rules.timeouts.request` field to the value `10s` in an
+ `HTTPRoute` will cause a timeout if a client request is taking longer than 10 seconds
+ to complete.
+
+ Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout
+ completely. Implementations that cannot completely disable the timeout MUST
+ instead interpret the zero duration as the longest possible value to which
+ the timeout can be set.
+
+ This timeout is intended to cover as close to the whole request-response transaction
+ as possible although an implementation MAY choose to start the timeout after the entire
+ request stream has been received instead of immediately after the transaction is
+ initiated by the client.
+
+ The value of Request is a Gateway API Duration string as defined by GEP-2257. When this
+ field is unspecified, request timeout behavior is implementation-specific.
+
+ Support: Extended
+ pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$
+ type: string
+ type: object
+ x-kubernetes-validations:
+ - message: backendRequest timeout cannot be longer than request
+ timeout
+ rule: '!(has(self.request) && has(self.backendRequest) &&
+ duration(self.request) != duration(''0s'') && duration(self.backendRequest)
+ > duration(self.request))'
type: object
x-kubernetes-validations:
- message: RequestRedirect filter must not be used together with
diff --git a/docs/agent/skills/access-log-triage.md b/docs/agent/skills/access-log-triage.md
index ac011886..456abc6d 100644
--- a/docs/agent/skills/access-log-triage.md
+++ b/docs/agent/skills/access-log-triage.md
@@ -56,11 +56,12 @@ Then widen the window, or send a request yourself and look again.
is down, refusing connections, or too slow.
- `429`: rate limited.
-**Durations near 15 seconds** mean the response limit, not a slow origin. A
-response must finish within about 15 seconds of the request reaching the edge.
-A request whose origin had not started answering by then comes back `504`. One
-whose origin had started keeps its status, `200` included, and is cut off
-mid-transfer. So a `200` at about 15000 ms is a large download or a stream that
+**Durations at the route's timeout** mean the response limit, not a slow
+origin. A response must finish within the rule's `timeouts.request`, one hour
+by default, of the request reaching the edge. A request whose origin had not
+started answering by then comes back `504`. One whose origin had started keeps
+its status, `200` included, and is cut off mid-transfer. So a `200` at about
+3600000 ms, or at whatever the rule sets, is a large download or a stream that
was cut short, not a success. WebSockets are not cut.
The limit can be raised to at most one hour. `HTTPProxy` has no field for it.
diff --git a/docs/api/httpproxies.md b/docs/api/httpproxies.md
index c765aaad..be6ce4bb 100644
--- a/docs/api/httpproxies.md
+++ b/docs/api/httpproxies.md
@@ -235,6 +235,24 @@ https://gateway-api.sigs.k8s.io/reference/spec/#httprouterule
if it is set.
false |
+
+ | timeouts |
+ object |
+
+ Timeouts bound how long a request matching this rule may take.
+
+The request timeout runs from the request reaching the edge until the
+last byte of the response is sent, so a streaming response or a large
+download must finish within it. It defaults to one hour, and the operator
+caps it at a configured maximum. It cannot be disabled.
+
+See documentation for the `timeouts` field in the `HTTPRouteRule` type at
+https://gateway-api.sigs.k8s.io/reference/spec/#httprouterule
+
+ Validations:!(has(self.request) && has(self.backendRequest) && duration(self.request) != duration('0s') && duration(self.backendRequest) > duration(self.request)): backendRequest timeout cannot be longer than request timeout
+ Default: map[request:1h]
+ |
+ false |
@@ -4324,6 +4342,87 @@ documentation to determine the supported dialect.
+### HTTPProxy.spec.rules[index].timeouts
+[↩ Parent](#httpproxyspecrulesindex)
+
+
+
+Timeouts bound how long a request matching this rule may take.
+
+The request timeout runs from the request reaching the edge until the
+last byte of the response is sent, so a streaming response or a large
+download must finish within it. It defaults to one hour, and the operator
+caps it at a configured maximum. It cannot be disabled.
+
+See documentation for the `timeouts` field in the `HTTPRouteRule` type at
+https://gateway-api.sigs.k8s.io/reference/spec/#httprouterule
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | backendRequest |
+ string |
+
+ BackendRequest specifies a timeout for an individual request from the gateway
+to a backend. This covers the time from when the request first starts being
+sent from the gateway to when the full response has been received from the backend.
+
+Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout
+completely. Implementations that cannot completely disable the timeout MUST
+instead interpret the zero duration as the longest possible value to which
+the timeout can be set.
+
+An entire client HTTP transaction with a gateway, covered by the Request timeout,
+may result in more than one call from the gateway to the destination backend,
+for example, if automatic retries are supported.
+
+The value of BackendRequest must be a Gateway API Duration string as defined by
+GEP-2257. When this field is unspecified, its behavior is implementation-specific;
+when specified, the value of BackendRequest must be no more than the value of the
+Request timeout (since the Request timeout encompasses the BackendRequest timeout).
+
+Support: Extended
+ |
+ false |
+
+ | request |
+ string |
+
+ Request specifies the maximum duration for a gateway to respond to an HTTP request.
+If the gateway has not been able to respond before this deadline is met, the gateway
+MUST return a timeout error.
+
+For example, setting the `rules.timeouts.request` field to the value `10s` in an
+`HTTPRoute` will cause a timeout if a client request is taking longer than 10 seconds
+to complete.
+
+Setting a timeout to the zero duration (e.g. "0s") SHOULD disable the timeout
+completely. Implementations that cannot completely disable the timeout MUST
+instead interpret the zero duration as the longest possible value to which
+the timeout can be set.
+
+This timeout is intended to cover as close to the whole request-response transaction
+as possible although an implementation MAY choose to start the timeout after the entire
+request stream has been received instead of immediately after the transaction is
+initiated by the client.
+
+The value of Request is a Gateway API Duration string as defined by GEP-2257. When this
+field is unspecified, request timeout behavior is implementation-specific.
+
+Support: Extended
+ |
+ false |
+
+
+
+
### HTTPProxy.spec.healthCheck
[↩ Parent](#httpproxyspec)
diff --git a/internal/config/config.go b/internal/config/config.go
index c761ef17..c0b947be 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -2145,9 +2145,25 @@ func (c *GatewayConfig) validate() error {
}
errs = append(errs, c.validateAuthorityLossGracePeriod())
errs = append(errs, c.DownstreamClientTraffic.validate())
+ errs = append(errs, c.validateHTTPMaxRequestTimeout())
return errors.Join(errs...)
}
+func (c *GatewayConfig) validateHTTPMaxRequestTimeout() error {
+ max := c.ExtensionAPIValidationOptions.BackendTrafficPolicies.ClusterSettings.HTTPMaxRequestTimeout
+ if max == nil {
+ return nil
+ }
+ floor, err := time.ParseDuration(networkingv1alpha.HTTPProxyDefaultRequestTimeout)
+ if err != nil {
+ return err
+ }
+ if max.Duration < floor {
+ return fmt.Errorf("extensionAPIValidationOptions.backendTrafficPolicies.clusterSettings.httpMaxRequestTimeout %s is below the HTTPProxy request timeout default of %s, which every proxy without a timeout of its own carries", max.Duration, networkingv1alpha.HTTPProxyDefaultRequestTimeout)
+ }
+ return nil
+}
+
// validateAuthorityLossGracePeriod refuses a negative period, which would turn the hold off without saying so;
// zero is the documented way to turn it off.
func (c *GatewayConfig) validateAuthorityLossGracePeriod() error {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index e5f1cf51..67ce84e7 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -717,6 +717,31 @@ func TestGatewayConfig_ValidateALPNProtocols(t *testing.T) {
// An HTTP/2 window outside Envoy's range is refused at start: the edge would
// keep its defaults, and nothing the operator reads would say so.
+func TestGatewayConfig_ValidateHTTPMaxRequestTimeout(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ max *metav1.Duration
+ wantSub string
+ }{
+ {name: "unset"},
+ {name: "at the proxy default", max: &metav1.Duration{Duration: time.Hour}},
+ {name: "above the proxy default", max: &metav1.Duration{Duration: 2 * time.Hour}},
+ {name: "below the proxy default", max: &metav1.Duration{Duration: 10 * time.Minute}, wantSub: "httpMaxRequestTimeout 10m0s is below"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &NetworkServicesOperator{}
+ cfg.Gateway.ExtensionAPIValidationOptions.BackendTrafficPolicies.ClusterSettings.HTTPMaxRequestTimeout = tt.max
+ err := cfg.Validate()
+ if tt.wantSub == "" && err != nil {
+ t.Fatalf("expected nil, got %v", err)
+ }
+ if tt.wantSub != "" && (err == nil || !strings.Contains(err.Error(), tt.wantSub)) {
+ t.Fatalf("expected an error containing %q, got %v", tt.wantSub, err)
+ }
+ })
+ }
+}
+
func TestGatewayConfig_ValidateHTTP2Windows(t *testing.T) {
tests := []struct {
name, stream, connection, wantSub string
diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go
index a3746ab5..dd098720 100644
--- a/internal/controller/httpproxy_controller.go
+++ b/internal/controller/httpproxy_controller.go
@@ -275,7 +275,7 @@ func (r *HTTPProxyReconciler) Reconcile(ctx context.Context, req mcreconcile.Req
return ctrl.Result{}, addErr
}
- if errs := validation.ValidateHTTPProxy(&httpProxy, validation.HTTPProxyValidationOptions{Hostnames: validation.CustomHostnameOptions(r.Config.Gateway)}); len(errs) > 0 {
+ if errs := validation.ValidateHTTPProxy(&httpProxy, validation.HTTPProxyOptions(r.Config.Gateway)); len(errs) > 0 {
acceptedCondition.Status = metav1.ConditionFalse
acceptedCondition.Reason = networkingv1alpha.HTTPProxyReasonInvalid
acceptedCondition.Message = fmt.Sprintf("The HTTPProxy is invalid and cannot be programmed: %s", errs.ToAggregate())
@@ -1370,6 +1370,7 @@ func (r *HTTPProxyReconciler) collectDesiredResources(
Matches: rule.Matches,
Filters: ruleFilters,
BackendRefs: backendRefs,
+ Timeouts: rule.Timeouts,
}
}
diff --git a/internal/controller/httpproxy_controller_test.go b/internal/controller/httpproxy_controller_test.go
index 964bd309..7012677e 100644
--- a/internal/controller/httpproxy_controller_test.go
+++ b/internal/controller/httpproxy_controller_test.go
@@ -92,6 +92,18 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) {
}
},
},
+ {
+ name: "rule timeouts reach the route",
+ httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) {
+ h.Spec.Rules[0].Timeouts = &gatewayv1.HTTPRouteTimeouts{
+ Request: ptr.To(gatewayv1.Duration("30m")),
+ BackendRequest: ptr.To(gatewayv1.Duration("5m")),
+ }
+ }),
+ assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) {
+ assert.Equal(t, httpProxy.Spec.Rules[0].Timeouts, desiredResources.httpRoute.Spec.Rules[0].Timeouts)
+ },
+ },
{
name: "no URLRewrite filter in rule with hostname in endpoint",
httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) {
diff --git a/internal/validation/httpproxy_validation.go b/internal/validation/httpproxy_validation.go
index 6d29c3fd..db1117fb 100644
--- a/internal/validation/httpproxy_validation.go
+++ b/internal/validation/httpproxy_validation.go
@@ -14,6 +14,7 @@ import (
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+ "go.datum.net/network-services-operator/internal/config"
gatewayutil "go.datum.net/network-services-operator/internal/util/gateway"
)
@@ -26,6 +27,30 @@ const (
// HTTPProxy may declare.
type HTTPProxyValidationOptions struct {
Hostnames HostnameOptions
+
+ // MaxRequestTimeout caps every rule's request and backendRequest timeouts.
+ // Zero leaves them uncapped.
+ MaxRequestTimeout time.Duration
+}
+
+func HTTPProxyOptions(gatewayConfig config.GatewayConfig) HTTPProxyValidationOptions {
+ opts := HTTPProxyValidationOptions{Hostnames: CustomHostnameOptions(gatewayConfig)}
+ if max := gatewayConfig.ExtensionAPIValidationOptions.BackendTrafficPolicies.ClusterSettings.HTTPMaxRequestTimeout; max != nil {
+ opts.MaxRequestTimeout = max.Duration
+ }
+ return opts
+}
+
+func DefaultHTTPProxyRuleTimeouts(httpProxy *networkingv1alpha.HTTPProxy) {
+ for i := range httpProxy.Spec.Rules {
+ rule := &httpProxy.Spec.Rules[i]
+ if rule.Timeouts == nil {
+ rule.Timeouts = &gatewayv1.HTTPRouteTimeouts{}
+ }
+ if rule.Timeouts.Request == nil {
+ rule.Timeouts.Request = ptr.To(gatewayv1.Duration(networkingv1alpha.HTTPProxyDefaultRequestTimeout))
+ }
+ }
}
func ValidateHTTPProxy(httpProxy *networkingv1alpha.HTTPProxy, opts HTTPProxyValidationOptions) field.ErrorList {
@@ -48,7 +73,7 @@ func ValidateHTTPProxy(httpProxy *networkingv1alpha.HTTPProxy, opts HTTPProxyVal
allErrs = append(allErrs, field.Invalid(field.NewPath("metadata", "name"), httpProxy.Name, msg))
}
- allErrs = append(allErrs, validateHTTPProxyRules(httpProxy, field.NewPath("spec", "rules"))...)
+ allErrs = append(allErrs, validateHTTPProxyRules(httpProxy, field.NewPath("spec", "rules"), opts)...)
allErrs = append(allErrs, validateHTTPProxyHealthCheck(httpProxy.Spec.HealthCheck, field.NewPath("spec", "healthCheck"))...)
return allErrs
@@ -115,22 +140,45 @@ func validateHostHeaderOverride(filters []gatewayv1.HTTPRouteFilter, fldPath *fi
return validateProgrammableHostname(override.Value, hostHeaderPath)
}
-func validateHTTPProxyRules(httpProxy *networkingv1alpha.HTTPProxy, fldPath *field.Path) field.ErrorList {
+func validateHTTPProxyRules(httpProxy *networkingv1alpha.HTTPProxy, fldPath *field.Path, opts HTTPProxyValidationOptions) field.ErrorList {
allErrs := field.ErrorList{}
for i, rule := range httpProxy.Spec.Rules {
- allErrs = append(allErrs, validateHTTPProxyRule(rule, fldPath.Index(i))...)
+ allErrs = append(allErrs, validateHTTPProxyRule(rule, fldPath.Index(i), opts)...)
}
return allErrs
}
-func validateHTTPProxyRule(rule networkingv1alpha.HTTPProxyRule, fldPath *field.Path) field.ErrorList {
+func validateHTTPProxyRule(rule networkingv1alpha.HTTPProxyRule, fldPath *field.Path, opts HTTPProxyValidationOptions) field.ErrorList {
allErrs := field.ErrorList{}
allErrs = append(allErrs, validateFilters(rule.Filters, supportedHTTPRouteRuleFilters, fldPath.Child("filters"))...)
allErrs = append(allErrs, validateHostHeaderOverride(rule.Filters, fldPath.Child("filters"))...)
allErrs = append(allErrs, validateHTTPProxyRuleBackends(rule, fldPath.Child("backends"))...)
+ allErrs = append(allErrs, validateHTTPProxyRuleTimeouts(rule.Timeouts, fldPath.Child("timeouts"), opts.MaxRequestTimeout)...)
+
+ return allErrs
+}
+
+func validateHTTPProxyRuleTimeouts(timeouts *gatewayv1.HTTPRouteTimeouts, fldPath *field.Path, maxRequestTimeout time.Duration) field.ErrorList {
+ if timeouts == nil {
+ return nil
+ }
+
+ var upperLimit *time.Duration
+ if maxRequestTimeout > 0 {
+ upperLimit = &maxRequestTimeout
+ }
+ lowerLimit := ptr.To(time.Nanosecond)
+
+ allErrs := field.ErrorList{}
+ if timeouts.Request != nil {
+ allErrs = append(allErrs, validateGatewayDuration(fldPath.Child("request"), timeouts.Request, lowerLimit, upperLimit)...)
+ }
+ if timeouts.BackendRequest != nil {
+ allErrs = append(allErrs, validateGatewayDuration(fldPath.Child("backendRequest"), timeouts.BackendRequest, lowerLimit, upperLimit)...)
+ }
return allErrs
}
diff --git a/internal/validation/httpproxy_validation_test.go b/internal/validation/httpproxy_validation_test.go
index ad640abf..d2c2a543 100644
--- a/internal/validation/httpproxy_validation_test.go
+++ b/internal/validation/httpproxy_validation_test.go
@@ -3,6 +3,7 @@ package validation
import (
"strings"
"testing"
+ "time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -757,6 +758,108 @@ func TestValidateHTTPProxy(t *testing.T) {
}
}
+func TestValidateHTTPProxyTimeouts(t *testing.T) {
+ timeoutsPath := rulePath().Child("timeouts")
+ scenarios := map[string]struct {
+ timeouts *gatewayv1.HTTPRouteTimeouts
+ expectedErrors field.ErrorList
+ }{
+ "unset": {
+ expectedErrors: field.ErrorList{},
+ },
+ "within the maximum": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{
+ Request: ptr.To(gatewayv1.Duration("30m")),
+ BackendRequest: ptr.To(gatewayv1.Duration("5m")),
+ },
+ expectedErrors: field.ErrorList{},
+ },
+ "at the maximum": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{Request: ptr.To(gatewayv1.Duration("1h"))},
+ expectedErrors: field.ErrorList{},
+ },
+ "request above the maximum": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{Request: ptr.To(gatewayv1.Duration("2h"))},
+ expectedErrors: field.ErrorList{
+ field.Invalid(timeoutsPath.Child("request"), "", ""),
+ },
+ },
+ "backendRequest above the maximum": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{BackendRequest: ptr.To(gatewayv1.Duration("90m"))},
+ expectedErrors: field.ErrorList{
+ field.Invalid(timeoutsPath.Child("backendRequest"), "", ""),
+ },
+ },
+ "zero disables the timeout and is refused": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{
+ Request: ptr.To(gatewayv1.Duration("0s")),
+ BackendRequest: ptr.To(gatewayv1.Duration("0s")),
+ },
+ expectedErrors: field.ErrorList{
+ field.Invalid(timeoutsPath.Child("request"), "", ""),
+ field.Invalid(timeoutsPath.Child("backendRequest"), "", ""),
+ },
+ },
+ }
+
+ for name, scenario := range scenarios {
+ t.Run(name, func(t *testing.T) {
+ proxy := &networkingv1alpha.HTTPProxy{
+ ObjectMeta: metav1.ObjectMeta{Name: "test"},
+ Spec: networkingv1alpha.HTTPProxySpec{
+ Rules: []networkingv1alpha.HTTPProxyRule{
+ {
+ Backends: []networkingv1alpha.HTTPProxyRuleBackend{{Endpoint: "http://example.com"}},
+ Timeouts: scenario.timeouts,
+ },
+ },
+ },
+ }
+ errs := ValidateHTTPProxy(proxy, HTTPProxyValidationOptions{MaxRequestTimeout: time.Hour})
+ delta := cmp.Diff(scenario.expectedErrors, errs, cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail"))
+ if delta != "" {
+ t.Errorf("expected errors '%v', got '%v', diff: '%v'", scenario.expectedErrors, errs, delta)
+ }
+ })
+ }
+}
+
+func TestDefaultHTTPProxyRuleTimeouts(t *testing.T) {
+ scenarios := map[string]struct {
+ timeouts *gatewayv1.HTTPRouteTimeouts
+ expected *gatewayv1.HTTPRouteTimeouts
+ }{
+ "unset gets the default": {
+ expected: &gatewayv1.HTTPRouteTimeouts{Request: ptr.To(gatewayv1.Duration("1h"))},
+ },
+ "only backendRequest set gets the default request": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{BackendRequest: ptr.To(gatewayv1.Duration("5m"))},
+ expected: &gatewayv1.HTTPRouteTimeouts{
+ Request: ptr.To(gatewayv1.Duration("1h")),
+ BackendRequest: ptr.To(gatewayv1.Duration("5m")),
+ },
+ },
+ "request set is kept": {
+ timeouts: &gatewayv1.HTTPRouteTimeouts{Request: ptr.To(gatewayv1.Duration("30s"))},
+ expected: &gatewayv1.HTTPRouteTimeouts{Request: ptr.To(gatewayv1.Duration("30s"))},
+ },
+ }
+
+ for name, scenario := range scenarios {
+ t.Run(name, func(t *testing.T) {
+ proxy := &networkingv1alpha.HTTPProxy{
+ Spec: networkingv1alpha.HTTPProxySpec{
+ Rules: []networkingv1alpha.HTTPProxyRule{{Timeouts: scenario.timeouts}},
+ },
+ }
+ DefaultHTTPProxyRuleTimeouts(proxy)
+ if delta := cmp.Diff(scenario.expected, proxy.Spec.Rules[0].Timeouts); delta != "" {
+ t.Errorf("unexpected timeouts, diff: %s", delta)
+ }
+ })
+ }
+}
+
func rulePath() *field.Path {
return field.NewPath("spec", "rules").Index(0)
}
diff --git a/internal/webhook/v1alpha/httpproxy_mutating_test.go b/internal/webhook/v1alpha/httpproxy_mutating_test.go
index 0a74e3a9..d48a1e9b 100644
--- a/internal/webhook/v1alpha/httpproxy_mutating_test.go
+++ b/internal/webhook/v1alpha/httpproxy_mutating_test.go
@@ -45,3 +45,27 @@ func TestHTTPProxyCustomDefaulter_Default(t *testing.T) {
require.NoError(t, (&HTTPProxyCustomDefaulter{}).Default(context.Background(), proxy))
assert.Equal(t, "Test ALB", proxy.Annotations[display.AnnotationDisplayName])
}
+
+func TestHTTPProxyCustomDefaulter_DefaultsRuleRequestTimeout(t *testing.T) {
+ t.Parallel()
+
+ proxy := &networkingv1alpha.HTTPProxy{
+ ObjectMeta: metav1.ObjectMeta{Name: "alb", Namespace: "proj"},
+ Spec: networkingv1alpha.HTTPProxySpec{
+ Rules: []networkingv1alpha.HTTPProxyRule{
+ {
+ Backends: []networkingv1alpha.HTTPProxyRuleBackend{{Endpoint: "https://origin.example.com"}},
+ Timeouts: &gatewayv1.HTTPRouteTimeouts{BackendRequest: ptr.To(gatewayv1.Duration("5m"))},
+ },
+ {
+ Backends: []networkingv1alpha.HTTPProxyRuleBackend{{Endpoint: "https://origin.example.com"}},
+ },
+ },
+ },
+ }
+
+ require.NoError(t, (&HTTPProxyCustomDefaulter{}).Default(context.Background(), proxy))
+ assert.Equal(t, gatewayv1.Duration("1h"), ptr.Deref(proxy.Spec.Rules[0].Timeouts.Request, ""))
+ assert.Equal(t, gatewayv1.Duration("5m"), ptr.Deref(proxy.Spec.Rules[0].Timeouts.BackendRequest, ""))
+ assert.Equal(t, gatewayv1.Duration("1h"), ptr.Deref(proxy.Spec.Rules[1].Timeouts.Request, ""))
+}
diff --git a/internal/webhook/v1alpha/httpproxy_webhook.go b/internal/webhook/v1alpha/httpproxy_webhook.go
index 13db962c..12b5e5fc 100644
--- a/internal/webhook/v1alpha/httpproxy_webhook.go
+++ b/internal/webhook/v1alpha/httpproxy_webhook.go
@@ -24,7 +24,7 @@ import (
// SetupHTTPProxyWebhookWithManager registers the webhook for HTTPProxy in the manager.
func SetupHTTPProxyWebhookWithManager(mgr mcmanager.Manager, gatewayConfig config.GatewayConfig) error {
return ctrl.NewWebhookManagedBy(mgr.GetLocalManager(), &networkingv1alpha.HTTPProxy{}).
- WithValidator(&HTTPProxyCustomValidator{mgr: mgr, opts: validation.HTTPProxyValidationOptions{Hostnames: validation.CustomHostnameOptions(gatewayConfig)}}).
+ WithValidator(&HTTPProxyCustomValidator{mgr: mgr, opts: validation.HTTPProxyOptions(gatewayConfig)}).
WithDefaulter(&HTTPProxyCustomDefaulter{}).
Complete()
}
@@ -37,6 +37,7 @@ var _ admission.Defaulter[*networkingv1alpha.HTTPProxy] = &HTTPProxyCustomDefaul
func (d *HTTPProxyCustomDefaulter) Default(ctx context.Context, httpProxy *networkingv1alpha.HTTPProxy) error {
_ = display.EnsureHTTPProxyAnnotations(httpProxy, oldHTTPProxy(ctx))
+ validation.DefaultHTTPProxyRuleTimeouts(httpProxy)
return nil
}
diff --git a/test/e2e/httpproxy-response-timeout/chainsaw-test.yaml b/test/e2e/httpproxy-response-timeout/chainsaw-test.yaml
new file mode 100644
index 00000000..a5284bec
--- /dev/null
+++ b/test/e2e/httpproxy-response-timeout/chainsaw-test.yaml
@@ -0,0 +1,347 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json
+#
+# A response through an HTTPProxy may run longer than Envoy's 15-second default
+# route timeout (issue #459).
+#
+# The generated HTTPRoute carried no timeouts, so Envoy applied its own default
+# and cut every response still in flight at 15 seconds: large downloads,
+# server-sent events, long polls. The rule now carries a request timeout that
+# defaults to one hour and that the user can set.
+#
+# The origin is go-httpbin on the downstream cluster. Its /drip endpoint sends
+# one byte at a time over the requested duration, so a 20-second drip through
+# the edge is the exact request the default used to cut.
+#
+# The scenario proves the field is in the path from both sides: a proxy with
+# the default completes the 20-second drip, and a proxy whose rule sets a
+# 5-second request timeout has the same drip cut. Without the second case a
+# timeout that Envoy ignored would pass the first.
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: httpproxy-response-timeout
+spec:
+ cluster: nso-standard
+ timeouts:
+ cleanup: 120s
+ steps:
+ - name: Deploy a slow origin on the downstream cluster
+ try:
+ - create:
+ cluster: nso-infra
+ resource:
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: drip-origin
+ namespace: default
+ spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: drip-origin
+ template:
+ metadata:
+ labels:
+ app: drip-origin
+ spec:
+ containers:
+ - name: go-httpbin
+ image: ghcr.io/mccutchen/go-httpbin:2.18.1
+ command: ["/bin/go-httpbin"]
+ args:
+ - -host
+ - "0.0.0.0"
+ - -port
+ - "8080"
+ - -max-duration
+ - 60s
+ ports:
+ - containerPort: 8080
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 100m
+ memory: 128Mi
+ - create:
+ cluster: nso-infra
+ resource:
+ apiVersion: v1
+ kind: Service
+ metadata:
+ name: drip-origin
+ namespace: default
+ spec:
+ selector:
+ app: drip-origin
+ ports:
+ - port: 8080
+ targetPort: 8080
+ - assert:
+ cluster: nso-infra
+ timeout: 120s
+ resource:
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: drip-origin
+ namespace: default
+ status:
+ availableReplicas: 1
+
+ - name: Create a proxy with the default timeout and one with a short timeout
+ try:
+ - script:
+ cluster: nso-standard
+ skipCommandOutput: true
+ skipLogOutput: true
+ content: |
+ kubectl get ns $NAMESPACE -o json
+ outputs:
+ - name: downstreamNamespaceName
+ value: (join('-', ['ns', json_parse($stdout).metadata.uid]))
+ - create:
+ cluster: nso-standard
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: default-timeout
+ spec:
+ rules:
+ - backends:
+ - endpoint: http://drip-origin.default.svc.cluster.local:8080
+ - create:
+ cluster: nso-standard
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: short-timeout
+ spec:
+ rules:
+ - backends:
+ - endpoint: http://drip-origin.default.svc.cluster.local:8080
+ timeouts:
+ request: 5s
+ - create:
+ cluster: nso-standard
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: partial-timeout
+ spec:
+ rules:
+ - backends:
+ - endpoint: http://drip-origin.default.svc.cluster.local:8080
+ timeouts:
+ backendRequest: 5m
+ - assert:
+ cluster: nso-standard
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: default-timeout
+ spec:
+ rules:
+ - timeouts:
+ request: 1h
+ - assert:
+ cluster: nso-standard
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: partial-timeout
+ spec:
+ rules:
+ - timeouts:
+ request: 1h
+ backendRequest: 5m
+ - assert:
+ cluster: nso-standard
+ timeout: 120s
+ resource:
+ apiVersion: gateway.networking.k8s.io/v1
+ kind: HTTPRoute
+ metadata:
+ name: default-timeout
+ spec:
+ rules:
+ - timeouts:
+ request: 1h
+ - assert:
+ cluster: nso-infra
+ timeout: 180s
+ resource:
+ apiVersion: gateway.networking.k8s.io/v1
+ kind: HTTPRoute
+ metadata:
+ name: default-timeout
+ namespace: ($downstreamNamespaceName)
+ spec:
+ rules:
+ - timeouts:
+ request: 1h
+ - assert:
+ cluster: nso-infra
+ timeout: 180s
+ resource:
+ apiVersion: gateway.networking.k8s.io/v1
+ kind: HTTPRoute
+ metadata:
+ name: short-timeout
+ namespace: ($downstreamNamespaceName)
+ spec:
+ rules:
+ - timeouts:
+ request: 5s
+ - assert:
+ cluster: nso-standard
+ timeout: 300s
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: default-timeout
+ status:
+ (conditions[?type == 'Programmed']):
+ - status: "True"
+ - assert:
+ cluster: nso-standard
+ timeout: 300s
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: short-timeout
+ status:
+ (conditions[?type == 'Programmed']):
+ - status: "True"
+ catch:
+ - script:
+ cluster: nso-standard
+ content: |
+ kubectl -n $NAMESPACE get httpproxy,httproute,gateway -o yaml
+ kubectl -n network-services-operator-system logs -l app.kubernetes.io/name=network-services-operator --tail=100
+ - script:
+ cluster: nso-infra
+ env:
+ - name: DOWNSTREAM_NAMESPACE
+ value: ($downstreamNamespaceName)
+ content: |
+ kubectl -n $DOWNSTREAM_NAMESPACE get httproute,gateway,backendtrafficpolicy -o yaml
+
+ - name: A 20-second response completes with the default and is cut by a 5-second timeout
+ description: |
+ Both proxies front the same origin through the shared downstream Envoy
+ on the kind hostPort. The request is served once a GET returns 200, and
+ then one 20-second drip is sent through each proxy. The default proxy
+ delivers every byte; the short-timeout proxy loses the stream before
+ the last byte, which shows the timeout on the rule is what Envoy
+ enforces.
+ try:
+ - script:
+ cluster: nso-standard
+ skipCommandOutput: true
+ skipLogOutput: true
+ content: |
+ kubectl -n $NAMESPACE get httpproxy default-timeout -o jsonpath='{.status.canonicalHostname}'
+ outputs:
+ - name: defaultHostname
+ value: ($stdout)
+ - script:
+ cluster: nso-standard
+ skipCommandOutput: true
+ skipLogOutput: true
+ content: |
+ kubectl -n $NAMESPACE get httpproxy short-timeout -o jsonpath='{.status.canonicalHostname}'
+ outputs:
+ - name: shortHostname
+ value: ($stdout)
+ - script:
+ timeout: 300s
+ env:
+ - name: DEFAULT_HOSTNAME
+ value: ($defaultHostname)
+ - name: SHORT_HOSTNAME
+ value: ($shortHostname)
+ content: |
+ set -u
+ for host in "$DEFAULT_HOSTNAME" "$SHORT_HOSTNAME"; do
+ for i in $(seq 1 60); do
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
+ -H "Host: ${host}" http://localhost:30080/get) || code=000
+ echo "${host} attempt ${i}: HTTP ${code}"
+ [ "${code}" = "200" ] && break
+ sleep 3
+ done
+ [ "${code}" = "200" ] || { echo "${host} never answered 200"; exit 1; }
+ done
+
+ drip() {
+ curl -s -o /dev/null --max-time 40 \
+ -w 'http=%{http_code} bytes=%{size_download} seconds=%{time_total}\n' \
+ -H "Host: $1" 'http://localhost:30080/drip?duration=20s&numbytes=20&code=200'
+ }
+
+ echo "default timeout:"
+ out=$(drip "$DEFAULT_HOSTNAME"); rc=$?
+ echo " ${out} exit=${rc}"
+ case "${out}" in
+ *"http=200 bytes=20 "*) ;;
+ *) echo "the 20-second drip did not complete through the default proxy"; exit 1 ;;
+ esac
+
+ echo "5-second timeout:"
+ out=$(drip "$SHORT_HOSTNAME"); rc=$?
+ echo " ${out} exit=${rc}"
+ case "${out}" in
+ *"bytes=20 "*) echo "the 20-second drip completed despite a 5-second request timeout"; exit 1 ;;
+ esac
+ echo "ok"
+ catch:
+ - script:
+ cluster: nso-infra
+ content: |
+ kubectl -n datum-downstream-gateway get pods -o wide
+ kubectl -n datum-downstream-gateway logs -l gateway.envoyproxy.io/owning-gateway-namespace --all-containers --tail=100 || true
+
+ - name: A timeout above the maximum or of zero is refused at admission
+ try:
+ - apply:
+ cluster: nso-standard
+ expect:
+ - check:
+ ($error != null): true
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: timeout-too-long
+ spec:
+ rules:
+ - backends:
+ - endpoint: http://drip-origin.default.svc.cluster.local:8080
+ timeouts:
+ request: 2h
+ - apply:
+ cluster: nso-standard
+ expect:
+ - check:
+ ($error != null): true
+ resource:
+ apiVersion: networking.datumapis.com/v1alpha
+ kind: HTTPProxy
+ metadata:
+ name: timeout-disabled
+ spec:
+ rules:
+ - backends:
+ - endpoint: http://drip-origin.default.svc.cluster.local:8080
+ timeouts:
+ request: 0s