Repository: cloudwego/hertz
Pull request #1368 (2025-05-29 09:41:59)

fix(client): set timeout for tls handshake
#### What type of PR is this?
fix

<!--
Add one of the following kinds:

build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
docs: Documentation only changes
feat: A new feature
optimize: A new optimization
fix: A bug fix
perf: A code change that improves performance
refactor: A code change that neither fixes a bug nor adds a feature
style: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc)
test: Adding missing tests or correcting existing tests
chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->

#### Check the PR title.
<!--
The description of the title will be attached in Release Notes, 
so please describe it from user-oriented, what this PR does / why we need it.
Please check your PR title with the below requirements:
-->
- [ ] This PR title match the format: \<type\>(optional scope): \<description\>
- [ ] The description of this PR title is user-oriented and clear enough for others to understand.
- [ ] Attach the PR updating the user documentation if the current PR requires user awareness at the usage level. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)


#### (Optional) Translate the PR title into Chinese.


#### (Optional) More detailed description for this PR(en: English/zh: Chinese).
<!--
Provide more detailed info for review(e.g., it's recommended to provide perf data if this is a perf type PR).
-->
en:
zh(optional):

#### (Optional) Which issue(s) this PR fixes:
<!--
Automatically closes linked issue when PR is merged.
Eg: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->

#### (Optional) The PR that updates user documentation:
<!--
If the current PR requires user awareness at the usage level, please submit a PR to update user docs. [User docs repo](https://github.com/cloudwego/cloudwego.github.io)
-->


Patch:
diff --git a/pkg/protocol/http1/client.go b/pkg/protocol/http1/client.go
index c49da74..442c9b8 100644
--- a/pkg/protocol/http1/client.go
+++ b/pkg/protocol/http1/client.go
@@ -472,51 +472,48 @@ func (c *HostClient) do(req *protocol.Request, resp *protocol.Response) (bool, e
 	return canIdempotentRetry, err
 }
 
-type requestConfig struct {
-	dialTimeout  time.Duration
-	readTimeout  time.Duration
-	writeTimeout time.Duration
-}
-
-func (c *HostClient) preHandleConfig(o *config.RequestOptions) requestConfig {
-	rc := requestConfig{
-		dialTimeout:  c.DialTimeout,
-		readTimeout:  c.ReadTimeout,
-		writeTimeout: c.WriteTimeout,
+func minTimeout(d0, d1 time.Duration) time.Duration {
+	if d0 <= 0 {
+		if d1 <= 0 {
+			return 0
+		}
+		return d1
 	}
-	if o.ReadTimeout() > 0 {
-		rc.readTimeout = o.ReadTimeout()
+	if d1 <= 0 {
+		return d0
 	}
-
-	if o.WriteTimeout() > 0 {
-		rc.writeTimeout = o.WriteTimeout()
+	if d0 < d1 {
+		return d0
 	}
+	return d1
+}
 
-	if o.DialTimeout() > 0 {
-		rc.dialTimeout = o.DialTimeout()
+func timeUntil(deadline time.Time) time.Duration {
+	timeout := time.Until(deadline)
+	if timeout <= 0 {
+		return -1
 	}
-
-	return rc
+	return timeout
 }
 
-func updateReqTimeout(reqTimeout, compareTimeout time.Duration, before time.Time) (shouldCloseConn bool, timeout time.Duration) {
-	if reqTimeout <= 0 {
-		return false, compareTimeout
+// calcTimeout checks deadline and returns timeout for conn.SetXXXTimeout
+//
+// returns 0 which means no timeout
+// returns -1 if deadline exceeded
+func calcTimeout(deadline time.Time, timeout time.Duration) time.Duration {
+	if timeout <= 0 {
+		if deadline.IsZero() {
+			return 0
+		}
+		return timeUntil(deadline)
 	}
-	left := reqTimeout - time.Since(before)
-	if left <= 0 {
-		return true, 0
+	if deadline.IsZero() {
+		return timeout // must > 0
 	}
-
-	if compareTimeout <= 0 {
-		return false, left
+	if d := timeUntil(deadline); d < timeout {
+		return d
 	}
-
-	if left > compareTimeout {
-		return false, compareTimeout
-	}
-
-	return false, left
+	return timeout
 }
 
 func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Response) (bool, error) {
@@ -529,8 +526,6 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
 
 	atomic.StoreUint32(&c.lastUseTime, uint32(time.Now().Unix()-startTimeUnix))
 
-	rc := c.preHandleConfig(req.Options())
-
 	// Free up resources occupied by response before sending the request,
 	// so the GC may reclaim these resources (e.g. response body).
 	// backing up SkipBody in case it was set explicitly
@@ -541,19 +536,46 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
 	if c.DisablePathNormalizing {
 		req.URI().DisablePathNormalizing = true
 	}
-	reqTimeout := req.Options().RequestTimeout()
-	begin := req.Options().StartTime()
 
-	dialTimeout := rc.dialTimeout
-	if (reqTimeout > 0 && reqTimeout < dialTimeout) || dialTimeout == 0 {
-		dialTimeout = reqTimeout
+	o := req.Options()
+	deadline := time.Time{}
+	if v := o.RequestTimeout(); v > 0 {
+		deadline = o.StartTime().Add(v)
 	}
-	cc, inPool, err := c.acquireConn(dialTimeout)
-	// if getting connection error, fast fail
+
+	// dial starts
+
+	timeout := calcTimeout(deadline, minTimeout(c.DialTimeout, o.DialTimeout()))
+	if timeout < 0 {
+		return false, errTimeout
+	}
+	cc, inPool, err := c.acquireConn(timeout)
 	if err != nil {
 		return false, err
 	}
 	conn := cc.c
+	resp.ParseNetAddr(conn)
+
+	if c.IsTLS && timeout > 0 { // force handshake using dial timeout
+		// NOTE: Handshake() here is optional as Write would tirigger handshake
+		// but for tls handshake, it writes and reads, and we need to set deadline for that.
+		tlsconn, ok := conn.(network.ConnTLSer)
+		if ok {
+			// currently netpoll doesn't support conn.SetDeadline nor tls, but crypto/tls.Conn does.
+			// in case netpoll supports tls in the future, may need to change this to
+			// call both conn.SetReadTimeout, and conn.SetWriteTimeout
+			err := conn.SetDeadline(time.Now().Add(timeout))
+			if err == nil {
+				err = tlsconn.Handshake()
+				// NOTE: no need conn.SetDeadline(time.Time{})?
+				// we always reset before Write and Read
+			}
+			if err != nil {
+				c.closeConn(cc)
+				return true, err
+			}
+		}
+	}
 
 	usingProxy := false
 	if c.ProxyURI != nil && bytes.Equal(req.Scheme(), bytestr.StrHTTP) {
@@ -561,17 +583,15 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
 		proxy.SetProxyAuthHeader(&req.Header, c.ProxyURI)
 	}
 
-	resp.ParseNetAddr(conn)
+	// write starts
 
-	shouldClose, timeout := updateReqTimeout(reqTimeout, rc.writeTimeout, begin)
-	if shouldClose {
+	timeout = calcTimeout(deadline, minTimeout(c.WriteTimeout, o.WriteTimeout()))
+	if timeout < 0 {
 		c.closeConn(cc)
 		return false, errTimeout
 	}
-
 	if err = conn.SetWriteTimeout(timeout); err != nil {
 		c.closeConn(cc)
-		// try another connection if retry is enabled
 		return true, err
 	}
 
@@ -595,55 +615,53 @@ func (c *HostClient) doNonNilReqResp(req *protocol.Request, resp *protocol.Respo
 	if resetConnection {
 		req.Header.ResetConnectionClose()
 	}
-
 	if err == nil {
 		err = zw.Flush()
 	}
-	// error happened when writing request, close the connection, and try another connection if retry is enabled
 	if err != nil {
 		defer c.closeConn(cc)
-
 		errNorm, ok := conn.(network.ErrorNormalization)
 		if ok {
 			err = errNorm.ToHertzError(err)
 		}
-
 		if !errors.Is(err, errs.ErrConnectionClosed) {
 			return true, err
 		}
 
-		// set a protection timeout to avoid infinite loop.
-		if conn.SetReadTimeout(time.Second) != nil {
+		// introduced by https://github.com/cloudwego/hertz/pull/412
+		// only for reading 4xx err
+
+		// short period of time (50ms) is enough for this case
+		// NOTE: can't use deadline since it likely already exceeded deadline when write
+		timeout = minTimeout(50*time.Millisecond, minTimeout(c.ReadTimeout, o.ReadTimeout()))
+		if conn.SetReadTimeout(timeout) != nil {
 			return true, err
 		}
-
-		// Only if the connection is closed while writing the request. Try to parse the response and return.
-		// In this case, the request/response is considered as successful.
-		// Otherwise, return the former error.
 		zr := c.acquireReader(conn)
 		defer zr.Release()
 		if respI.ReadHeaderAndLimitBody(resp, zr, c.MaxResponseBodySize) == nil {
-			return false, nil
+			if code := resp.StatusCode(); code >= 400 && code < 600 {
+				// strictly for 4xx only, but 5xx is also acceptable.
+				// both can be considered better response rather than write err
+				return false, nil
+			}
 		}
 
 		if inPool {
 			err = errs.ErrBadPoolConn
 		}
-
 		return true, err
 	}
 
-	shouldClose, timeout = updateReqTimeout(reqTimeout, rc.readTimeout, begin)
-	if shouldClose {
+	// read starts
+
+	timeout = calcTimeout(deadline, minTimeout(c.ReadTimeout, o.ReadTimeout()))
+	if timeout < 0 {
 		c.closeConn(cc)
 		return false, errTimeout
 	}
-
-	// Set Deadline every time, since golang has fixed the performance issue
-	// See https://github.com/golang/go/issues/15133#issuecomment-271571395 for details
 	if err = conn.SetReadTimeout(timeout); err != nil {
 		c.closeConn(cc)
-		// try another connection if retry is enabled
 		return true, err
 	}
 


Test patch:
diff --git a/pkg/common/test/mock/network.go b/pkg/common/test/mock/network.go
index 366e335..66f5aba 100644
--- a/pkg/common/test/mock/network.go
+++ b/pkg/common/test/mock/network.go
@@ -18,6 +18,7 @@ package mock
 
 import (
 	"bytes"
+	"crypto/tls"
 	"io"
 	"net"
 	"strings"
@@ -34,10 +35,12 @@ var (
 )
 
 type Conn struct {
-	readTimeout time.Duration
-	zr          network.Reader
-	zw          network.ReadWriter
-	wroteLen    int
+	zr       network.Reader
+	zw       network.ReadWriter
+	wroteLen int
+
+	rtimeout time.Duration
+	wtimeout time.Duration
 }
 
 type Recorder interface {
@@ -59,7 +62,7 @@ func (m *SlowReadConn) SetWriteTimeout(t time.Duration) error {
 }
 
 func (m *SlowReadConn) SetReadTimeout(t time.Duration) error {
-	m.Conn.readTimeout = t
+	m.Conn.rtimeout = t
 	return nil
 }
 
@@ -90,11 +93,11 @@ func (m *Conn) Release() error {
 func (m *Conn) Peek(i int) ([]byte, error) {
 	b, err := m.zr.Peek(i)
 	if err != nil || len(b) != i {
-		if m.readTimeout <= 0 {
+		if m.rtimeout <= 0 {
 			// simulate timeout forever
 			select {}
 		}
-		time.Sleep(m.readTimeout)
+		time.Sleep(m.rtimeout)
 		return nil, errs.ErrTimeout
 	}
 	return b, err
@@ -132,7 +135,11 @@ func (m *Conn) WriterRecorder() Recorder {
 }
 
 func (m *Conn) GetReadTimeout() time.Duration {
-	return m.readTimeout
+	return m.rtimeout
+}
+
+func (m *Conn) GetWriteTimeout() time.Duration {
+	return m.wtimeout
 }
 
 type recorder struct {
@@ -146,8 +153,8 @@ func (r *recorder) WroteLen() int {
 
 func (m *SlowReadConn) Peek(i int) ([]byte, error) {
 	b, err := m.zr.Peek(i)
-	if m.readTimeout > 0 {
-		time.Sleep(m.readTimeout)
+	if m.rtimeout > 0 {
+		time.Sleep(m.rtimeout)
 	} else {
 		time.Sleep(100 * time.Millisecond)
 	}
@@ -283,11 +290,13 @@ func (m *Conn) RemoteAddr() net.Addr {
 }
 
 func (m *Conn) SetDeadline(t time.Time) error {
-	panic("implement me")
+	m.rtimeout = -time.Since(t)
+	m.wtimeout = m.rtimeout
+	return nil
 }
 
 func (m *Conn) SetReadDeadline(t time.Time) error {
-	m.readTimeout = -time.Since(t)
+	m.rtimeout = -time.Since(t)
 	return nil
 }
 
@@ -312,7 +321,7 @@ func (m *Conn) SetIdleTimeout(timeout time.Duration) error {
 }
 
 func (m *Conn) SetReadTimeout(t time.Duration) error {
-	m.readTimeout = t
+	m.rtimeout = t
 	return nil
 }
 
@@ -407,3 +416,23 @@ func (m *MockWriter) Flush() error {
 	}
 	return m.w.Flush()
 }
+
+type TLSConn struct {
+	network.Conn
+
+	HandshakeErr error
+}
+
+var _ network.ConnTLSer = (*TLSConn)(nil)
+
+func (c *TLSConn) Handshake() error {
+	return c.HandshakeErr
+}
+
+func (c *TLSConn) ConnectionState() tls.ConnectionState {
+	return tls.ConnectionState{}
+}
+
+func NewTLSConn(conn network.Conn) *TLSConn {
+	return &TLSConn{Conn: conn}
+}
diff --git a/pkg/common/test/mock/network_test.go b/pkg/common/test/mock/network_test.go
index 34b0769..9839a0d 100644
--- a/pkg/common/test/mock/network_test.go
+++ b/pkg/common/test/mock/network_test.go
@@ -111,9 +111,6 @@ func TestConn(t *testing.T) {
 		assert.DeepEqual(t, nil, conn.LocalAddr())
 		assert.DeepEqual(t, nil, conn.RemoteAddr())
 		assert.DeepEqual(t, nil, conn.SetIdleTimeout(du1))
-		assert.Panic(t, func() {
-			conn.SetDeadline(t1)
-		})
 		assert.Panic(t, func() {
 			conn.SetWriteDeadline(t1)
 		})
@@ -139,7 +136,7 @@ func TestSlowConn(t *testing.T) {
 		conn := NewSlowReadConn(s1)
 		assert.Nil(t, conn.SetWriteTimeout(1))
 		assert.Nil(t, conn.SetReadTimeout(1))
-		assert.DeepEqual(t, time.Duration(1), conn.readTimeout)
+		assert.DeepEqual(t, time.Duration(1), conn.GetReadTimeout())
 
 		b, err := conn.Peek(4)
 		assert.DeepEqual(t, nil, err)
diff --git a/pkg/protocol/http1/client_test.go b/pkg/protocol/http1/client_test.go
index f73bea7..17d0649 100644
--- a/pkg/protocol/http1/client_test.go
+++ b/pkg/protocol/http1/client_test.go
@@ -84,7 +84,7 @@ func TestHostClientMaxConnWaitTimeoutWithEarlierDeadline(t *testing.T) {
 
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.SlowReadDialer(addr)
 			}),
 			MaxConns:           1,
@@ -244,28 +244,22 @@ func testContinueReadResponseBodyStream(t *testing.T, header, body string, maxBo
 	}
 }
 
-func newSlowConnDialer(dialer func(network, addr string, timeout time.Duration) (network.Conn, error)) network.Dialer {
-	return &mockDialer{customDialConn: dialer}
-}
-
-type mockDialer struct {
-	customDialConn func(network, addr string, timeout time.Duration) (network.Conn, error)
-}
+type dialerFunc func(network, addr string, timeout time.Duration) (network.Conn, error)
 
-func (m *mockDialer) DialConnection(network, address string, timeout time.Duration, tlsConfig *tls.Config) (conn network.Conn, err error) {
-	return m.customDialConn(network, address, timeout)
+func (f dialerFunc) DialConnection(network, address string, timeout time.Duration, tlsConfig *tls.Config) (conn network.Conn, err error) {
+	return f(network, address, timeout)
 }
 
-func (m *mockDialer) DialTimeout(network, address string, timeout time.Duration, tlsConfig *tls.Config) (conn net.Conn, err error) {
+func (_ dialerFunc) DialTimeout(network, address string, timeout time.Duration, tlsConfig *tls.Config) (conn net.Conn, err error) {
 	return nil, nil
 }
 
-func (m *mockDialer) AddTLS(conn network.Conn, tlsConfig *tls.Config) (network.Conn, error) {
+func (_ dialerFunc) AddTLS(conn network.Conn, tlsConfig *tls.Config) (network.Conn, error) {
 	return nil, nil
 }
 
 type slowDialer struct {
-	*mockDialer
+	network.Dialer
 }
 
 func (s *slowDialer) DialConnection(network, address string, timeout time.Duration, tlsConfig *tls.Config) (conn network.Conn, err error) {
@@ -276,7 +270,7 @@ func (s *slowDialer) DialConnection(network, address string, timeout time.Durati
 func TestReadTimeoutPriority(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.SlowReadDialer(addr)
 			}),
 			MaxConns:           1,
@@ -306,11 +300,8 @@ func TestReadTimeoutPriority(t *testing.T) {
 func TestDoNonNilReqResp(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
-				return &writeErrConn{
-						Conn: mock.NewConn("HTTP/1.1 400 OK\nContent-Length: 6\n\n123456"),
-					},
-					nil
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+				return mock.NewConn("HTTP/1.1 400 OK\nContent-Length: 6\n\n123456"), nil
 			}),
 		},
 	}
@@ -324,10 +315,85 @@ func TestDoNonNilReqResp(t *testing.T) {
 	assert.DeepEqual(t, resp.Body(), []byte("123456"))
 }
 
+func TestDoNonNilReqResp_WriteErr(t *testing.T) {
+	c := &HostClient{
+		ClientOptions: &ClientOptions{},
+	}
+	req := protocol.AcquireRequest()
+	resp := protocol.AcquireResponse()
+	req.SetHost("foobar")
+	req.SetConnectionClose() // won't reuse the conn
+
+	// 200 with write err, will return write err
+	c.ClientOptions.Dialer = dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+		return &writeErrConn{mock.NewConn("HTTP/1.1 200 OK\nContent-Length: 6\n\n123456")}, nil
+	})
+	retry, err := c.doNonNilReqResp(req, resp)
+	assert.True(t, retry)
+	assert.NotNil(t, err)
+
+	c = &HostClient{
+		ClientOptions: &ClientOptions{
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+				return &writeErrConn{mock.NewConn("HTTP/1.1 400 OK\nContent-Length: 6\n\n123456")}, nil
+			}),
+		},
+	}
+
+	// 400 with write err, will NOT return write err
+	c.ClientOptions.Dialer = dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+		return &writeErrConn{mock.NewConn("HTTP/1.1 400 OK\nContent-Length: 6\n\n123456")}, nil
+	})
+	retry, err = c.doNonNilReqResp(req, resp)
+	assert.False(t, retry)
+	assert.Nil(t, err)
+	assert.DeepEqual(t, resp.StatusCode(), 400)
+	assert.DeepEqual(t, resp.Body(), []byte("123456"))
+}
+
+func TestDoNonNilReqResp_TLS(t *testing.T) {
+	const (
+		dialTimeout = 123 * time.Millisecond
+		dev         = 10 * time.Millisecond
+	)
+	conn := mock.NewConn("HTTP/1.1 200 OK\nContent-Length: 5\n\n54321")
+	tlsconn := mock.NewTLSConn(conn)
+	c := &HostClient{
+		IsTLS: true,
+		ClientOptions: &ClientOptions{
+			DialTimeout: dialTimeout,
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+				return tlsconn, nil
+			}),
+		},
+	}
+	req := protocol.AcquireRequest()
+	resp := protocol.AcquireResponse()
+	req.SetHost("foobar")
+
+	// HandshakeErr != nil
+	tlsconn.HandshakeErr = errors.New("testerr")
+	retry, err := c.doNonNilReqResp(req, resp)
+	assert.True(t, retry)
+	assert.True(t, err == tlsconn.HandshakeErr)
+	if diff := conn.GetReadTimeout() - dialTimeout; diff < -dev || diff > dev {
+		t.Fatal("unexpected timeout. got", conn.GetReadTimeout(), "expect", dialTimeout)
+	}
+	assert.True(t, conn.GetReadTimeout() == conn.GetWriteTimeout())
+
+	// HandshakeErr == nil
+	tlsconn.HandshakeErr = nil
+	retry, err = c.doNonNilReqResp(req, resp)
+	assert.False(t, retry)
+	assert.Nil(t, err)
+	assert.DeepEqual(t, resp.StatusCode(), 200)
+	assert.DeepEqual(t, resp.Body(), []byte("54321"))
+}
+
 func TestDoNonNilReqResp_Err(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return peekErrConn{writeErrConn{mock.NewConn("")}}, nil
 			}),
 		},
@@ -439,7 +505,7 @@ func TestStreamResponse_ConnUpgrade(t *testing.T) {
 func TestWriteTimeoutPriority(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.SlowWriteDialer(addr)
 			}),
 			MaxConns:           1,
@@ -501,7 +567,7 @@ func TestStateObserve(t *testing.T) {
 	}{}
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.SlowReadDialer(addr)
 			}),
 			StateObserve: func(hcs config.HostClientState) {
@@ -529,7 +595,7 @@ func TestStateObserve(t *testing.T) {
 func TestCachedTLSConfig(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.SlowReadDialer(addr)
 			}),
 			TLSConfig: &tls.Config{
@@ -551,7 +617,7 @@ func TestRetry(t *testing.T) {
 	var times int32
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				times++
 				if times < 3 {
 					return &retryConn{
@@ -619,7 +685,7 @@ func (w retryConn) SetWriteTimeout(t time.Duration) error {
 func TestConnInPoolRetry(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.NewOneTimeConn("HTTP/1.1 200 OK\r\nContent-Length: 10\r\nContent-Type: foo/bar\r\n\r\n0123456789"), nil
 			}),
 		},
@@ -651,7 +717,7 @@ func TestConnInPoolRetry(t *testing.T) {
 func TestConnNotRetry(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return mock.NewBrokenConn(""), nil
 			}),
 		},
@@ -691,7 +757,7 @@ func TestStreamNoContent(t *testing.T) {
 
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
-			Dialer: newSlowConnDialer(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
 				return conn, nil
 			}),
 		},
@@ -714,12 +780,10 @@ func TestDialTimeout(t *testing.T) {
 	c := &HostClient{
 		ClientOptions: &ClientOptions{
 			DialTimeout: time.Second * 10,
-			Dialer: &mockDialer{
-				customDialConn: func(network, addr string, timeout time.Duration) (network.Conn, error) {
-					assert.DeepEqual(t, time.Second*10, timeout)
-					return nil, errors.New("test error")
-				},
-			},
+			Dialer: dialerFunc(func(network, addr string, timeout time.Duration) (network.Conn, error) {
+				assert.DeepEqual(t, time.Second*10, timeout)
+				return nil, errors.New("test error")
+			}),
 		},
 		Addr: "foobar",
 	}
@@ -740,3 +804,62 @@ func TestContextNil(t *testing.T) {
 	c := &HostClient{}
 	c.Do(nil, nil, nil) //nolint:staticcheck // SA1012: do not pass a nil Context
 }
+
+func TestMinTimeout(t *testing.T) {
+	tests := []struct {
+		name     string
+		d0       time.Duration
+		d1       time.Duration
+		expected time.Duration
+	}{
+		{"both positive, d0 < d1", 1 * time.Second, 2 * time.Second, 1 * time.Second},
+		{"both positive, d0 > d1", 3 * time.Second, 1 * time.Second, 1 * time.Second},
+		{"both positive, d0 = d1", 2 * time.Second, 2 * time.Second, 2 * time.Second},
+		{"d0 zero, d1 positive", 0, 2 * time.Second, 2 * time.Second},
+		{"d0 negative, d1 positive", -1 * time.Second, 2 * time.Second, 2 * time.Second},
+		{"d0 positive, d1 zero", 2 * time.Second, 0, 2 * time.Second},
+		{"d0 positive, d1 negative", 2 * time.Second, -1 * time.Second, 2 * time.Second},
+		{"both zero", 0, 0, 0},
+		{"both negative", -1 * time.Second, -2 * time.Second, 0},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := minTimeout(tt.d0, tt.d1)
+			if result != tt.expected {
+				t.Errorf("minTimeout(%v, %v) = %v, expected %v", tt.d0, tt.d1, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestCalcimeout(t *testing.T) {
+	now := time.Now()
+
+	tests := []struct {
+		name     string
+		deadline time.Time
+		timeout  time.Duration
+		expected time.Duration
+	}{
+		{"zero deadline, positive timeout", time.Time{}, 5 * time.Second, 5 * time.Second},
+		{"zero deadline, zero timeout", time.Time{}, 0, 0},
+		{"zero deadline, negative timeout", time.Time{}, -1 * time.Second, 0},
+		{"future deadline, zero timeout", now.Add(10 * time.Second), 0, 10 * time.Second},
+		{"future deadline, positive timeout (deadline < timeout)", now.Add(3 * time.Second), 5 * time.Second, 3 * time.Second},
+		{"future deadline, positive timeout (deadline > timeout)", now.Add(8 * time.Second), 5 * time.Second, 5 * time.Second},
+		{"past deadline, zero timeout", now.Add(-5 * time.Second), 0, -1},
+		{"past deadline, positive timeout", now.Add(-5 * time.Second), time.Second, -1},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := calcTimeout(tt.deadline, tt.timeout)
+			diff := result - tt.expected
+			if diff < -50*time.Millisecond || diff > 50*time.Millisecond {
+				t.Errorf("calcTimeout(%v, %v) = %v, expected %v",
+					tt.deadline, tt.timeout, result, tt.expected)
+			}
+		})
+	}
+}