mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:38:22 +08:00
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:
@@ -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 必须立即中止拨号")
|
||||
}
|
||||
Reference in New Issue
Block a user