diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go index e437cae342..3e135e3eb9 100644 --- a/backend/internal/pkg/proxyutil/dialer.go +++ b/backend/internal/pkg/proxyutil/dialer.go @@ -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) } diff --git a/backend/internal/pkg/proxyutil/dialer_timeout_test.go b/backend/internal/pkg/proxyutil/dialer_timeout_test.go new file mode 100644 index 0000000000..212bf0adac --- /dev/null +++ b/backend/internal/pkg/proxyutil/dialer_timeout_test.go @@ -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") +} diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 68a986648a..5a56d4bf7c 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -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, diff --git a/backend/internal/repository/http_upstream_dial_timeout_test.go b/backend/internal/repository/http_upstream_dial_timeout_test.go new file mode 100644 index 0000000000..00d6ce1a41 --- /dev/null +++ b/backend/internal/repository/http_upstream_dial_timeout_test.go @@ -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 必须立即中止拨号") +}