This is an automated email from the ASF dual-hosted git repository.

moonming pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new 9b8f7099 fix: preserve fractional seconds in upstream timeouts (#2894)
9b8f7099 is described below

commit 9b8f7099a9a1f723d175742b7e6a64d947e18bbb
Author: Efe <[email protected]>
AuthorDate: Mon Nov 9 06:40:45 2026 +0300

    fix: preserve fractional seconds in upstream timeouts (#2894)
    
    * Fix fractional upstream timeout translation
    
    Fixes APISIX Ingress Controller issue #2879.
    
    Signed-off-by: Efe Gökdemir <[email protected]>
    
    * test: compare ingress timeouts as floats
    
    Signed-off-by: Efe Gökdemir <[email protected]>
    
    * fix: reject negative ApisixRoute timeouts
    
    * test: cover valid and negative upstream timeouts
    
    * fix: allow fractional backend policy timeouts
    
    ---------
    
    Signed-off-by: Efe Gökdemir <[email protected]>
    Signed-off-by: Efe Gökdemir <[email protected]>
---
 api/adc/types.go                                   |   6 +-
 api/v1alpha1/backendtrafficpolicy_types.go         |   6 +-
 api/v2/apisixroute_types.go                        |   3 +
 api/v2/apisixroute_types_test.go                   |  50 ++++++++
 api/v2/backendtrafficpolicy_timeout_test.go        |  47 +++++++
 api/v2/crd_schema_validator_test.go                |   9 +-
 .../crd/bases/apisix.apache.org_apisixroutes.yaml  |   9 ++
 .../bases/apisix.apache.org_apisixupstreams.yaml   |  18 +++
 .../apisix.apache.org_backendtrafficpolicies.yaml  |  12 +-
 internal/adc/translator/apisixroute.go             |  29 +++--
 internal/adc/translator/apisixroute_test.go        |  46 +++++++
 internal/adc/translator/apisixupstream.go          |   6 +-
 internal/adc/translator/ingress.go                 |   6 +-
 internal/adc/translator/policies.go                |   7 +-
 internal/adc/translator/timeouts_test.go           | 138 +++++++++++++++++++++
 test/e2e/ingress/annotations.go                    |   6 +-
 16 files changed, 368 insertions(+), 30 deletions(-)

diff --git a/api/adc/types.go b/api/adc/types.go
index 667f29ec..f1d507ff 100644
--- a/api/adc/types.go
+++ b/api/adc/types.go
@@ -167,9 +167,9 @@ type Route struct {
 }
 
 type Timeout struct {
-       Connect int `json:"connect"`
-       Read    int `json:"read"`
-       Send    int `json:"send"`
+       Connect float64 `json:"connect"`
+       Read    float64 `json:"read"`
+       Send    float64 `json:"send"`
 }
 
 // +k8s:deepcopy-gen=true
diff --git a/api/v1alpha1/backendtrafficpolicy_types.go 
b/api/v1alpha1/backendtrafficpolicy_types.go
index b3dc24c3..b822d9b5 100644
--- a/api/v1alpha1/backendtrafficpolicy_types.go
+++ b/api/v1alpha1/backendtrafficpolicy_types.go
@@ -113,17 +113,17 @@ type LoadBalancer struct {
 type Timeout struct {
        // Connection timeout. Default is `60s`.
        // +kubebuilder:default="60s"
-       // +kubebuilder:validation:Pattern=`^[0-9]+s$`
+       // +kubebuilder:validation:XValidation:rule="duration(self) >= 
duration('0s')",message="timeout must not be negative"
        // +kubebuilder:validation:Type=string
        Connect metav1.Duration `json:"connect,omitempty" 
yaml:"connect,omitempty"`
        // Send timeout. Default is `60s`.
        // +kubebuilder:default="60s"
-       // +kubebuilder:validation:Pattern=`^[0-9]+s$`
+       // +kubebuilder:validation:XValidation:rule="duration(self) >= 
duration('0s')",message="timeout must not be negative"
        // +kubebuilder:validation:Type=string
        Send metav1.Duration `json:"send,omitempty" yaml:"send,omitempty"`
        // Read timeout. Default is `60s`.
        // +kubebuilder:default="60s"
-       // +kubebuilder:validation:Pattern=`^[0-9]+s$`
+       // +kubebuilder:validation:XValidation:rule="duration(self) >= 
duration('0s')",message="timeout must not be negative"
        // +kubebuilder:validation:Type=string
        Read metav1.Duration `json:"read,omitempty" yaml:"read,omitempty"`
 }
diff --git a/api/v2/apisixroute_types.go b/api/v2/apisixroute_types.go
index 0f6ad5a5..72a763e0 100644
--- a/api/v2/apisixroute_types.go
+++ b/api/v2/apisixroute_types.go
@@ -131,10 +131,13 @@ type ApisixRouteStream struct {
 // UpstreamTimeout defines timeout settings for connecting, sending, and 
reading from the upstream.
 type UpstreamTimeout struct {
        // Connect timeout for establishing a connection to the upstream.
+       // +kubebuilder:validation:XValidation:rule="duration(self) >= 
duration('0s')",message="timeout must not be negative"
        Connect metav1.Duration `json:"connect,omitempty" 
yaml:"connect,omitempty"`
        // Send timeout for sending data to the upstream.
+       // +kubebuilder:validation:XValidation:rule="duration(self) >= 
duration('0s')",message="timeout must not be negative"
        Send metav1.Duration `json:"send,omitempty" yaml:"send,omitempty"`
        // Read timeout for reading data from the upstream.
+       // +kubebuilder:validation:XValidation:rule="duration(self) >= 
duration('0s')",message="timeout must not be negative"
        Read metav1.Duration `json:"read,omitempty" yaml:"read,omitempty"`
 }
 
diff --git a/api/v2/apisixroute_types_test.go b/api/v2/apisixroute_types_test.go
index d5093477..1872e51b 100644
--- a/api/v2/apisixroute_types_test.go
+++ b/api/v2/apisixroute_types_test.go
@@ -19,9 +19,11 @@ import (
        "path/filepath"
        "runtime"
        "testing"
+       "time"
 
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "k8s.io/apimachinery/pkg/util/intstr"
 
        apisixv2 "github.com/apache/apisix-ingress-controller/api/v2"
@@ -35,6 +37,14 @@ func loadApisixRouteSchema(t *testing.T) *crdSchemaValidator 
{
        return loadCRDSchema(t, crdPath)
 }
 
+func loadApisixUpstreamSchema(t *testing.T) *crdSchemaValidator {
+       t.Helper()
+       _, thisFile, _, _ := runtime.Caller(0)
+       crdPath := filepath.Join(filepath.Dir(thisFile), "..", "..",
+               "config", "crd", "bases", 
"apisix.apache.org_apisixupstreams.yaml")
+       return loadCRDSchema(t, crdPath)
+}
+
 func strPtr(s string) *string { return &s }
 func boolPtr(b bool) *bool    { return &b }
 func intPtr(i int) *int       { return &i }
@@ -125,3 +135,43 @@ func TestApisixRoute_PathScope_EmptyName(t *testing.T) {
        }
        assert.NoError(t, v.Validate(t, ar))
 }
+
+func TestApisixRoute_NegativeTimeoutRejected(t *testing.T) {
+       v := loadApisixRouteSchema(t)
+       tests := []struct {
+               name    string
+               timeout apisixv2.UpstreamTimeout
+       }{
+               {name: "connect", timeout: apisixv2.UpstreamTimeout{Connect: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+               {name: "read", timeout: apisixv2.UpstreamTimeout{Read: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+               {name: "send", timeout: apisixv2.UpstreamTimeout{Send: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       ar := &apisixv2.ApisixRoute{Spec: 
apisixv2.ApisixRouteSpec{HTTP: []apisixv2.ApisixRouteHTTP{{
+                               Name: "rule1", Websocket: boolPtr(false), 
Match: apisixv2.ApisixRouteHTTPMatch{Paths: []string{"/*"}}, Timeout: 
&tt.timeout,
+                       }}}}
+                       require.ErrorContains(t, v.Validate(t, ar), "timeout 
must not be negative")
+               })
+       }
+}
+
+func TestApisixUpstream_NegativeTimeoutRejected(t *testing.T) {
+       v := loadApisixUpstreamSchema(t)
+       tests := []struct {
+               name    string
+               timeout apisixv2.UpstreamTimeout
+       }{
+               {name: "connect", timeout: apisixv2.UpstreamTimeout{Connect: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+               {name: "read", timeout: apisixv2.UpstreamTimeout{Read: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+               {name: "send", timeout: apisixv2.UpstreamTimeout{Send: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       upstream := &apisixv2.ApisixUpstream{Spec: 
apisixv2.ApisixUpstreamSpec{
+                               ApisixUpstreamConfig: 
apisixv2.ApisixUpstreamConfig{Timeout: &tt.timeout},
+                       }}
+                       require.ErrorContains(t, v.Validate(t, upstream), 
"timeout must not be negative")
+               })
+       }
+}
diff --git a/api/v2/backendtrafficpolicy_timeout_test.go 
b/api/v2/backendtrafficpolicy_timeout_test.go
new file mode 100644
index 00000000..dbe81096
--- /dev/null
+++ b/api/v2/backendtrafficpolicy_timeout_test.go
@@ -0,0 +1,47 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.  See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v2_test
+
+import (
+       "path/filepath"
+       "runtime"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestBackendTrafficPolicyTimeoutSchema(t *testing.T) {
+       _, thisFile, _, _ := runtime.Caller(0)
+       schema := loadCRDSchemaVersion(t, filepath.Join(filepath.Dir(thisFile), 
"..", "..",
+               "config", "crd", "bases", 
"apisix.apache.org_backendtrafficpolicies.yaml"), "v1alpha1")
+       for _, field := range []string{"connect", "read", "send"} {
+               for _, value := range []string{"0s", "60s", "500ms", "1.5s", 
"1m30s", "-500ms", "", "invalid", "9223372036854775808ns"} {
+                       t.Run(field+"/"+value, func(t *testing.T) {
+                               policy := map[string]any{"spec": map[string]any{
+                                       "targetRefs": 
[]any{map[string]any{"group": "", "kind": "Service", "name": "backend"}},
+                                       "timeout":    map[string]any{field: 
value},
+                               }}
+                               err := schema.Validate(t, policy)
+                               switch value {
+                               case "-500ms", "", "invalid", 
"9223372036854775808ns":
+                                       require.Error(t, err)
+                               default:
+                                       require.NoError(t, err)
+                               }
+                       })
+               }
+       }
+}
diff --git a/api/v2/crd_schema_validator_test.go 
b/api/v2/crd_schema_validator_test.go
index 0337b856..5204a2eb 100644
--- a/api/v2/crd_schema_validator_test.go
+++ b/api/v2/crd_schema_validator_test.go
@@ -67,6 +67,11 @@ func (v *crdSchemaValidator) Validate(t *testing.T, obj any) 
error {
 // loadCRDSchema reads a CRD YAML file and returns a validator for the "v2" 
version schema.
 func loadCRDSchema(t *testing.T, crdPath string) *crdSchemaValidator {
        t.Helper()
+       return loadCRDSchemaVersion(t, crdPath, "v2")
+}
+
+func loadCRDSchemaVersion(t *testing.T, crdPath, version string) 
*crdSchemaValidator {
+       t.Helper()
 
        data, err := os.ReadFile(crdPath)
        require.NoError(t, err, "failed to read CRD file: %s", crdPath)
@@ -79,12 +84,12 @@ func loadCRDSchema(t *testing.T, crdPath string) 
*crdSchemaValidator {
 
        var v1Schema *apiextensionsv1.JSONSchemaProps
        for _, v := range crd.Spec.Versions {
-               if v.Name == "v2" {
+               if v.Name == version {
                        v1Schema = v.Schema.OpenAPIV3Schema
                        break
                }
        }
-       require.NotNil(t, v1Schema, "v2 schema not found in CRD")
+       require.NotNil(t, v1Schema, "%s schema not found in CRD", version)
 
        var internal apiextensions.JSONSchemaProps
        require.NoError(t,
diff --git a/config/crd/bases/apisix.apache.org_apisixroutes.yaml 
b/config/crd/bases/apisix.apache.org_apisixroutes.yaml
index f692629e..4c2f89ab 100644
--- a/config/crd/bases/apisix.apache.org_apisixroutes.yaml
+++ b/config/crd/bases/apisix.apache.org_apisixroutes.yaml
@@ -332,12 +332,21 @@ spec:
                           description: Connect timeout for establishing a 
connection
                             to the upstream.
                           type: string
+                          x-kubernetes-validations:
+                          - message: timeout must not be negative
+                            rule: duration(self) >= duration('0s')
                         read:
                           description: Read timeout for reading data from the 
upstream.
                           type: string
+                          x-kubernetes-validations:
+                          - message: timeout must not be negative
+                            rule: duration(self) >= duration('0s')
                         send:
                           description: Send timeout for sending data to the 
upstream.
                           type: string
+                          x-kubernetes-validations:
+                          - message: timeout must not be negative
+                            rule: duration(self) >= duration('0s')
                       type: object
                     upstreams:
                       description: Upstreams references ApisixUpstream CRDs.
diff --git a/config/crd/bases/apisix.apache.org_apisixupstreams.yaml 
b/config/crd/bases/apisix.apache.org_apisixupstreams.yaml
index 8ac4c125..9e8c37a6 100644
--- a/config/crd/bases/apisix.apache.org_apisixupstreams.yaml
+++ b/config/crd/bases/apisix.apache.org_apisixupstreams.yaml
@@ -633,12 +633,21 @@ spec:
                           description: Connect timeout for establishing a 
connection
                             to the upstream.
                           type: string
+                          x-kubernetes-validations:
+                          - message: timeout must not be negative
+                            rule: duration(self) >= duration('0s')
                         read:
                           description: Read timeout for reading data from the 
upstream.
                           type: string
+                          x-kubernetes-validations:
+                          - message: timeout must not be negative
+                            rule: duration(self) >= duration('0s')
                         send:
                           description: Send timeout for sending data to the 
upstream.
                           type: string
+                          x-kubernetes-validations:
+                          - message: timeout must not be negative
+                            rule: duration(self) >= duration('0s')
                       type: object
                     tlsSecret:
                       description: |-
@@ -718,12 +727,21 @@ spec:
                     description: Connect timeout for establishing a connection 
to
                       the upstream.
                     type: string
+                    x-kubernetes-validations:
+                    - message: timeout must not be negative
+                      rule: duration(self) >= duration('0s')
                   read:
                     description: Read timeout for reading data from the 
upstream.
                     type: string
+                    x-kubernetes-validations:
+                    - message: timeout must not be negative
+                      rule: duration(self) >= duration('0s')
                   send:
                     description: Send timeout for sending data to the upstream.
                     type: string
+                    x-kubernetes-validations:
+                    - message: timeout must not be negative
+                      rule: duration(self) >= duration('0s')
                 type: object
               tlsSecret:
                 description: |-
diff --git a/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml 
b/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml
index 3b070a78..b6be5dbe 100644
--- a/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml
+++ b/config/crd/bases/apisix.apache.org_backendtrafficpolicies.yaml
@@ -365,18 +365,24 @@ spec:
                   connect:
                     default: 60s
                     description: Connection timeout. Default is `60s`.
-                    pattern: ^[0-9]+s$
                     type: string
+                    x-kubernetes-validations:
+                    - message: timeout must not be negative
+                      rule: duration(self) >= duration('0s')
                   read:
                     default: 60s
                     description: Read timeout. Default is `60s`.
-                    pattern: ^[0-9]+s$
                     type: string
+                    x-kubernetes-validations:
+                    - message: timeout must not be negative
+                      rule: duration(self) >= duration('0s')
                   send:
                     default: 60s
                     description: Send timeout. Default is `60s`.
-                    pattern: ^[0-9]+s$
                     type: string
+                    x-kubernetes-validations:
+                    - message: timeout must not be negative
+                      rule: duration(self) >= duration('0s')
                 type: object
               upstreamHost:
                 description: |-
diff --git a/internal/adc/translator/apisixroute.go 
b/internal/adc/translator/apisixroute.go
index 5d03e8c1..56d56ffe 100644
--- a/internal/adc/translator/apisixroute.go
+++ b/internal/adc/translator/apisixroute.go
@@ -63,7 +63,10 @@ func (t *Translator) TranslateApisixRoute(tctx 
*provider.TranslateContext, ar *a
 }
 
 func (t *Translator) translateHTTPRule(tctx *provider.TranslateContext, ar 
*apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP, ruleIndex int) (*adc.Service, 
error) {
-       timeout := t.buildTimeout(rule)
+       timeout, err := t.buildTimeout(rule)
+       if err != nil {
+               return nil, err
+       }
        plugins, err := t.buildPlugins(tctx, ar, rule)
        if err != nil {
                return nil, err
@@ -81,16 +84,28 @@ func (t *Translator) translateHTTPRule(tctx 
*provider.TranslateContext, ar *apiv
        return service, nil
 }
 
-func (t *Translator) buildTimeout(rule apiv2.ApisixRouteHTTP) *adc.Timeout {
+func (t *Translator) buildTimeout(rule apiv2.ApisixRouteHTTP) (*adc.Timeout, 
error) {
        if rule.Timeout == nil {
-               return nil
+               return nil, nil
+       }
+       for _, timeout := range []struct {
+               name     string
+               duration metav1.Duration
+       }{
+               {name: "connect", duration: rule.Timeout.Connect},
+               {name: "read", duration: rule.Timeout.Read},
+               {name: "send", duration: rule.Timeout.Send},
+       } {
+               if timeout.duration.Duration < 0 {
+                       return nil, fmt.Errorf("invalid value timeout.%s", 
timeout.name)
+               }
        }
        defaultTimeout := metav1.Duration{Duration: 
apiv2.DefaultUpstreamTimeout}
        return &adc.Timeout{
-               Connect: cmp.Or(int(rule.Timeout.Connect.Seconds()), 
int(defaultTimeout.Seconds())),
-               Read:    cmp.Or(int(rule.Timeout.Read.Seconds()), 
int(defaultTimeout.Seconds())),
-               Send:    cmp.Or(int(rule.Timeout.Send.Seconds()), 
int(defaultTimeout.Seconds())),
-       }
+               Connect: cmp.Or(rule.Timeout.Connect.Seconds(), 
defaultTimeout.Seconds()),
+               Read:    cmp.Or(rule.Timeout.Read.Seconds(), 
defaultTimeout.Seconds()),
+               Send:    cmp.Or(rule.Timeout.Send.Seconds(), 
defaultTimeout.Seconds()),
+       }, nil
 }
 
 func (t *Translator) buildPlugins(tctx *provider.TranslateContext, ar 
*apiv2.ApisixRoute, rule apiv2.ApisixRouteHTTP) (adc.Plugins, error) {
diff --git a/internal/adc/translator/apisixroute_test.go 
b/internal/adc/translator/apisixroute_test.go
index 431e2d5b..8d646c89 100644
--- a/internal/adc/translator/apisixroute_test.go
+++ b/internal/adc/translator/apisixroute_test.go
@@ -20,6 +20,7 @@ package translator
 import (
        "context"
        "testing"
+       "time"
 
        "github.com/go-logr/logr"
        "github.com/stretchr/testify/assert"
@@ -252,3 +253,48 @@ func TestTranslateApisixRouteStreamUpstreamScheme(t 
*testing.T) {
                })
        }
 }
+
+func TestTranslateApisixRouteRejectsNegativeTimeouts(t *testing.T) {
+       tests := []struct {
+               name    string
+               timeout apiv2.UpstreamTimeout
+       }{
+               {
+                       name: "connect",
+                       timeout: apiv2.UpstreamTimeout{
+                               Connect: metav1.Duration{Duration: -500 * 
time.Millisecond},
+                       },
+               },
+               {
+                       name: "read",
+                       timeout: apiv2.UpstreamTimeout{
+                               Read: metav1.Duration{Duration: -500 * 
time.Millisecond},
+                       },
+               },
+               {
+                       name: "send",
+                       timeout: apiv2.UpstreamTimeout{
+                               Send: metav1.Duration{Duration: -500 * 
time.Millisecond},
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       ar := &apiv2.ApisixRoute{
+                               Spec: apiv2.ApisixRouteSpec{
+                                       HTTP: []apiv2.ApisixRouteHTTP{{
+                                               Name:    "rule1",
+                                               Timeout: &tt.timeout,
+                                       }},
+                               },
+                       }
+
+                       _, err := NewTranslator(logr.Discard(), 
"").TranslateApisixRoute(
+                               
provider.NewDefaultTranslateContext(context.Background()), ar,
+                       )
+
+                       require.ErrorContains(t, err, "invalid value 
timeout."+tt.name)
+               })
+       }
+}
diff --git a/internal/adc/translator/apisixupstream.go 
b/internal/adc/translator/apisixupstream.go
index 53b1b006..a552f1c7 100644
--- a/internal/adc/translator/apisixupstream.go
+++ b/internal/adc/translator/apisixupstream.go
@@ -164,9 +164,9 @@ func translateApisixUpstreamRetriesAndTimeout(config 
*apiv2.ApisixUpstreamConfig
        sendTimeout := cmp.Or(timeout.Send.Duration, 
apiv2.DefaultUpstreamTimeout)
 
        ups.Timeout = &adc.Timeout{
-               Connect: int(connTimeout.Seconds()),
-               Read:    int(readTimeout.Seconds()),
-               Send:    int(sendTimeout.Seconds()),
+               Connect: connTimeout.Seconds(),
+               Read:    readTimeout.Seconds(),
+               Send:    sendTimeout.Seconds(),
        }
 
        return nil
diff --git a/internal/adc/translator/ingress.go 
b/internal/adc/translator/ingress.go
index f3e121fe..0c0d90e8 100644
--- a/internal/adc/translator/ingress.go
+++ b/internal/adc/translator/ingress.go
@@ -207,9 +207,9 @@ func (t *Translator) resolveIngressUpstream(
                }
                if upConfig.TimeoutConnect > 0 || upConfig.TimeoutRead > 0 || 
upConfig.TimeoutSend > 0 {
                        upstream.Timeout = &adctypes.Timeout{
-                               Connect: cmp.Or(upConfig.TimeoutConnect, 60),
-                               Read:    cmp.Or(upConfig.TimeoutRead, 60),
-                               Send:    cmp.Or(upConfig.TimeoutSend, 60),
+                               Connect: 
float64(cmp.Or(upConfig.TimeoutConnect, 60)),
+                               Read:    float64(cmp.Or(upConfig.TimeoutRead, 
60)),
+                               Send:    float64(cmp.Or(upConfig.TimeoutSend, 
60)),
                        }
                }
        }
diff --git a/internal/adc/translator/policies.go 
b/internal/adc/translator/policies.go
index f60bba21..d850d7d0 100644
--- a/internal/adc/translator/policies.go
+++ b/internal/adc/translator/policies.go
@@ -18,6 +18,7 @@
 package translator
 
 import (
+       "cmp"
        "fmt"
 
        corev1 "k8s.io/api/core/v1"
@@ -123,9 +124,9 @@ func (t *Translator) 
attachBackendTrafficPolicyToUpstream(policy *v1alpha1.Backe
        }
        if policy.Spec.Timeout != nil {
                upstream.Timeout = &adctypes.Timeout{
-                       Connect: int(policy.Spec.Timeout.Connect.Seconds()),
-                       Read:    int(policy.Spec.Timeout.Read.Seconds()),
-                       Send:    int(policy.Spec.Timeout.Send.Seconds()),
+                       Connect: cmp.Or(policy.Spec.Timeout.Connect.Duration, 
apiv2.DefaultUpstreamTimeout).Seconds(),
+                       Read:    cmp.Or(policy.Spec.Timeout.Read.Duration, 
apiv2.DefaultUpstreamTimeout).Seconds(),
+                       Send:    cmp.Or(policy.Spec.Timeout.Send.Duration, 
apiv2.DefaultUpstreamTimeout).Seconds(),
                }
        }
        if policy.Spec.LoadBalancer != nil {
diff --git a/internal/adc/translator/timeouts_test.go 
b/internal/adc/translator/timeouts_test.go
new file mode 100644
index 00000000..6479f10a
--- /dev/null
+++ b/internal/adc/translator/timeouts_test.go
@@ -0,0 +1,138 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package translator
+
+import (
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+       "github.com/apache/apisix-ingress-controller/api/adc"
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
+)
+
+func TestTranslateApisixUpstreamRetriesAndTimeoutPreservesSubsecondValues(t 
*testing.T) {
+       config := &apiv2.ApisixUpstreamConfig{
+               Timeout: &apiv2.UpstreamTimeout{
+                       Connect: metav1.Duration{Duration: 500 * 
time.Millisecond},
+                       Read:    metav1.Duration{Duration: 1500 * 
time.Millisecond},
+                       Send:    metav1.Duration{Duration: 2500 * 
time.Millisecond},
+               },
+       }
+       upstream := adc.NewDefaultUpstream()
+
+       err := translateApisixUpstreamRetriesAndTimeout(config, upstream)
+
+       require.NoError(t, err)
+       assert.Equal(t, 0.5, upstream.Timeout.Connect)
+       assert.Equal(t, 1.5, upstream.Timeout.Read)
+       assert.Equal(t, 2.5, upstream.Timeout.Send)
+}
+
+func TestTranslateApisixUpstreamRetriesAndTimeoutRejectsNegativeValues(t 
*testing.T) {
+       tests := []struct {
+               name    string
+               timeout apiv2.UpstreamTimeout
+       }{
+               {name: "connect", timeout: apiv2.UpstreamTimeout{Connect: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+               {name: "read", timeout: apiv2.UpstreamTimeout{Read: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+               {name: "send", timeout: apiv2.UpstreamTimeout{Send: 
metav1.Duration{Duration: -500 * time.Millisecond}}},
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       config := &apiv2.ApisixUpstreamConfig{Timeout: 
&tt.timeout}
+                       upstream := adc.NewDefaultUpstream()
+
+                       err := translateApisixUpstreamRetriesAndTimeout(config, 
upstream)
+
+                       require.ErrorContains(t, err, "invalid value 
timeout."+tt.name)
+               })
+       }
+}
+
+func TestBuildTimeoutPreservesSubsecondValues(t *testing.T) {
+       rule := apiv2.ApisixRouteHTTP{
+               Timeout: &apiv2.UpstreamTimeout{
+                       Connect: metav1.Duration{Duration: 500 * 
time.Millisecond},
+                       Read:    metav1.Duration{Duration: 1500 * 
time.Millisecond},
+                       Send:    metav1.Duration{Duration: 2500 * 
time.Millisecond},
+               },
+       }
+
+       timeout, err := (&Translator{}).buildTimeout(rule)
+
+       require.NoError(t, err)
+       assert.Equal(t, 0.5, timeout.Connect)
+       assert.Equal(t, 1.5, timeout.Read)
+       assert.Equal(t, 2.5, timeout.Send)
+}
+
+func TestBuildTimeoutDefaultsZeroValuesAndPreservesWholeSeconds(t *testing.T) {
+       tests := []struct {
+               name    string
+               timeout apiv2.UpstreamTimeout
+               want    adc.Timeout
+       }{
+               {
+                       name: "zero values use the default",
+                       want: adc.Timeout{Connect: 60, Read: 60, Send: 60},
+               },
+               {
+                       name: "whole-second values are preserved",
+                       timeout: apiv2.UpstreamTimeout{
+                               Connect: metav1.Duration{Duration: time.Second},
+                               Read:    metav1.Duration{Duration: 2 * 
time.Second},
+                               Send:    metav1.Duration{Duration: 3 * 
time.Second},
+                       },
+                       want: adc.Timeout{Connect: 1, Read: 2, Send: 3},
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       rule := apiv2.ApisixRouteHTTP{Timeout: &tt.timeout}
+
+                       got, err := (&Translator{}).buildTimeout(rule)
+
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, *got)
+               })
+       }
+}
+
+func TestAttachBackendTrafficPolicyToUpstreamPreservesSubsecondValues(t 
*testing.T) {
+       policy := &v1alpha1.BackendTrafficPolicy{
+               Spec: v1alpha1.BackendTrafficPolicySpec{
+                       Timeout: &v1alpha1.Timeout{
+                               Connect: metav1.Duration{},
+                               Read:    metav1.Duration{Duration: 1500 * 
time.Millisecond},
+                               Send:    metav1.Duration{Duration: 2500 * 
time.Millisecond},
+                       },
+               },
+       }
+       upstream := &adc.Upstream{}
+
+       (&Translator{}).attachBackendTrafficPolicyToUpstream(policy, upstream)
+
+       assert.Equal(t, 60.0, upstream.Timeout.Connect)
+       assert.Equal(t, 1.5, upstream.Timeout.Read)
+       assert.Equal(t, 2.5, upstream.Timeout.Send)
+}
diff --git a/test/e2e/ingress/annotations.go b/test/e2e/ingress/annotations.go
index 2f813c88..5dc0e3ec 100644
--- a/test/e2e/ingress/annotations.go
+++ b/test/e2e/ingress/annotations.go
@@ -210,9 +210,9 @@ spec:
                        Expect(err).NotTo(HaveOccurred(), "listing Upstream")
                        Expect(upstreams).To(HaveLen(1), "checking Upstream 
length")
                        Expect(upstreams[0].Timeout).ToNot(BeNil(), "checking 
Upstream timeout")
-                       Expect(upstreams[0].Timeout.Read).To(Equal(2), 
"checking Upstream read timeout")
-                       Expect(upstreams[0].Timeout.Send).To(Equal(3), 
"checking Upstream send timeout")
-                       Expect(upstreams[0].Timeout.Connect).To(Equal(4), 
"checking Upstream connect timeout")
+                       Expect(upstreams[0].Timeout.Read).To(Equal(2.0), 
"checking Upstream read timeout")
+                       Expect(upstreams[0].Timeout.Send).To(Equal(3.0), 
"checking Upstream send timeout")
+                       Expect(upstreams[0].Timeout.Connect).To(Equal(4.0), 
"checking Upstream connect timeout")
                })
 
                It("cors annotations", func() {

Reply via email to