fix(cache): harden cache backends, resilience, recovery, and transports

- RESP -ERR no longer closes healthy conn / aborts batch; global cap replacement-aware
- global health-check pass serialized (latent data race); MemoryMonitor via ResourceManager
- token-exchange retry, open-circuit rejections counted as failures
- TLS-posture matching transport reuse (no silent InsecureSkipVerify handoff)
This commit is contained in:
2026-08-14 16:16:51 +01:00
parent 8640451877
commit 4d23d618f5
49 changed files with 2318 additions and 181 deletions
+64
View File
@@ -0,0 +1,64 @@
package pool
import (
"crypto/tls"
"testing"
"time"
)
// TestTransportPool_ConfigKeyDistinguishesMinTLSVersion guards the R146
// fix in internal/pool/transport.go configKey: the key previously
// omitted MinTLSVersion (and all timeouts), so two configs differing
// only in the TLS minimum version collided on one key and silently
// shared a single transport with whoever-arrived-first's settings — a
// possible TLS-version downgrade. Distinct TLS minimum versions must
// yield non-shared transports with the requested MinVersion.
func TestTransportPool_ConfigKeyDistinguishesMinTLSVersion(t *testing.T) {
p := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 100, // high so the at-cap path isn't taken
}
cfg12 := TransportConfig{MinTLSVersion: tls.VersionTLS12}
cfg13 := TransportConfig{MinTLSVersion: tls.VersionTLS13}
a := p.GetTransport(cfg12)
b := p.GetTransport(cfg13)
if a == b {
t.Fatal("two configs differing only in MinTLSVersion must not share a transport")
}
if a.TLSClientConfig == nil || a.TLSClientConfig.MinVersion != tls.VersionTLS12 {
t.Fatalf("transport A must use TLS1.2, got %+v", a.TLSClientConfig)
}
if b.TLSClientConfig == nil || b.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Fatalf("transport B must use TLS1.3, got %+v", b.TLSClientConfig)
}
}
// TestTransportPool_ConfigKeyDistinguishesTimeouts guards the R146 fix
// for the timeout fields: configs differing only in a timeout must not
// collide and share one transport.
func TestTransportPool_ConfigKeyDistinguishesTimeouts(t *testing.T) {
p := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
clientCount: 0,
maxClients: 100,
}
cfgFast := TransportConfig{MinTLSVersion: tls.VersionTLS12, DialTimeout: time.Second}
cfgSlow := TransportConfig{MinTLSVersion: tls.VersionTLS12, DialTimeout: 30 * time.Second}
a := p.GetTransport(cfgFast)
b := p.GetTransport(cfgSlow)
if a == nil || b == nil {
t.Fatal("both transports must be created")
}
if a == b {
t.Fatal("two configs differing only in DialTimeout must not share a transport")
}
}
+78
View File
@@ -0,0 +1,78 @@
package pool
import (
"crypto/tls"
"net/http"
"testing"
"time"
)
// TestTransportPool_TLSMatchesNormalizesDefault covers the previously
// 0%-covered tlsMatches security guard (R146): two transports share the
// pool only when their TLS posture (certificate verification and
// minimum protocol version) matches, with 0 normalized to the TLS1.2
// default that createTransport applies.
func TestTransportPool_TLSMatchesNormalizesDefault(t *testing.T) {
p := &TransportPool{}
// 0 (createTransport's default) must be equivalent to explicit TLS1.2.
if !p.tlsMatches(TransportConfig{}, TransportConfig{MinTLSVersion: tls.VersionTLS12}) {
t.Fatal("zero and explicit TLS1.2 minimum must match after normalization")
}
// Differing verification posture must never match.
if p.tlsMatches(
TransportConfig{InsecureSkipVerify: true},
TransportConfig{InsecureSkipVerify: false},
) {
t.Fatal("configs differing in InsecureSkipVerify must not match")
}
// Differing minimum TLS version must never match.
if p.tlsMatches(
TransportConfig{MinTLSVersion: tls.VersionTLS12},
TransportConfig{MinTLSVersion: tls.VersionTLS13},
) {
t.Fatal("configs differing in MinTLSVersion must not match")
}
}
// TestTransportPool_NoTLSMismatchReuse drives getExistingTransport
// directly: a strict-verifying caller must never be handed a pooled
// InsecureSkipVerify transport (silent cert-verification bypass, R146);
// it falls back to nil so the caller builds a safe verifying client.
func TestTransportPool_NoTLSMismatchReuse(t *testing.T) {
p := &TransportPool{
transports: map[string]*sharedTransport{
"insecure": {
transport: &http.Transport{},
config: TransportConfig{InsecureSkipVerify: true, MinTLSVersion: tls.VersionTLS12},
refCount: 0,
lastUsed: time.Now(),
},
"verifyTLS13": {
transport: &http.Transport{},
config: TransportConfig{InsecureSkipVerify: false, MinTLSVersion: tls.VersionTLS13},
refCount: 0,
lastUsed: time.Now(),
},
},
maxConns: 20,
maxClients: 100,
}
// Strict TLS1.2 caller must NOT reuse the insecure or TLS1.3 peers.
if tr := p.getExistingTransport(TransportConfig{MinTLSVersion: tls.VersionTLS12}); tr != nil {
t.Fatal("strict-verifying caller received a mismatched pooled transport")
}
// Insecure caller matches the insecure peer and reuses it.
if tr := p.getExistingTransport(TransportConfig{InsecureSkipVerify: true, MinTLSVersion: tls.VersionTLS12}); tr == nil {
t.Fatal("insecure caller should reuse the insecure pooled transport")
}
// TLS1.3 verifying caller matches the TLS1.3 peer.
if tr := p.getExistingTransport(TransportConfig{MinTLSVersion: tls.VersionTLS13}); tr == nil {
t.Fatal("TLS1.3 caller should reuse the TLS1.3 verifying transport")
}
}
+46 -3
View File
@@ -118,7 +118,7 @@ func DefaultTransportConfig() TransportConfig {
func (p *TransportPool) GetTransport(config TransportConfig) *http.Transport {
// Check client limit
if atomic.LoadInt32(&p.clientCount) >= p.maxClients {
return p.getExistingTransport()
return p.getExistingTransport(config)
}
key := p.configKey(config)
@@ -180,20 +180,41 @@ func (p *TransportPool) ReleaseTransport(transport *http.Transport) {
}
// getExistingTransport returns any available transport when limit is reached
func (p *TransportPool) getExistingTransport() *http.Transport {
func (p *TransportPool) getExistingTransport(config TransportConfig) *http.Transport {
p.mu.RLock()
defer p.mu.RUnlock()
// Prefer a transport whose TLS settings (verification, min version)
// match the request. Reusing a mismatched transport at cap could hand a
// strict-verifying caller an InsecureSkipVerify transport (silent cert
// verification bypass) or an insecure caller a verifying one (R146).
for _, shared := range p.transports {
if shared != nil && shared.transport != nil {
if shared != nil && shared.transport != nil && p.tlsMatches(config, shared.config) {
atomic.AddInt32(&shared.refCount, 1)
shared.lastUsed = time.Now()
return shared.transport
}
}
// No TLS-matching pooled transport: return nil so the caller falls back
// to a safe basic client (http.DefaultTransport verifies certs) rather
// than receiving a mismatched peer. Bounded by the cap (R146).
return nil
}
// tlsMatches reports whether two transports share the same TLS posture
// (certificate verification and minimum protocol version), normalized for
// createTransport's 0-means-TLS1.2 default.
func (p *TransportPool) tlsMatches(a, b TransportConfig) bool {
amin, bmin := a.MinTLSVersion, b.MinTLSVersion
if amin == 0 {
amin = tls.VersionTLS12
}
if bmin == 0 {
bmin = tls.VersionTLS12
}
return a.InsecureSkipVerify == b.InsecureSkipVerify && amin == bmin
}
// createTransport creates a new HTTP transport with the given config
func (p *TransportPool) createTransport(config TransportConfig) *http.Transport {
// Set secure defaults
@@ -276,6 +297,28 @@ func (p *TransportPool) configKey(config TransportConfig) string {
} else {
sb.WriteByte(0)
}
// Include MinTLSVersion and every timeout so two configs differing only
// in TLS minimum version or timeouts do NOT collide on one key and
// silently share a transport with whoever-arrived-first's settings
// (a possible TLS-version downgrade). Mirror http_client_pool.go's
// all-field key (R146).
sb.WriteByte('|')
if config.MinTLSVersion == 0 {
sb.WriteString("tls12")
} else {
sb.WriteString(strconv.Itoa(int(config.MinTLSVersion)))
}
for _, d := range []time.Duration{
config.DialTimeout,
config.TLSHandshakeTimeout,
config.ResponseHeaderTimeout,
config.ExpectContinueTimeout,
config.IdleConnTimeout,
config.KeepAlive,
} {
sb.WriteByte('|')
sb.WriteString(strconv.FormatInt(int64(d), 10))
}
return sb.String()
}