Skip to content

Commit 76223cd

Browse files
committed
feat: implement per-route timeout configuration
Add three user-facing timeouts configurable via InterceptorRoute CRD: request (disabled by default), responseHeader (300s default), and readiness (disabled by default, 30s when fallback is configured). Timeout errors now return 504 Gateway Timeout instead of 502 Bad Gateway. Changes: - Add timeouts spec to InterceptorRoute CRD and update CRD manifest - Refactor timeout config to derive internal timeouts from three user-facing ones - Propagate per-route timeouts through routing table and middleware - Simplify dial context to single-arg DialContextWithRetry - Cap dial retries at 1 minute when request timeout is disabled - Fix fallback not being reached when readiness timeout >= request timeout - Default readiness timeout to 30s when a fallback service is configured - Remove legacy per-transport env vars (breaking), deprecate old naming - Add unit tests for timeout config, routing, and upstream handler - Expand e2e timeout tests for the new per-route behavior - Delete unused pkg/k8s/annotations.go Signed-off-by: Vincent Link <[email protected]>
1 parent 86c86d9 commit 76223cd

27 files changed

Lines changed: 1039 additions & 310 deletions

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ This changelog keeps track of work items that have been completed and are ready
2727

2828
### Breaking Changes
2929

30+
- **Interceptor**: Change default timeout behavior: request timeout (`KEDA_HTTP_REQUEST_TIMEOUT`) defaults to `0` (disabled), response header timeout (`KEDA_RESPONSE_HEADER_TIMEOUT``KEDA_HTTP_RESPONSE_HEADER_TIMEOUT`) defaults to `300s` (was `500ms`), and readiness timeout (`KEDA_CONDITION_WAIT_TIMEOUT``KEDA_HTTP_READINESS_TIMEOUT`) defaults to `0` (disabled, was `20s`). Timeout errors return 504 instead of 502. ([#1474](https://github.com/kedacore/http-add-on/issues/1474))
3031
- **Interceptor**: Redesign interceptor metrics: `interceptor_request_count``interceptor_requests_total` (labels: `method`, `code`, `route_name`, `route_namespace`), `interceptor_pending_request_count``interceptor_pending_requests` (labels: `route_name`, `route_namespace`), added `interceptor_request_duration_seconds` histogram; `path` and `host` labels removed in favor of route identity via InterceptorRoute name/namespace to fix unbounded cardinality OOM issues; non-standard HTTP methods normalized to `_OTHER`; dashboards and alerting rules must be updated ([#1559](https://github.com/kedacore/http-add-on/issues/1559))
32+
- **Interceptor**: Remove `KEDA_HTTP_TLS_HANDSHAKE_TIMEOUT`, `KEDA_HTTP_EXPECT_CONTINUE_TIMEOUT`, `KEDA_HTTP_KEEP_ALIVE`, `KEDA_HTTP_IDLE_CONN_TIMEOUT`, and `KEDA_HTTP_DIAL_RETRY_TIMEOUT` environment variables; these now use Go's `DefaultTransport` defaults. ([#1474](https://github.com/kedacore/http-add-on/issues/1474))
3133

3234
### New
3335

3436
- **General**: Add `InterceptorRoute` CRD to separate routing/interceptor config from scaling config; `HTTPScaledObject` remains supported but will be deprecated in a future release ([#1501](https://github.com/kedacore/http-add-on/issues/1501))
37+
- **Interceptor**: Add per-route timeout configuration via InterceptorRoute `timeouts` spec with `request`, `responseHeader`, and `readiness` fields. When unset, global env var defaults are used. When a fallback service is configured and no readiness timeout is set, it defaults to 30s. ([#1474](https://github.com/kedacore/http-add-on/issues/1474))
3538

3639
### Improvements
3740

@@ -43,7 +46,7 @@ This changelog keeps track of work items that have been completed and are ready
4346

4447
### Deprecations
4548

46-
- **General**: TODO ([#TODO](https://github.com/kedacore/http-add-on/issues/TODO))
49+
- **Interceptor**: Deprecate `KEDA_CONDITION_WAIT_TIMEOUT` and `KEDA_RESPONSE_HEADER_TIMEOUT` environment variables in favor of `KEDA_HTTP_READINESS_TIMEOUT` and `KEDA_HTTP_RESPONSE_HEADER_TIMEOUT`. Old vars take precedence when set and log deprecation warnings. ([#1474](https://github.com/kedacore/http-add-on/issues/1474))
4750

4851
### Other
4952

config/crd/bases/http.keda.sh_interceptorroutes.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,31 @@ spec:
208208
x-kubernetes-validations:
209209
- message: exactly one of 'port' or 'portName' must be set
210210
rule: has(self.port) != has(self.portName)
211+
timeouts:
212+
description: Timeout configuration for request handling.
213+
properties:
214+
readiness:
215+
description: |-
216+
Time to wait for the backend to become ready (e.g. scale-from-zero).
217+
Unset: uses the global KEDA_HTTP_READINESS_TIMEOUT (default: disabled).
218+
Set to "0s" to disable the dedicated readiness deadline so the full
219+
request budget is available for cold starts. When a fallback service
220+
is configured and this is "0s", a 30s default is applied.
221+
type: string
222+
request:
223+
description: |-
224+
Total time allowed for the entire request lifecycle.
225+
Unset: uses the global KEDA_HTTP_REQUEST_TIMEOUT (default: disabled).
226+
Set to "0s" to disable the request deadline.
227+
type: string
228+
responseHeader:
229+
description: |-
230+
Max time to wait for the response headers from the backend after the
231+
request has been fully sent. Does not include cold-start wait time.
232+
Unset: uses the global KEDA_HTTP_RESPONSE_HEADER_TIMEOUT (default: 300s).
233+
Set to "0s" to disable the response header deadline.
234+
type: string
235+
type: object
211236
required:
212237
- scalingMetric
213238
- target

docs/integrations.md

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,13 @@ spec:
4040
4141
1. **Error: `context marked done while waiting for workload reach > 0 replicas`**
4242

43-
- This error indicates that the `KEDA_CONDITION_WAIT_TIMEOUT` value (default: 20 seconds) might be too low. The workload scaling process may not be complete within this timeframe.
44-
- To increase the timeout:
45-
- If using Helm, adjust the `interceptor.replicas.waitTimeout` parameter (see reference below).
46-
- Reference: [https://github.com/kedacore/charts/blob/main/http-add-on/values.yaml#L139](https://github.com/kedacore/charts/blob/main/http-add-on/values.yaml#L139)
47-
48-
2. **502 Errors with POST Requests:**
49-
50-
- You might encounter 502 errors during POST requests when the request is routed through the interceptor service. This could be due to insufficient timeout settings.
51-
- To adjust timeout parameters:
52-
- If using Helm, modify the following parameters (see reference below):
53-
- `KEDA_HTTP_CONNECT_TIMEOUT`
54-
- `KEDA_RESPONSE_HEADER_TIMEOUT`
55-
- `KEDA_HTTP_EXPECT_CONTINUE_TIMEOUT`
56-
- Reference: [https://github.com/kedacore/charts/blob/main/http-add-on/values.yaml#L152](https://github.com/kedacore/charts/blob/main/http-add-on/values.yaml#L152)
43+
- This error indicates that the readiness timeout might be too low. By default the readiness timeout is disabled (bounded only by the `request` timeout); when a fallback service is configured and no readiness timeout is set, it defaults to 30s.
44+
- Set the `readiness` timeout in the InterceptorRoute `timeouts` spec or `KEDA_HTTP_READINESS_TIMEOUT` to a value that gives the workload enough time to scale up.
45+
46+
2. **502/504 Errors with POST Requests:**
47+
48+
- You might encounter timeout errors during POST requests when the request is routed through the interceptor service. This could be due to insufficient timeout settings.
49+
- Increase `KEDA_HTTP_RESPONSE_HEADER_TIMEOUT` or set the `responseHeader` timeout in the InterceptorRoute `timeouts` spec.
5750

5851
3. **Immediate Scaling Down to Zero:**
5952
- If `minReplica` is set to 0 in the HTTPScaledObject, the application will immediately scale down to 0.

interceptor/config/timeouts.go

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,66 @@ import (
44
"time"
55

66
"github.com/caarlos0/env/v11"
7+
"github.com/go-logr/logr"
78
)
89

9-
// Timeouts is the configuration for connection and HTTP timeouts
10+
// Timeouts is the configuration for request handling and connection timeouts.
1011
type Timeouts struct {
11-
// Connect is the per-attempt TCP dial timeout (net.Dialer.Timeout)
12-
Connect time.Duration `env:"KEDA_HTTP_CONNECT_TIMEOUT" envDefault:"500ms"`
13-
// KeepAlive is the interval between keepalive probes
14-
KeepAlive time.Duration `env:"KEDA_HTTP_KEEP_ALIVE" envDefault:"1s"`
15-
// ResponseHeaderTimeout is how long to wait between when the HTTP request
16-
// is sent to the backing app and when response headers need to arrive
17-
ResponseHeader time.Duration `env:"KEDA_RESPONSE_HEADER_TIMEOUT" envDefault:"500ms"`
18-
// WorkloadReplicas is how long to wait for the backing workload
12+
// Request is the total wall-clock deadline from request arrival to response completion.
13+
// When 0 (the default), there is no total request deadline.
14+
Request time.Duration `env:"KEDA_HTTP_REQUEST_TIMEOUT" envDefault:"0s"`
15+
// ResponseHeader is how long to wait between when the HTTP request
16+
// is sent to the backing app and when response headers need to arrive.
17+
// Defaults to 300s as a safety net against hung backends. Set to 0 to disable.
18+
ResponseHeader time.Duration `env:"KEDA_HTTP_RESPONSE_HEADER_TIMEOUT" envDefault:"300s"`
19+
// Readiness is how long to wait for the backing workload
1920
// to have 1 or more replicas before connecting and sending the HTTP request.
20-
WorkloadReplicas time.Duration `env:"KEDA_CONDITION_WAIT_TIMEOUT" envDefault:"20s"`
21-
// ForceHTTP2 toggles whether to try to force HTTP2 for all requests
22-
ForceHTTP2 bool `env:"KEDA_HTTP_FORCE_HTTP2" envDefault:"false"`
21+
// When 0 (the default), the readiness wait is bounded only by the request
22+
// timeout, giving the full request budget to cold starts. When a fallback
23+
// service is configured and this is 0, a 30s default is applied.
24+
Readiness time.Duration `env:"KEDA_HTTP_READINESS_TIMEOUT" envDefault:"0s"`
25+
// Connect is the per-attempt TCP dial timeout (net.Dialer.Timeout).
26+
// Bounded by the request context deadline.
27+
Connect time.Duration `env:"KEDA_HTTP_CONNECT_TIMEOUT" envDefault:"500ms"`
28+
2329
// MaxIdleConns is the max number of idle connections to keep in the
2430
// interceptor's internal connection pool across all backend services.
2531
// Increase this if you proxy to many unique backend services.
2632
MaxIdleConns int `env:"KEDA_HTTP_MAX_IDLE_CONNS" envDefault:"1000"`
2733
// MaxIdleConnsPerHost is the max number of idle connections to keep per backend service.
2834
// Increase this if you observe many new connection establishments under load.
2935
MaxIdleConnsPerHost int `env:"KEDA_HTTP_MAX_IDLE_CONNS_PER_HOST" envDefault:"200"`
30-
// IdleConnTimeout is the timeout after which a connection in the interceptor's
31-
// internal connection pool will be closed
32-
IdleConnTimeout time.Duration `env:"KEDA_HTTP_IDLE_CONN_TIMEOUT" envDefault:"90s"`
33-
// TLSHandshakeTimeout is the max amount of time the interceptor will
34-
// wait to establish a TLS connection
35-
TLSHandshakeTimeout time.Duration `env:"KEDA_HTTP_TLS_HANDSHAKE_TIMEOUT" envDefault:"10s"`
36-
// ExpectContinueTimeout is the max amount of time the interceptor will wait
37-
// for a 100 Continue response from the backend after sending request headers
38-
// with Expect: 100-continue
39-
ExpectContinueTimeout time.Duration `env:"KEDA_HTTP_EXPECT_CONTINUE_TIMEOUT" envDefault:"1s"`
40-
// DialRetryTimeout caps the total time spent retrying failed dial attempts.
41-
DialRetryTimeout time.Duration `env:"KEDA_HTTP_DIAL_RETRY_TIMEOUT" envDefault:"15s"`
36+
// ForceHTTP2 toggles whether to try to force HTTP2 for all requests.
37+
ForceHTTP2 bool `env:"KEDA_HTTP_FORCE_HTTP2" envDefault:"false"`
38+
}
39+
40+
// deprecatedTimeouts holds deprecated env vars that take precedence when set.
41+
type deprecatedTimeouts struct {
42+
// ResponseHeader is how long to wait between when the HTTP request
43+
// is sent to the backing app and when response headers need to arrive.
44+
ResponseHeader time.Duration `env:"KEDA_RESPONSE_HEADER_TIMEOUT"`
45+
// WorkloadReplicas is how long to wait for the backing workload
46+
// to have 1 or more replicas before connecting and sending the HTTP request.
47+
WorkloadReplicas time.Duration `env:"KEDA_CONDITION_WAIT_TIMEOUT"`
4248
}
4349

44-
// MustParseTimeouts parses standard configs and returns the
45-
// newly created config. It panics if parsing fails.
46-
func MustParseTimeouts() Timeouts {
47-
return env.Must(env.ParseAs[Timeouts]())
50+
// MustParseTimeouts parses timeout configuration from environment variables.
51+
// Deprecated env vars take precedence over new ones when set, to preserve
52+
// existing behavior for users who haven't migrated yet.
53+
func MustParseTimeouts(log logr.Logger) Timeouts {
54+
cfg := env.Must(env.ParseAs[Timeouts]())
55+
56+
deprecated := env.Must(env.ParseAs[deprecatedTimeouts]())
57+
58+
if deprecated.WorkloadReplicas > 0 {
59+
log.Info("WARNING: KEDA_CONDITION_WAIT_TIMEOUT is deprecated, use KEDA_HTTP_READINESS_TIMEOUT instead")
60+
cfg.Readiness = deprecated.WorkloadReplicas
61+
}
62+
63+
if deprecated.ResponseHeader > 0 {
64+
log.Info("WARNING: KEDA_RESPONSE_HEADER_TIMEOUT is deprecated, use KEDA_HTTP_RESPONSE_HEADER_TIMEOUT instead")
65+
cfg.ResponseHeader = deprecated.ResponseHeader
66+
}
67+
68+
return cfg
4869
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/go-logr/logr"
8+
)
9+
10+
func TestMustParseTimeouts_DeprecatedOverride(t *testing.T) {
11+
t.Setenv("KEDA_HTTP_READINESS_TIMEOUT", "25s")
12+
t.Setenv("KEDA_CONDITION_WAIT_TIMEOUT", "31s")
13+
t.Setenv("KEDA_RESPONSE_HEADER_TIMEOUT", "7s")
14+
15+
cfg := MustParseTimeouts(logr.Discard())
16+
17+
if got, want := cfg.Readiness, 31*time.Second; got != want {
18+
t.Errorf("Readiness = %v, want %v (deprecated var should take precedence)", got, want)
19+
}
20+
if got, want := cfg.ResponseHeader, 7*time.Second; got != want {
21+
t.Errorf("ResponseHeader = %v, want %v (deprecated var should take precedence)", got, want)
22+
}
23+
}

interceptor/handler/upstream.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package handler
22

33
import (
4+
"context"
45
"errors"
6+
"net"
57
"net/http"
68
"net/http/httputil"
79
"time"
@@ -12,7 +14,6 @@ import (
1214

1315
"github.com/kedacore/http-add-on/interceptor/config"
1416
kedahttp "github.com/kedacore/http-add-on/pkg/http"
15-
"github.com/kedacore/http-add-on/pkg/k8s"
1617
"github.com/kedacore/http-add-on/pkg/util"
1718
)
1819

@@ -23,16 +24,16 @@ var (
2324
)
2425

2526
type Upstream struct {
26-
transportPool *kedahttp.TransportPool
27-
tracingCfg config.Tracing
28-
respHeaderTimeout time.Duration
27+
transportPool *kedahttp.TransportPool
28+
tracingCfg config.Tracing
29+
responseHeaderTimeout time.Duration
2930
}
3031

31-
func NewUpstream(baseTransport *http.Transport, tracingCfg config.Tracing, respHeaderTimeout time.Duration) *Upstream {
32+
func NewUpstream(baseTransport *http.Transport, tracingCfg config.Tracing, responseHeaderTimeout time.Duration) *Upstream {
3233
return &Upstream{
33-
transportPool: kedahttp.NewTransportPool(baseTransport),
34-
tracingCfg: tracingCfg,
35-
respHeaderTimeout: respHeaderTimeout,
34+
transportPool: kedahttp.NewTransportPool(baseTransport),
35+
tracingCfg: tracingCfg,
36+
responseHeaderTimeout: responseHeaderTimeout,
3637
}
3738
}
3839

@@ -59,16 +60,15 @@ func (uh *Upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5960
return
6061
}
6162

62-
respHeaderTimeout := uh.respHeaderTimeout
63-
// TODO(v1): remove timeout compatibility fallback for HTTPSO before v1 release
63+
// Select transport with per-route or global response header timeout.
64+
responseHeaderTimeout := uh.responseHeaderTimeout
6465
if ir := util.InterceptorRouteFromContext(ctx); ir != nil {
65-
if v, ok := ir.Annotations[k8s.AnnotationResponseHeaderTimeout]; ok {
66-
if d, err := time.ParseDuration(v); err == nil && d > 0 {
67-
respHeaderTimeout = d
68-
}
66+
if ir.Spec.Timeouts.ResponseHeader != nil {
67+
responseHeaderTimeout = ir.Spec.Timeouts.ResponseHeader.Duration
6968
}
7069
}
71-
transport := uh.transportPool.Get(respHeaderTimeout)
70+
71+
transport := uh.transportPool.Get(responseHeaderTimeout)
7272

7373
var rt http.RoundTripper = transport
7474
if uh.tracingCfg.Enabled {
@@ -99,7 +99,15 @@ func (uh *Upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
9999
BufferPool: bufferPool,
100100
Transport: rt,
101101
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
102-
sh := NewStatic(http.StatusBadGateway, err)
102+
code := http.StatusBadGateway
103+
var netErr net.Error
104+
if errors.As(err, &netErr) && netErr.Timeout() {
105+
// Respond with 504 Gateway Timeout on timeouts to differentiate from general server errors
106+
code = http.StatusGatewayTimeout
107+
} else if errors.Is(err, context.DeadlineExceeded) {
108+
code = http.StatusGatewayTimeout
109+
}
110+
sh := NewStatic(code, err)
103111
sh.ServeHTTP(w, r)
104112
},
105113
}

0 commit comments

Comments
 (0)