Merge pull request #5231 from fengshao1227/fix/upstream-dial-timeout

fix(upstream): set explicit TCP dial timeout on upstream transports
This commit is contained in:
Wesley Liddick
2026-08-07 16:24:53 +08:00
committed by GitHub
4 changed files with 180 additions and 1 deletions
+20 -1
View File
@@ -16,10 +16,29 @@ import (
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/proxy"
)
const (
// socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。
socks5DialTimeout = 10 * time.Second
// socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。
socks5DialKeepAlive = 30 * time.Second
)
// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。
//
// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时),
// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会
// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径
// 无效,必须在这里补上。
var socks5ForwardDialer = &net.Dialer{
Timeout: socks5DialTimeout,
KeepAlive: socks5DialKeepAlive,
}
// ConfigureTransportProxy 根据代理 URL 配置 Transport
//
// 支持的协议:
@@ -45,7 +64,7 @@ func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error
return nil
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer)
if err != nil {
return fmt.Errorf("create socks5 dialer: %w", err)
}
@@ -0,0 +1,58 @@
package proxyutil
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var errStub = errors.New("stub dial")
// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext,
// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer,
// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。
func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) {
require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0))
require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout)
require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive)
}
func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) {
for _, scheme := range []string{"socks5", "socks5h"} {
t.Run(scheme, func(t *testing.T) {
proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080")
require.NoError(t, err)
transport := &http.Transport{}
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
require.NotNil(t, transport.DialContext)
require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy")
})
}
}
// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。
func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) {
proxyURL, err := url.Parse("http://127.0.0.1:8080")
require.NoError(t, err)
called := false
transport := &http.Transport{}
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
called = true
return nil, errStub
}
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
require.NotNil(t, transport.Proxy)
require.NotNil(t, transport.DialContext)
_, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1")
require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext")
}
@@ -55,6 +55,18 @@ const (
// defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟)
// LLM 请求可能排队较久,需要较长超时
defaultResponseHeaderTimeout = 300 * time.Second
// defaultUpstreamDialTimeout: 默认 TCP/DNS 建连超时(10秒)
// Transport 不设置 DialContext 时会退化为零值 net.Dialer(无超时),建连阶段
// 只能依赖内核默认 TCP 重传(Linux 约 130 秒)。ResponseHeaderTimeout 只约束
// 连接建立之后等待响应头的阶段,覆盖不到 DNS 解析与 TCP 握手。
// 上游域名被解析到 443 不可达的 IP 时(DNS 污染/路由异常),单个账号就要卡满
// 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误。
defaultUpstreamDialTimeout = 10 * time.Second
// defaultUpstreamDialKeepAlive: TCP keepalive 探测间隔,与 Go 默认值保持一致
defaultUpstreamDialKeepAlive = 30 * time.Second
// defaultUpstreamTLSHandshakeTimeout: TLS 握手超时(10秒)
// 与建连超时同量级,避免 TCP 已连通但对端不推进握手时无限等待
defaultUpstreamTLSHandshakeTimeout = 10 * time.Second
// defaultMaxUpstreamClients: 默认最大客户端缓存数量
// 超出后会淘汰最久未使用的客户端
defaultMaxUpstreamClients = 5000
@@ -1246,6 +1258,17 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
}
}
// newUpstreamDialer 构建上游 Transport 的 TCP dialer。
//
// 必须显式提供:http.Transport 的 DialContext 为 nil 时使用零值 net.Dialer,
// 建连没有任何超时上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
func newUpstreamDialer() *net.Dialer {
return &net.Dialer{
Timeout: defaultUpstreamDialTimeout,
KeepAlive: defaultUpstreamDialKeepAlive,
}
}
// buildUpstreamTransport 构建上游请求的 Transport
// 使用配置文件中的连接池参数,支持生产环境调优
//
@@ -1258,6 +1281,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
// - error: 代理配置错误
//
// Transport 参数说明:
// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传)
// - TLSHandshakeTimeout: TLS 握手超时
// - MaxIdleConns: 所有主机的最大空闲连接总数
// - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率)
// - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待)
@@ -1265,6 +1290,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
// - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输)
func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMode string) (*http.Transport, error) {
transport := &http.Transport{
DialContext: newUpstreamDialer().DialContext,
TLSHandshakeTimeout: defaultUpstreamTLSHandshakeTimeout,
MaxIdleConns: settings.maxIdleConns,
MaxIdleConnsPerHost: settings.maxIdleConnsPerHost,
MaxConnsPerHost: settings.maxConnsPerHost,
@@ -0,0 +1,75 @@
package repository
import (
"context"
"net"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// 回归:上游 Transport 必须显式配置建连超时。
//
// http.Transport.DialContext 为 nil 时 Go 使用零值 net.Dialer(Timeout=0),
// DNS 解析与 TCP 握手没有任何上限,只能等内核重传耗尽(Linux 约 130 秒)。
// ResponseHeaderTimeout 只覆盖连接建立之后的阶段,管不到建连。
// 上游域名被解析到不可达 IP 时,串行的多账号故障转移会把一次请求拖到数分钟。
func TestBuildUpstreamTransportSetsDialTimeout(t *testing.T) {
settings := defaultPoolSettings(nil)
transport, err := buildUpstreamTransport(settings, nil, upstreamProtocolModeDefault)
require.NoError(t, err)
require.NotNil(t, transport.DialContext, "DialContext 缺失会退化为无超时的零值 dialer")
require.Equal(t, defaultUpstreamTLSHandshakeTimeout, transport.TLSHandshakeTimeout)
}
func TestNewUpstreamDialerHasBoundedTimeout(t *testing.T) {
dialer := newUpstreamDialer()
require.Greater(t, dialer.Timeout, time.Duration(0), "建连超时必须有上限")
require.Equal(t, defaultUpstreamDialTimeout, dialer.Timeout)
require.Equal(t, defaultUpstreamDialKeepAlive, dialer.KeepAlive)
}
// 建连超时对 HTTP 代理同样生效:Transport.Proxy 走的仍是 DialContext,
// 代理地址不可达时必须快速失败而不是挂满内核超时。
func TestBuildUpstreamTransportKeepsDialTimeoutWithHTTPProxy(t *testing.T) {
proxyURL, err := url.Parse("http://127.0.0.1:1080")
require.NoError(t, err)
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
require.NoError(t, err)
require.NotNil(t, transport.Proxy)
require.NotNil(t, transport.DialContext)
}
// SOCKS5 分支会覆盖 Transport.DialContext,覆盖后仍必须是有超时的拨号器。
func TestBuildUpstreamTransportKeepsDialContextWithSOCKS5Proxy(t *testing.T) {
proxyURL, err := url.Parse("socks5h://127.0.0.1:1080")
require.NoError(t, err)
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
require.NoError(t, err)
require.NotNil(t, transport.DialContext)
}
// Timeout 字段确实被 net.Dialer 用于建连:拨一个已被 close 的本地监听端口,
// 断言 Dialer 走的是自己的超时路径而不是无限等待。
// (不依赖外网可达性,CI 中确定性执行。)
func TestUpstreamDialerRespectsContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
ctx, cancel := context.WithCancel(context.Background())
cancel()
conn, err := newUpstreamDialer().DialContext(ctx, "tcp", addr)
if conn != nil {
_ = conn.Close()
}
require.Error(t, err, "已取消的 context 必须立即中止拨号")
}