Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions gateway/configs/config-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@ connect_timeout_ms = 5000
server_header_transformation = "OVERWRITE"
server_header_value = "WSO2 API Platform"

# HTTP Connection Manager (downstream) timeouts
# A value of zero disables any of these timeout settings.
[router.http_listener.timeouts]
# Max duration for the entire downstream request
request_timeout = "0s"
# Max duration to receive the complete request headers
request_headers_timeout = "0s"
# Idle timeout for a single HTTP stream/request
stream_idle_timeout = "5m"
# Idle timeout for the downstream connection
idle_timeout = "1h"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[router.policy_engine]
host = "policy-engine"
port = 9001
Expand Down
37 changes: 37 additions & 0 deletions gateway/gateway-controller/api/management-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4194,6 +4194,8 @@ components:
description: List of API-level policies applied to all operations unless overridden
items:
$ref: "#/components/schemas/Policy"
resilience:
$ref: "#/components/schemas/Resilience"
operations:
type: array
description: List of HTTP operations/routes
Expand Down Expand Up @@ -4258,6 +4260,25 @@ components:
pattern: '^\d+(\.\d+)?(ms|s|m|h)$'
example: 5s

Resilience:
type: object
description: >
Backend/route timeout configuration. Maps to Envoy RouteAction timeouts.
Can be set at the API level (applies to all routes) and/or the operation level
(applies to that operation's route). When set at both levels, the operation-level
value takes precedence. When unset, the gateway's global route timeout defaults apply.
properties:
timeout:
type: string
description: Maximum time for the entire route (request to upstream response). "0s" disables the timeout.
pattern: '^\d+(\.\d+)?(ms|s|m|h)$'
example: 15s
idleTimeout:
type: string
description: Per-route stream idle timeout (overrides the listener stream idle timeout for this route). "0s" disables the timeout.
pattern: '^\d+(\.\d+)?(ms|s|m|h)$'
example: 0s

Upstream:
type: object
oneOf:
Expand Down Expand Up @@ -4313,6 +4334,8 @@ components:
description: List of policies applied only to this operation (overrides or adds to API-level policies)
items:
$ref: "#/components/schemas/Policy"
resilience:
$ref: "#/components/schemas/Resilience"

Policy:
type: object
Expand Down Expand Up @@ -5795,6 +5818,13 @@ components:
enum: [deployed, undeployed]
default: deployed
example: deployed
resilience:
$ref: '#/components/schemas/Resilience'
description: >
API-level backend/route timeout configuration. Applies to all routes generated
for this LLM Provider (the routes that forward traffic upstream). Supported at the
API level only - LLM routes are synthesized by the gateway, so there is no
operation-level override.

UpstreamAuth:
type: object
Expand Down Expand Up @@ -6085,6 +6115,13 @@ components:
enum: [deployed, undeployed]
default: deployed
example: deployed
resilience:
$ref: '#/components/schemas/Resilience'
description: >
API-level backend/route timeout configuration. Applies to all routes generated
for this LLM Proxy (the routes that forward traffic upstream). Supported at the
API level only - LLM routes are synthesized by the gateway, so there is no
operation-level override.

SecretConfigurationRequest:
type: object
Expand Down
575 changes: 300 additions & 275 deletions gateway/gateway-controller/pkg/api/management/generated.go

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions gateway/gateway-controller/pkg/config/api_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"time"

api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants"
)

// APIValidator validates API configurations using rule-based validation
Expand Down Expand Up @@ -436,12 +437,63 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr
errors = append(errors, v.validateUpstream("sandbox", spec.Upstream.Sandbox, spec.UpstreamDefinitions)...)
}

// Validate API-level resilience block
errors = append(errors, v.validateResilience("spec.resilience", spec.Resilience)...)

// Validate operations
errors = append(errors, v.validateOperations(spec.Operations)...)

return errors
}

// validateResilience validates a resilience block (timeout / idleTimeout). Both fields
// are optional duration strings; "0s" is allowed (disables the timeout), negative and
// malformed values are rejected. fieldPrefix is the path to the block (e.g.
// "spec.resilience" or "spec.operations[2].resilience").
func (v *APIValidator) validateResilience(fieldPrefix string, r *api.Resilience) []ValidationError {
return validateResilienceTimeouts(fieldPrefix, r)
}

// validateResilienceTimeouts validates the timeout fields of a resilience block.
func validateResilienceTimeouts(fieldPrefix string, r *api.Resilience) []ValidationError {
var errors []ValidationError
if r == nil {
return errors
}

validate := func(field string, value *string) {
if value == nil {
return
}
s := strings.TrimSpace(*value)
if s == "" {
return
}
// Enforce the same single-unit format as the CRD admission controller (see
// constants.ResilienceDurationPattern). This rejects compound durations ("1h30m"),
// negatives ("-30s"), and unitless values ("0", "30"), while accepting "0s" to disable.
if !constants.ResilienceDurationRegex.MatchString(s) {
errors = append(errors, ValidationError{
Field: field,
Message: "Invalid timeout format (expected a single-unit duration like '30s', '1m', '500ms', or '0s' to disable; compound, negative, and unitless values are not allowed)",
})
return
}
// The pattern guarantees a parseable, non-negative, single-unit value; ParseDuration is a
// final guard against pathological overflow.
if _, err := time.ParseDuration(s); err != nil {
errors = append(errors, ValidationError{
Field: field,
Message: fmt.Sprintf("Invalid timeout format: %v", err),
})
}
}

validate(fieldPrefix+".timeout", r.Timeout)
validate(fieldPrefix+".idleTimeout", r.IdleTimeout)
return errors
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// validateAsyncData validates the data section of the configuration for http/rest kind
func (v *APIValidator) validateAsyncData(spec *api.WebhookAPIData) []ValidationError {
var errors []ValidationError
Expand Down Expand Up @@ -621,6 +673,9 @@ func (v *APIValidator) validateOperations(operations []api.Operation) []Validati
Message: "Operation path has unbalanced braces in parameters",
})
}

// Validate operation-level resilience block
errors = append(errors, v.validateResilience(fmt.Sprintf("spec.operations[%d].resilience", i), op.Resilience)...)
}

return errors
Expand Down
54 changes: 54 additions & 0 deletions gateway/gateway-controller/pkg/config/api_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,60 @@ func TestAPIValidator_ValidateOperations(t *testing.T) {
}
}

func TestAPIValidator_ValidateResilience(t *testing.T) {
v := NewAPIValidator()

tests := []struct {
name string
apiRes *api.Resilience
opRes *api.Resilience
wantError bool
errField string
}{
{name: "No resilience is valid", wantError: false},
{name: "Valid API-level timeout + idleTimeout", apiRes: &api.Resilience{Timeout: stringPtr("15s"), IdleTimeout: stringPtr("0s")}, wantError: false},
{name: "Valid operation-level timeout", opRes: &api.Resilience{Timeout: stringPtr("2s")}, wantError: false},
{name: "0s is allowed (disabled)", apiRes: &api.Resilience{Timeout: stringPtr("0s"), IdleTimeout: stringPtr("0s")}, wantError: false},
{name: "Invalid API-level timeout format", apiRes: &api.Resilience{Timeout: stringPtr("15seconds")}, wantError: true, errField: "spec.resilience.timeout"},
{name: "Invalid API-level idleTimeout format", apiRes: &api.Resilience{IdleTimeout: stringPtr("abc")}, wantError: true, errField: "spec.resilience.idleTimeout"},
{name: "Negative API-level timeout rejected", apiRes: &api.Resilience{Timeout: stringPtr("-5s")}, wantError: true, errField: "spec.resilience.timeout"},
{name: "Compound API-level timeout rejected (must match CRD pattern)", apiRes: &api.Resilience{Timeout: stringPtr("1h30m")}, wantError: true, errField: "spec.resilience.timeout"},
{name: "Compound API-level idleTimeout rejected", apiRes: &api.Resilience{IdleTimeout: stringPtr("1m30s")}, wantError: true, errField: "spec.resilience.idleTimeout"},
{name: "Unitless API-level timeout rejected", apiRes: &api.Resilience{Timeout: stringPtr("30")}, wantError: true, errField: "spec.resilience.timeout"},
{name: "Bare 0 rejected (unit required)", apiRes: &api.Resilience{Timeout: stringPtr("0")}, wantError: true, errField: "spec.resilience.timeout"},
{name: "Fractional single-unit timeout allowed", apiRes: &api.Resilience{Timeout: stringPtr("1.5s")}, wantError: false},
{name: "Invalid operation-level timeout format", opRes: &api.Resilience{Timeout: stringPtr("nope")}, wantError: true, errField: "spec.operations[0].resilience.timeout"},
{name: "Compound operation-level timeout rejected", opRes: &api.Resilience{Timeout: stringPtr("1h30m")}, wantError: true, errField: "spec.operations[0].resilience.timeout"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := createValidRestAPIConfig()
cfg.Spec.Resilience = tt.apiRes
cfg.Spec.Operations[0].Resilience = tt.opRes

errors := v.Validate(cfg)
hasExpectedError := false
for _, e := range errors {
if e.Field == tt.errField {
hasExpectedError = true
break
}
}
if tt.wantError && !hasExpectedError {
t.Errorf("expected error for field %s, got: %v", tt.errField, errors)
}
if !tt.wantError {
for _, e := range errors {
if strings.Contains(e.Field, "resilience") {
t.Errorf("unexpected resilience error: %v", e)
}
}
}
})
}
}

func TestAPIValidator_ValidateAllHTTPMethods(t *testing.T) {
v := NewAPIValidator()

Expand Down
39 changes: 37 additions & 2 deletions gateway/gateway-controller/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,17 @@ type VHostEntry struct {

// HTTPListenerConfig holds HTTP listener related configuration of an API
type HTTPListenerConfig struct {
ServerHeaderTransformation string `koanf:"server_header_transformation"` // Options: "APPEND_IF_ABSENT", "OVERWRITE", "PASS_THROUGH"
ServerHeaderValue string `koanf:"server_header_value"` // Custom value for the Server header
ServerHeaderTransformation string `koanf:"server_header_transformation"` // Options: "APPEND_IF_ABSENT", "OVERWRITE", "PASS_THROUGH"
ServerHeaderValue string `koanf:"server_header_value"` // Custom value for the Server header
Timeouts HCMTimeouts `koanf:"timeouts"` // HTTP Connection Manager (downstream) timeouts
}

// HCMTimeouts holds HTTP Connection Manager (downstream/connection) timeouts.
type HCMTimeouts struct {
RequestTimeout time.Duration `koanf:"request_timeout"` // HCM request_timeout (default 0s = disabled)
RequestHeadersTimeout time.Duration `koanf:"request_headers_timeout"` // HCM request_headers_timeout (default 0s = disabled)
StreamIdleTimeout time.Duration `koanf:"stream_idle_timeout"` // HCM stream_idle_timeout (default 5m)
IdleTimeout time.Duration `koanf:"idle_timeout"` // common_http_protocol_options.idle_timeout (default 1h)
}

// PolicyEngineConfig holds policy engine ext_proc filter configuration
Expand Down Expand Up @@ -893,6 +902,12 @@ func defaultConfig() *Config {
HTTPListener: HTTPListenerConfig{
ServerHeaderTransformation: commonconstants.OVERWRITE,
ServerHeaderValue: commonconstants.ServerName,
Timeouts: HCMTimeouts{
RequestTimeout: 0, // 0s = disabled (Envoy default)
RequestHeadersTimeout: 0, // 0s = disabled (Envoy default)
StreamIdleTimeout: 5 * time.Minute, // Envoy default
IdleTimeout: 1 * time.Hour, // Envoy default (connection-level)
},
},
},
Analytics: AnalyticsConfig{
Expand Down Expand Up @@ -1592,6 +1607,26 @@ func (c *Config) validateTimeoutConfig() error {
timeouts.ConnectTimeoutMs, constants.MaxReasonableTimeoutMs)
}

// Validate HCM (downstream/connection) timeouts. Unlike the upstream timeouts above,
// 0 is a valid value here: which denotes "disabled scenario"
// only reject negative values and unreasonably large ones are rejected.
maxConnTimeout := time.Duration(constants.MaxReasonableConnectionTimeoutMs) * time.Millisecond
hcmTimeouts := map[string]time.Duration{
"request_timeout": c.Router.HTTPListener.Timeouts.RequestTimeout,
"request_headers_timeout": c.Router.HTTPListener.Timeouts.RequestHeadersTimeout,
"stream_idle_timeout": c.Router.HTTPListener.Timeouts.StreamIdleTimeout,
"idle_timeout": c.Router.HTTPListener.Timeouts.IdleTimeout,
}
for name, v := range hcmTimeouts {
if v < 0 {
return fmt.Errorf("router.http_listener.timeouts.%s must not be negative, got: %s", name, v)
}
if v > maxConnTimeout {
return fmt.Errorf("router.http_listener.timeouts.%s (%s) exceeds maximum reasonable timeout of %s",
name, v, maxConnTimeout)
}
}

return nil
}

Expand Down
Loading
Loading