fix(client): keep reference-level request timeout over consumer default (#3757)

* fix(client): keep reference-level request timeout over consumer default

Motivation:
ReferenceOptions.refer() builds the reference URL from getURLMap(), which
sets the timeout from the reference-level RequestTimeout, and then
unconditionally overwrote it with the consumer-level RequestTimeout. An
explicit WithRequestTimeout(500ms) on a reference therefore ended up as the
consumer default (e.g. 3s) in the URL timeout param, which the Triple client
reads as its default call timeout.

Approach:
Use the consumer-level timeout only as a fallback when the reference-level
timeout is empty. Behaviour is unchanged when only the consumer timeout (or
neither) is set. Per-call WithCallRequestTimeout is untouched.

Validation:
- Added TestReferenceTimeoutPrecedence (client/timeout_precedence_test.go),
  which drives ReferenceOptions.Refer() with a mock protocol/cluster and
  checks the timeout param on the URL. Without the fix the "reference wins"
  case fails (got 3s, want 500ms); with the fix both cases pass.
- go test ./client/ -count=1: ok
- go build ./..., go vet ./client/, golangci-lint run ./client/...: clean
Not run: an end-to-end RPC measuring actual call duration; the test checks
URL construction only.

Report: https://github.com/apache/dubbo-go/issues/3756
Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Assisted-by: claude-sonnet-5-5 (via Claude Code)

* test(client): move timeout precedence test to action_test.go; clone overallReference per dial

Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>

---------

Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Co-authored-by: Xuetao Li <m134679102365478@163.com>
diff --git a/client/action.go b/client/action.go
index 935b668..23a7aef 100644
--- a/client/action.go
+++ b/client/action.go
@@ -126,6 +126,13 @@
 		ref.Loadbalance = constant.LoadBalanceKeyP2C
 	}
 
+	// The reference-level timeout (already in getURLMap) takes precedence over the consumer-level one.
+	// TODO: remove consumer timeout fallback after old config removed
+	timeout := ref.RequestTimeout
+	if len(timeout) == 0 {
+		timeout = refOpts.Consumer.RequestTimeout
+	}
+
 	// cfgURL is an interface-level invoker url, in the other words, it represents an interface.
 	cfgURL := common.NewURLWithOptions(
 		common.WithPath(ref.InterfaceName),
@@ -134,8 +141,7 @@
 		common.WithParams(refOpts.getURLMap()),
 		common.WithParamsValue(constant.BeanNameKey, refOpts.id),
 		common.WithParamsValue(constant.MetadataTypeKey, refOpts.metaDataType),
-		// TODO: remove TimeoutKey after old confid removed
-		common.WithParamsValue(constant.TimeoutKey, refOpts.Consumer.RequestTimeout),
+		common.WithParamsValue(constant.TimeoutKey, timeout),
 
 		// Compatibility: propagate legacy reference-level keepalive settings.
 		// TODO: remove KeepAliveInterval and KeepAliveTimeout in version 4.0.0.
diff --git a/client/action_test.go b/client/action_test.go
index fb2834c..09139a4 100644
--- a/client/action_test.go
+++ b/client/action_test.go
@@ -21,6 +21,7 @@
 	"os"
 	"strconv"
 	"testing"
+	"time"
 )
 
 import (
@@ -28,9 +29,13 @@
 )
 
 import (
+	"dubbo.apache.org/dubbo-go/v3/cluster/cluster"
+	"dubbo.apache.org/dubbo-go/v3/cluster/directory"
 	"dubbo.apache.org/dubbo-go/v3/common"
 	"dubbo.apache.org/dubbo-go/v3/common/constant"
+	"dubbo.apache.org/dubbo-go/v3/common/extension"
 	"dubbo.apache.org/dubbo-go/v3/global"
+	"dubbo.apache.org/dubbo-go/v3/protocol/base"
 )
 
 func TestGetEnv(t *testing.T) {
@@ -226,3 +231,73 @@
 	require.Error(t, err)
 	require.Contains(t, err.Error(), "no urls available")
 }
+
+type timeoutCaptureProtocol struct{ captured *common.URL }
+
+func (p *timeoutCaptureProtocol) Export(base.Invoker) base.Exporter { return nil }
+func (p *timeoutCaptureProtocol) Destroy()                          {}
+func (p *timeoutCaptureProtocol) Refer(u *common.URL) base.Invoker {
+	p.captured = u
+	return base.NewBaseInvoker(u)
+}
+
+type timeoutCaptureCluster struct{}
+
+func (timeoutCaptureCluster) Join(d directory.Directory) base.Invoker {
+	return base.NewBaseInvoker(d.GetURL())
+}
+
+func TestReferenceTimeoutPrecedence(t *testing.T) {
+	tests := []struct {
+		name string
+		opts []ReferenceOption
+		want string
+	}{
+		{"reference timeout wins over consumer", []ReferenceOption{WithRequestTimeout(500 * time.Millisecond)}, "500ms"},
+		{"consumer timeout is the fallback", nil, "3s"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			p := &timeoutCaptureProtocol{}
+			extension.SetProtocol("timeout-precedence", func() base.Protocol { return p })
+			extension.SetCluster("timeout-precedence", func() cluster.Cluster { return timeoutCaptureCluster{} })
+			t.Cleanup(func() { extension.UnregisterProtocol("timeout-precedence") })
+
+			opts := defaultReferenceOptions()
+			opts.Consumer = global.DefaultConsumerConfig()
+			opts.Consumer.RequestTimeout = "3s"
+			for _, o := range tt.opts {
+				o(opts)
+			}
+			opts.Reference.InterfaceName = "example.TimeoutService"
+			opts.Reference.Protocol = "timeout-precedence"
+			opts.Reference.Cluster = "timeout-precedence"
+			opts.Reference.URL = "timeout-precedence://127.0.0.1:20000"
+			opts.Reference.Filter = "-default"
+			opts.Refer()
+
+			require.NotNil(t, p.captured)
+			require.Equal(t, tt.want, p.captured.GetParam(constant.TimeoutKey, ""))
+		})
+	}
+}
+
+func TestDialRequestTimeoutDoesNotLeakAcrossDials(t *testing.T) {
+	p := &timeoutCaptureProtocol{}
+	extension.SetProtocol("timeout-leak", func() base.Protocol { return p })
+	extension.SetCluster("timeout-leak", func() cluster.Cluster { return timeoutCaptureCluster{} })
+	t.Cleanup(func() { extension.UnregisterProtocol("timeout-leak") })
+
+	cli, err := NewClient()
+	require.NoError(t, err)
+	cli.cliOpts.Consumer.RequestTimeout = "3s"
+
+	dial := func(opts ...ReferenceOption) string {
+		opts = append(opts, WithProtocol("timeout-leak"), WithURL("timeout-leak://127.0.0.1:20000"), WithFilter("-default"), WithCluster("timeout-leak"))
+		_, err := cli.Dial("example.TimeoutService", opts...)
+		require.NoError(t, err)
+		return p.captured.GetParam(constant.TimeoutKey, "")
+	}
+	require.Equal(t, "500ms", dial(WithRequestTimeout(500*time.Millisecond)))
+	require.Equal(t, "3s", dial())
+}
diff --git a/client/client.go b/client/client.go
index 21c39be..6bef6c8 100644
--- a/client/client.go
+++ b/client/client.go
@@ -207,7 +207,7 @@
 func (cli *Client) dial(interfaceName string, info *ClientInfo, srv any, opts ...ReferenceOption) (*Connection, error) {
 	newRefOpts := defaultReferenceOptions()
 	finalOpts := []ReferenceOption{
-		setReference(cli.cliOpts.overallReference),
+		setReference(cli.cliOpts.overallReference.Clone()),
 		setApplication(cli.cliOpts.Application),
 		setRegistries(cli.cliOpts.Registries),
 		setConsumer(cli.cliOpts.Consumer),