feat: add per-channel HTTP transport controls

This commit is contained in:
CaIon
2026-07-27 21:41:13 +08:00
parent b27b2b1d6f
commit e99a9bd86f
24 changed files with 1330 additions and 81 deletions
+176 -40
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
@@ -12,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/system_setting"
"golang.org/x/net/proxy"
@@ -30,7 +32,7 @@ var (
type proxyHTTPClientCache struct {
mutex sync.RWMutex
clients map[string]*http.Client
aliases map[string]string
aliases map[string]string // rawProxyURL -> canonicalProxyURL
}
type proxyURLConfig struct {
@@ -96,7 +98,7 @@ func newRelayHTTPTransport() *http.Transport {
return transport
}
func newRelayHTTPClient(transport *http.Transport) *http.Client {
func newRelayHTTPClient(transport http.RoundTripper) *http.Client {
client := &http.Client{
Transport: transport,
CheckRedirect: checkRedirect,
@@ -107,10 +109,14 @@ func newRelayHTTPClient(transport *http.Transport) *http.Client {
return client
}
func clientCacheKey(proxyCacheKey string, policy HTTPTransportPolicy) string {
return proxyCacheKey + "\x00" + policy.cacheKeyPart()
}
func InitHttpClient() {
transport := newRelayHTTPTransport()
transport.Proxy = http.ProxyFromEnvironment
httpClient = newRelayHTTPClient(transport)
policy := defaultHTTPTransportPolicy()
httpClient = newDirectHTTPClient(policy, nil)
proxyClients.store(clientCacheKey("", policy), httpClient)
ssrfProtectedHTTPClient = newProtectedFetchHTTPClient()
}
@@ -177,45 +183,73 @@ func ValidateProxyURL(rawProxyURL string) error {
return err
}
func (cache *proxyHTTPClientCache) get(rawCacheKey string) (*http.Client, bool) {
func (cache *proxyHTTPClientCache) store(fullKey string, client *http.Client) {
cache.mutex.Lock()
defer cache.mutex.Unlock()
cache.clients[fullKey] = client
}
func (cache *proxyHTTPClientCache) resolveProxyKey(rawProxyURL string) string {
if canonicalKey, ok := cache.aliases[rawProxyURL]; ok {
return canonicalKey
}
return rawProxyURL
}
func (cache *proxyHTTPClientCache) get(rawProxyURL string, policy HTTPTransportPolicy) (*http.Client, bool) {
cache.mutex.RLock()
defer cache.mutex.RUnlock()
cacheKey := rawCacheKey
if canonicalKey, ok := cache.aliases[rawCacheKey]; ok {
cacheKey = canonicalKey
}
client, ok := cache.clients[cacheKey]
proxyKey := cache.resolveProxyKey(rawProxyURL)
client, ok := cache.clients[clientCacheKey(proxyKey, policy)]
return client, ok
}
func (cache *proxyHTTPClientCache) getOrCreate(rawCacheKey string, config *proxyURLConfig) (*http.Client, error) {
func (cache *proxyHTTPClientCache) getOrCreate(
rawProxyURL string,
config *proxyURLConfig,
policy HTTPTransportPolicy,
factory func() (*http.Client, error),
) (*http.Client, error) {
cache.mutex.Lock()
defer cache.mutex.Unlock()
if client, ok := cache.clients[config.cacheKey]; ok {
cache.aliases[rawCacheKey] = config.cacheKey
proxyKey := ""
if config != nil {
proxyKey = config.cacheKey
cache.aliases[rawProxyURL] = proxyKey
} else if rawProxyURL != "" {
proxyKey = cache.resolveProxyKey(rawProxyURL)
}
fullKey := clientCacheKey(proxyKey, policy)
if client, ok := cache.clients[fullKey]; ok {
return client, nil
}
client, err := newProxyHTTPClient(config.parsedURL)
client, err := factory()
if err != nil {
return nil, err
}
cache.clients[config.cacheKey] = client
cache.aliases[rawCacheKey] = config.cacheKey
cache.clients[fullKey] = client
return client, nil
}
func (cache *proxyHTTPClientCache) remove(cacheKey string) *http.Client {
func (cache *proxyHTTPClientCache) removeProxy(proxyCacheKey string) []*http.Client {
cache.mutex.Lock()
defer cache.mutex.Unlock()
client := cache.clients[cacheKey]
delete(cache.clients, cacheKey)
removed := make([]*http.Client, 0)
prefix := proxyCacheKey + "\x00"
for key, client := range cache.clients {
if strings.HasPrefix(key, prefix) {
removed = append(removed, client)
delete(cache.clients, key)
}
}
for alias, canonicalKey := range cache.aliases {
if canonicalKey == cacheKey {
if canonicalKey == proxyCacheKey {
delete(cache.aliases, alias)
}
}
return client
return removed
}
func (cache *proxyHTTPClientCache) reset() map[string]*http.Client {
@@ -227,13 +261,11 @@ func (cache *proxyHTTPClientCache) reset() map[string]*http.Client {
return oldClients
}
func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) {
transport := newRelayHTTPTransport()
func configureProxyTransport(transport *http.Transport, proxyURL *url.URL) error {
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
return nil
case "socks5", "socks5h":
transport.Proxy = nil
forwardDialer := &net.Dialer{
@@ -242,31 +274,104 @@ func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) {
}
dialer, err := proxy.FromURL(proxyURL, forwardDialer)
if err != nil {
return nil, err
return err
}
contextDialer, ok := dialer.(proxy.ContextDialer)
if !ok {
return nil, fmt.Errorf("SOCKS proxy dialer does not support context cancellation")
return fmt.Errorf("SOCKS proxy dialer does not support context cancellation")
}
transport.DialContext = contextDialer.DialContext
return nil
default:
return nil, fmt.Errorf("unsupported proxy scheme")
return fmt.Errorf("unsupported proxy scheme")
}
}
return newRelayHTTPClient(transport), nil
func newTransportFactory(proxyURL *url.URL, tlsConfig *tls.Config) (func() *http.Transport, error) {
// Validate proxy configuration once before creating shard transports.
if proxyURL != nil {
probe := newRelayHTTPTransport()
if err := configureProxyTransport(probe, proxyURL); err != nil {
return nil, err
}
}
return func() *http.Transport {
transport := newRelayHTTPTransport()
if proxyURL != nil {
_ = configureProxyTransport(transport, proxyURL)
} else {
transport.Proxy = http.ProxyFromEnvironment
}
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig.Clone()
}
return transport
}, nil
}
func newHTTPClientFromPolicy(policy HTTPTransportPolicy, proxyURL *url.URL, tlsConfig *tls.Config) (*http.Client, error) {
factory, err := newTransportFactory(proxyURL, tlsConfig)
if err != nil {
return nil, err
}
return newHTTPClientFromTransportFactory(policy, factory), nil
}
func newHTTPClientFromTransportFactory(policy HTTPTransportPolicy, factory func() *http.Transport) *http.Client {
if policy.Shards < 1 {
policy.Shards = 1
}
if policy.Protocol == dto.HTTPProtocolHTTP1 || policy.Shards == 1 {
transport := factory()
applyHTTPTransportPolicy(transport, policy)
return newRelayHTTPClient(transport)
}
shardedFactory := func() *http.Transport {
transport := factory()
applyHTTPTransportPolicy(transport, policy)
return transport
}
return newRelayHTTPClient(newShardedRoundTripper(policy, shardedFactory))
}
func newDirectHTTPClient(policy HTTPTransportPolicy, tlsConfig *tls.Config) *http.Client {
client, err := newHTTPClientFromPolicy(policy, nil, tlsConfig)
if err != nil {
// Direct clients cannot fail proxy configuration.
transport := newRelayHTTPTransport()
applyHTTPTransportPolicy(transport, policy)
return newRelayHTTPClient(transport)
}
return client
}
// newHTTPClientWithPolicyAndTLS is a test seam that builds a never-used transport
// stack with the given policy and TLS config (for httptest certificate trust).
func newHTTPClientWithPolicyAndTLS(policy HTTPTransportPolicy, tlsConfig *tls.Config) *http.Client {
return newDirectHTTPClient(policy, tlsConfig)
}
func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) {
return newHTTPClientFromPolicy(defaultHTTPTransportPolicy(), proxyURL, nil)
}
// GetHttpClientWithProxy returns the default client or a cached proxy-enabled client.
func GetHttpClientWithProxy(rawProxyURL string) (*http.Client, error) {
return GetHttpClientWithProxySettings(rawProxyURL, dto.ChannelSettings{})
}
// GetHttpClientWithProxySettings returns a cached HTTP client for the proxy URL and
// channel transport settings. Default auto + 1 shard shares the same client pool as
// GetHttpClientWithProxy / GetHttpClient for the empty-proxy case.
func GetHttpClientWithProxySettings(rawProxyURL string, settings dto.ChannelSettings) (*http.Client, error) {
policy := NormalizeHTTPTransportPolicy(settings)
trimmedProxyURL := strings.TrimSpace(rawProxyURL)
if trimmedProxyURL == "" {
if client := GetHttpClient(); client != nil {
return client, nil
}
return http.DefaultClient, nil
return getOrCreateDirectClient(policy)
}
if client, ok := proxyClients.get(trimmedProxyURL); ok {
if client, ok := proxyClients.get(trimmedProxyURL, policy); ok {
return client, nil
}
@@ -278,10 +383,31 @@ func GetHttpClientWithProxy(rawProxyURL string) (*http.Client, error) {
if legacySuffixStripped {
warnLegacyProxyURLOnce(config)
}
return proxyClients.getOrCreate(trimmedProxyURL, config)
return proxyClients.getOrCreate(trimmedProxyURL, config, policy, func() (*http.Client, error) {
return newHTTPClientFromPolicy(policy, config.parsedURL, nil)
})
}
// InvalidateProxyClient removes one proxy client and closes its idle connections.
func getOrCreateDirectClient(policy HTTPTransportPolicy) (*http.Client, error) {
defaultPolicy := defaultHTTPTransportPolicy()
if policy == defaultPolicy {
if client := GetHttpClient(); client != nil {
return client, nil
}
// Compatibility with pre-init callers: never assign httpClient outside InitHttpClient.
return http.DefaultClient, nil
}
if client, ok := proxyClients.get("", policy); ok {
return client, nil
}
return proxyClients.getOrCreate("", nil, policy, func() (*http.Client, error) {
return newDirectHTTPClient(policy, nil), nil
})
}
// InvalidateProxyClient removes every cached policy variant for one proxy and
// closes their idle connections (including all HTTP/2 shards).
func InvalidateProxyClient(rawProxyURL string) {
parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(rawProxyURL)
if err != nil || parsedURL == nil {
@@ -291,16 +417,26 @@ func InvalidateProxyClient(rawProxyURL string) {
if legacySuffixStripped {
warnLegacyProxyURLOnce(config)
}
if client := proxyClients.remove(config.cacheKey); client != nil {
for _, client := range proxyClients.removeProxy(config.cacheKey) {
client.CloseIdleConnections()
}
}
// ResetProxyClientCache clears all cached proxy clients.
// ResetProxyClientCache clears cached proxy and non-default direct policy clients
// and closes idle connections on every transport/shard. The package-level default
// httpClient pointer stays stable after InitHttpClient; it is only closed and
// re-registered in the policy cache so concurrent GetHttpClient readers never race
// a pointer replacement.
func ResetProxyClientCache() {
defaultClient := httpClient
for _, client := range proxyClients.reset() {
client.CloseIdleConnections()
}
if defaultClient == nil {
return
}
defaultClient.CloseIdleConnections()
proxyClients.store(clientCacheKey("", defaultHTTPTransportPolicy()), defaultClient)
}
// NewProxyHttpClient is kept for compatibility.
+491
View File
@@ -0,0 +1,491 @@
package service
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func withRelayHTTPTransportSettings(t *testing.T) {
t.Helper()
prevMaxIdle := common.RelayMaxIdleConns
prevPerHost := common.RelayMaxIdleConnsPerHost
prevTimeout := common.RelayIdleConnTimeout
common.RelayMaxIdleConns = 500
common.RelayMaxIdleConnsPerHost = 100
common.RelayIdleConnTimeout = 90
t.Cleanup(func() {
common.RelayMaxIdleConns = prevMaxIdle
common.RelayMaxIdleConnsPerHost = prevPerHost
common.RelayIdleConnTimeout = prevTimeout
})
}
func initDefaultHTTPClientFixture(t *testing.T) *http.Client {
t.Helper()
withRelayHTTPTransportSettings(t)
if httpClient == nil {
InitHttpClient()
} else {
ResetProxyClientCache()
}
require.NotNil(t, httpClient)
t.Cleanup(ResetProxyClientCache)
return httpClient
}
func TestShardedRoundTripperPerOriginRotation(t *testing.T) {
s := &shardedRoundTripper{n: 4}
originA := "https://a.example:443"
originB := "https://b.example:443"
gotA := make([]uint32, 0, 8)
for i := 0; i < 8; i++ {
gotA = append(gotA, s.pickShard(originA))
}
assert.Equal(t, []uint32{0, 1, 2, 3, 0, 1, 2, 3}, gotA)
gotB := make([]uint32, 0, 4)
for i := 0; i < 4; i++ {
gotB = append(gotB, s.pickShard(originB))
}
assert.Equal(t, []uint32{0, 1, 2, 3}, gotB, "independent origins must have independent counters")
var wg sync.WaitGroup
const workers = 32
const perWorker = 50
var badShardCount atomic.Uint32
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for j := 0; j < perWorker; j++ {
idx := s.pickShard(originA)
if idx >= 4 {
badShardCount.Add(1)
}
}
}()
}
wg.Wait()
assert.Equal(t, uint32(0), badShardCount.Load())
}
func TestOriginKeyUsesSchemeAndHost(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "HTTPS://Example.COM:8443/path", nil)
assert.Equal(t, "https://Example.COM:8443", originKey(req))
}
func testTLSClientConfig(t *testing.T, server *httptest.Server) *tls.Config {
t.Helper()
pool := x509.NewCertPool()
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
require.True(t, pool.AppendCertsFromPEM(certPEM))
return &tls.Config{RootCAs: pool}
}
func startHTTP2TLSServer(t *testing.T, handler http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(handler)
server.EnableHTTP2 = true
server.StartTLS()
t.Cleanup(server.Close)
return server
}
func drainClose(t *testing.T, resp *http.Response) {
t.Helper()
require.NotNil(t, resp)
_, err := io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}
func TestAutoOneShardNegotiatesHTTP2SingleConnection(t *testing.T) {
withRelayHTTPTransportSettings(t)
var mu sync.Mutex
addrs := make(map[string]struct{})
var sawHTTP2 atomic.Bool
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs[r.RemoteAddr] = struct{}{}
mu.Unlock()
if r.ProtoMajor == 2 {
sawHTTP2.Store(true)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
client := newHTTPClientWithPolicyAndTLS(defaultHTTPTransportPolicy(), testTLSClientConfig(t, server))
for i := 0; i < 4; i++ {
resp, err := client.Get(server.URL)
require.NoError(t, err)
assert.Equal(t, 2, resp.ProtoMajor)
drainClose(t, resp)
}
mu.Lock()
defer mu.Unlock()
assert.True(t, sawHTTP2.Load())
assert.Len(t, addrs, 1, "auto+1 must reuse a single HTTP/2 connection")
}
func TestFourShardHTTP2ReusesExactlyFourConnections(t *testing.T) {
withRelayHTTPTransportSettings(t)
var mu sync.Mutex
addrs := make(map[string]struct{})
var nonHTTP2Count atomic.Uint32
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs[r.RemoteAddr] = struct{}{}
mu.Unlock()
if r.ProtoMajor != 2 {
nonHTTP2Count.Add(1)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 4}
client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server))
for i := 0; i < 8; i++ {
resp, err := client.Get(server.URL)
require.NoError(t, err)
assert.Equal(t, 2, resp.ProtoMajor)
drainClose(t, resp)
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, uint32(0), nonHTTP2Count.Load())
assert.Len(t, addrs, 4, "four shards must establish and reuse exactly four connections")
}
func TestForcedHTTP1AgainstHTTP2Server(t *testing.T) {
withRelayHTTPTransportSettings(t)
var nonHTTP1Count atomic.Uint32
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor != 1 {
nonHTTP1Count.Add(1)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}
client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server))
resp, err := client.Get(server.URL)
require.NoError(t, err)
assert.Equal(t, 1, resp.ProtoMajor)
drainClose(t, resp)
assert.Equal(t, uint32(0), nonHTTP1Count.Load())
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
assert.False(t, transport.DisableKeepAlives)
assert.False(t, transport.ForceAttemptHTTP2)
assert.NotNil(t, transport.TLSNextProto)
assert.Len(t, transport.TLSNextProto, 0)
}
func TestForcedHTTP1ConcurrentDistinctConnections(t *testing.T) {
withRelayHTTPTransportSettings(t)
const k = 8
var mu sync.Mutex
addrs := make(map[string]struct{})
arrived := make(chan struct{}, k)
release := make(chan struct{})
var nonHTTP1Count atomic.Uint32
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs[r.RemoteAddr] = struct{}{}
mu.Unlock()
if r.ProtoMajor != 1 {
nonHTTP1Count.Add(1)
}
arrived <- struct{}{}
<-release
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}
client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server))
errCh := make(chan error, k)
for i := 0; i < k; i++ {
go func() {
resp, err := client.Get(server.URL)
if err != nil {
errCh <- err
return
}
if resp.ProtoMajor != 1 {
errCh <- fmt.Errorf("expected HTTP/1.x, got %s", resp.Proto)
_ = resp.Body.Close()
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
errCh <- nil
}()
}
for i := 0; i < k; i++ {
<-arrived
}
mu.Lock()
activeAddrs := len(addrs)
mu.Unlock()
close(release)
for i := 0; i < k; i++ {
require.NoError(t, <-errCh)
}
assert.Equal(t, uint32(0), nonHTTP1Count.Load())
assert.Equal(t, k, activeAddrs, "all HTTP/1.1 handlers active together must use K distinct connections")
}
func TestHTTPClientCachePolicyAndCompatibility(t *testing.T) {
defaultClient := initDefaultHTTPClientFixture(t)
compat, err := GetHttpClientWithProxy("")
require.NoError(t, err)
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, defaultClient, compat)
assert.Same(t, compat, aware)
assert.Same(t, GetHttpClient(), aware)
http1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
assert.NotSame(t, aware, http1)
sharded, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 4})
require.NoError(t, err)
assert.NotSame(t, aware, sharded)
assert.NotSame(t, http1, sharded)
proxyA := "http://proxy.example:8080"
proxyAlias := "http://proxy.example:8080/"
clientA, err := GetHttpClientWithProxy(proxyA)
require.NoError(t, err)
clientAlias, err := GetHttpClientWithProxy(proxyAlias)
require.NoError(t, err)
assert.Same(t, clientA, clientAlias, "canonical proxy aliases must share the default policy client")
proxyHTTP1, err := GetHttpClientWithProxySettings(proxyA, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
assert.NotSame(t, clientA, proxyHTTP1)
}
func TestHTTPClientCacheConcurrentGetOrCreate(t *testing.T) {
initDefaultHTTPClientFixture(t)
proxyURL := "http://concurrent-proxy.example:9090"
const workers = 32
results := make([]*http.Client, workers)
errs := make([]error, workers)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
i := i
go func() {
defer wg.Done()
client, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 3})
errs[i] = err
results[i] = client
}()
}
wg.Wait()
for i := 0; i < workers; i++ {
require.NoError(t, errs[i])
}
for i := 1; i < workers; i++ {
assert.Same(t, results[0], results[i])
}
}
type closeCountingRoundTripper struct {
closes atomic.Int32
}
func (c *closeCountingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
Header: make(http.Header),
Request: req,
}, nil
}
func (c *closeCountingRoundTripper) CloseIdleConnections() {
c.closes.Add(1)
}
func TestShardedRoundTripperCloseIdleConnectionsFansOut(t *testing.T) {
trackers := []*closeCountingRoundTripper{{}, {}, {}}
shards := make([]http.RoundTripper, len(trackers))
for i, tracker := range trackers {
shards[i] = tracker
}
s := &shardedRoundTripper{shards: shards, n: uint32(len(shards))}
s.CloseIdleConnections()
for _, tracker := range trackers {
assert.Equal(t, int32(1), tracker.closes.Load())
}
}
func TestInvalidateProxyClientClosesAllPolicyVariants(t *testing.T) {
initDefaultHTTPClientFixture(t)
proxyURL := "http://invalidate-proxy.example:8080"
defaultClient, err := GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
http1Client, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
shardedClient, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2})
require.NoError(t, err)
InvalidateProxyClient(proxyURL)
afterDefault, err := GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
afterHTTP1, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
afterSharded, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2})
require.NoError(t, err)
assert.NotSame(t, defaultClient, afterDefault)
assert.NotSame(t, http1Client, afterHTTP1)
assert.NotSame(t, shardedClient, afterSharded)
}
func TestResetProxyClientCacheKeepsDefaultPointerAndRecreatesVariants(t *testing.T) {
defaultClient := initDefaultHTTPClientFixture(t)
http1Client, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
shardedClient, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 3})
require.NoError(t, err)
proxyClient, err := GetHttpClientWithProxy("http://reset-proxy.example:8080")
require.NoError(t, err)
ResetProxyClientCache()
assert.Same(t, defaultClient, GetHttpClient(), "default httpClient pointer must stay stable across reset")
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, defaultClient, aware)
afterHTTP1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
afterSharded, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 3})
require.NoError(t, err)
afterProxy, err := GetHttpClientWithProxy("http://reset-proxy.example:8080")
require.NoError(t, err)
assert.NotSame(t, http1Client, afterHTTP1)
assert.NotSame(t, shardedClient, afterSharded)
assert.NotSame(t, proxyClient, afterProxy)
}
func TestResetProxyClientCacheClosesDefaultIdlePool(t *testing.T) {
defaultClient := initDefaultHTTPClientFixture(t)
tracker := &closeCountingRoundTripper{}
previousTransport := defaultClient.Transport
defaultClient.Transport = tracker
t.Cleanup(func() {
defaultClient.Transport = previousTransport
})
ResetProxyClientCache()
assert.Same(t, defaultClient, GetHttpClient())
assert.GreaterOrEqual(t, tracker.closes.Load(), int32(1), "reset must close idle connections on the stable default client")
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, defaultClient, aware)
}
func TestResetProxyClientCacheConcurrentWithGetHttpClient(t *testing.T) {
initDefaultHTTPClientFixture(t)
const workers = 64
var wg sync.WaitGroup
wg.Add(workers * 2)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
_ = GetHttpClient()
}()
go func() {
defer wg.Done()
ResetProxyClientCache()
}()
}
wg.Wait()
assert.NotNil(t, GetHttpClient())
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, GetHttpClient(), aware)
}
func TestCloseIdleConnectionsRedialsHTTP2(t *testing.T) {
withRelayHTTPTransportSettings(t)
var mu sync.Mutex
addrs := make([]string, 0, 2)
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs = append(addrs, r.RemoteAddr)
mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
client := newHTTPClientWithPolicyAndTLS(defaultHTTPTransportPolicy(), testTLSClientConfig(t, server))
resp, err := client.Get(server.URL)
require.NoError(t, err)
drainClose(t, resp)
client.CloseIdleConnections()
resp, err = client.Get(server.URL)
require.NoError(t, err)
drainClose(t, resp)
mu.Lock()
defer mu.Unlock()
require.Len(t, addrs, 2)
assert.NotEqual(t, addrs[0], addrs[1], "after CloseIdleConnections the next request must redial")
}
func TestNormalizeHTTPTransportPolicyClampsWithoutPanic(t *testing.T) {
assert.Equal(t, defaultHTTPTransportPolicy(), NormalizeHTTPTransportPolicy(dto.ChannelSettings{}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "AUTO"}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "HTTP1", HTTP2ConnectionShards: 8}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "http3"}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTP2ConnectionShards: -3}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 8}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTP2ConnectionShards: 99}))
}
+91
View File
@@ -0,0 +1,91 @@
package service
import (
"context"
"fmt"
"strings"
"sync"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
)
// HTTPTransportPolicy is the runtime-normalized outbound HTTP transport policy
// for a channel. Unknown or out-of-range stored values are clamped safely.
type HTTPTransportPolicy struct {
Protocol string // dto.HTTPProtocolAuto or dto.HTTPProtocolHTTP1
Shards int // 1..dto.MaxHTTP2ConnectionShards
}
var httpTransportPolicyWarnings sync.Map
func defaultHTTPTransportPolicy() HTTPTransportPolicy {
return HTTPTransportPolicy{
Protocol: dto.HTTPProtocolAuto,
Shards: 1,
}
}
// NormalizeHTTPTransportPolicy converts channel settings into a safe runtime policy.
// Invalid stored values never panic; they clamp to defaults and warn once per bad value.
func NormalizeHTTPTransportPolicy(settings dto.ChannelSettings) HTTPTransportPolicy {
policy := defaultHTTPTransportPolicy()
protocol := strings.ToLower(strings.TrimSpace(settings.HTTPProtocol))
switch protocol {
case "", dto.HTTPProtocolAuto:
policy.Protocol = dto.HTTPProtocolAuto
case dto.HTTPProtocolHTTP1:
policy.Protocol = dto.HTTPProtocolHTTP1
default:
warnHTTPTransportPolicyOnce("http_protocol", settings.HTTPProtocol)
policy.Protocol = dto.HTTPProtocolAuto
}
shards := settings.HTTP2ConnectionShards
switch {
case shards == 0:
policy.Shards = 1
case shards < 1:
warnHTTPTransportPolicyOnce("http2_connection_shards", fmt.Sprintf("%d", shards))
policy.Shards = 1
case shards > dto.MaxHTTP2ConnectionShards:
warnHTTPTransportPolicyOnce("http2_connection_shards", fmt.Sprintf("%d", shards))
policy.Shards = dto.MaxHTTP2ConnectionShards
default:
policy.Shards = shards
}
if policy.Protocol == dto.HTTPProtocolHTTP1 {
if settings.HTTP2ConnectionShards > 1 {
warnHTTPTransportPolicyOnce(
"http_protocol+http2_connection_shards",
fmt.Sprintf("%s+%d", dto.HTTPProtocolHTTP1, settings.HTTP2ConnectionShards),
)
}
policy.Shards = 1
}
if policy.Shards < 1 {
policy.Shards = 1
}
return policy
}
func warnHTTPTransportPolicyOnce(field, value string) {
key := field + "=" + value
if _, loaded := httpTransportPolicyWarnings.LoadOrStore(key, struct{}{}); loaded {
return
}
logger.LogWarn(
context.Background(),
fmt.Sprintf("invalid channel http transport setting clamped: %s=%q", field, value),
)
}
func (p HTTPTransportPolicy) cacheKeyPart() string {
return fmt.Sprintf("%s|%d", p.Protocol, p.Shards)
}
func (p HTTPTransportPolicy) String() string {
return p.cacheKeyPart()
}
+131
View File
@@ -0,0 +1,131 @@
package service
import (
"crypto/tls"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
)
// shardedRoundTripper fans requests for each origin across N independent
// transports so each origin can keep N reusable HTTP/2 connections.
type shardedRoundTripper struct {
shards []http.RoundTripper
n uint32
policy HTTPTransportPolicy
counters sync.Map // origin -> *atomic.Uint32
}
func newShardedRoundTripper(policy HTTPTransportPolicy, factory func() *http.Transport) *shardedRoundTripper {
n := policy.Shards
if n < 1 {
n = 1
}
shards := make([]http.RoundTripper, n)
for i := 0; i < n; i++ {
transport := factory()
transport.MaxIdleConns = max(1, transport.MaxIdleConns/n)
transport.MaxIdleConnsPerHost = max(1, transport.MaxIdleConnsPerHost/n)
shards[i] = transport
}
return &shardedRoundTripper{
shards: shards,
n: uint32(n),
policy: policy,
}
}
func originKey(req *http.Request) string {
if req == nil || req.URL == nil {
return ""
}
return strings.ToLower(req.URL.Scheme) + "://" + req.URL.Host
}
func (s *shardedRoundTripper) pickShard(origin string) uint32 {
if s.n <= 1 {
return 0
}
counterAny, _ := s.counters.LoadOrStore(origin, &atomic.Uint32{})
counter := counterAny.(*atomic.Uint32)
return (counter.Add(1) - 1) % s.n
}
func (s *shardedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
origin := originKey(req)
idx := s.pickShard(origin)
resp, err := s.shards[idx].RoundTrip(req)
if common.DebugEnabled {
proto := ""
if resp != nil {
proto = resp.Proto
}
host := ""
if req != nil && req.URL != nil {
host = req.URL.Host
}
logger.LogDebug(
req.Context(),
fmt.Sprintf(
"http transport: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s",
host,
s.policy.Protocol,
idx,
s.n,
s.policy.cacheKeyPart(),
proto,
),
)
}
return resp, err
}
func (s *shardedRoundTripper) CloseIdleConnections() {
for _, shard := range s.shards {
closeIdleConnections(shard)
}
}
func closeIdleConnections(rt http.RoundTripper) {
type idleCloser interface {
CloseIdleConnections()
}
if closer, ok := rt.(idleCloser); ok {
closer.CloseIdleConnections()
}
}
// applyHTTP1Force disables automatic HTTP/2 on a never-used transport.
// ForceAttemptHTTP2=false alone is insufficient; a non-nil empty TLSNextProto
// map prevents net/http from wiring HTTP/2.
func applyHTTP1Force(transport *http.Transport) {
if transport == nil {
return
}
transport.ForceAttemptHTTP2 = false
transport.DisableKeepAlives = false
transport.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
if transport.TLSClientConfig != nil {
cfg := transport.TLSClientConfig.Clone()
cfg.NextProtos = nil
transport.TLSClientConfig = cfg
}
}
func applyHTTPTransportPolicy(transport *http.Transport, policy HTTPTransportPolicy) {
if transport == nil {
return
}
if policy.Protocol == dto.HTTPProtocolHTTP1 {
applyHTTP1Force(transport)
return
}
transport.ForceAttemptHTTP2 = true
transport.DisableKeepAlives = false
}