Skip to content

Commit fd1e7b5

Browse files
authored
Bound and drain hand-rolled HTTP transport pools (#6495)
* Bound and drain hand-rolled HTTP transport pools #6480 bounded and drained only the clients built by networking.HttpClientBuilder.Build. Several hand-rolled &http.Transport{} literals elsewhere left IdleConnTimeout at zero, so a pooled idle connection never expired and a dropped client pinned a socket plus its goroutine pair for the process lifetime. Separately, several wrapping RoundTrippers did not implement CloseIdleConnections, so http.Client.CloseIdleConnections type-asserted the outermost transport, missed the method, and silently became a no-op. Implements changes for issue #6483: - Bound the transport literals in pkg/auth/discovery, pkg/auth/oauth, and pkg/oauthproto (discovery, dcr) with the same host-scoped pool bounds Build applies - Add networking.ForwardCloseIdle so a wrapping RoundTripper forwards CloseIdleConnections without an ad-hoc anonymous assertion; use it from ValidatingTransport and the vmcp wrappers - Forward CloseIdleConnections from bearerTokenTransport, registry/auth.Transport, and oauthproto.UserAgentTransport (the last inline, since pkg/oauthproto cannot import pkg/networking) pkg/authz/authorizers/http builds no wrapper and uses http.DefaultTransport (or its Clone), which is already bounded and forwards the call, so it needs no change. * Centralize idle-pool bounds and cover them with tests Addresses #6495 review comments: - MEDIUM pkg/oauthproto/dcr.go (3925404488): the 90s/100/4 pool values were duplicated across five transport literals with only a comment to keep them in sync. Add networking.SetIdleConnBounds as the single source (Build now uses it too); the three cycle-free sites call it, and the two oauthproto leaf sites reference a local const block since pkg/networking imports pkg/oauthproto. - MEDIUM pkg/auth/discovery/discovery.go (3925404500): the two discovery.go transports and the oidc.go transport shipped untested. Extract each into a small package-private builder that calls SetIdleConnBounds, and assert the three bounds (and that blockPrivateIPs disables keep-alives) in unit tests. Also add compile-time IdleConnectionCloser assertions to networking's own ValidatingTransport and closeIdlerTransport wrappers. * Forward and assert CloseIdleConnections on all wrappers Addresses #6495 review comments: - MEDIUM pkg/vmcp/client/client.go (3925404525): the session-backed twins authRoundTripper and identityRoundTripper in mcp_session.go implemented RoundTrip but not CloseIdleConnections, so they swallowed the drain the canonical twins forward. Their doc requires the invariant be kept in sync until #5333; mirror the forward with networking.ForwardCloseIdle. - LOW pkg/registry/auth/transport.go (3925404536): none of the new forwarding wrappers carried a compile-time assertion, so a rename or typo of CloseIdleConnections would compile and re-hide the pool. Add var _ networking.IdleConnectionCloser assertions to the wrappers (registry/auth.Transport, bearerTokenTransport, the vmcp wrappers, and the twins); UserAgentTransport asserts the local shape since pkg/oauthproto cannot import pkg/networking. * Strengthen UserAgentTransport drain tests Addresses #6495 review comments: - MEDIUM pkg/oauthproto/useragent_test.go (3925404517): the nil-Base test claimed to verify the DefaultTransport fallback forwards, but a type assertion on a nil interface returns ok==false without panicking, so the test would still pass if the guard were removed. Soften the comment to state accurately that it only checks the nil-Base path does not panic. - LOW pkg/oauthproto/useragent_test.go (3925404540): the ok==false arm (a non-nil Base lacking CloseIdleConnections) was never exercised. Add a plainRoundTripper sub-case asserting a safe no-op. * Fix codespell: keep-alives -> keep-alive Codespell flags the plural "keep-alives"; the rest of the tree uses the singular. Reword the comments and test messages added in this branch to match, unblocking the Spellcheck CI job.
1 parent 4037560 commit fd1e7b5

20 files changed

Lines changed: 436 additions & 52 deletions

File tree

pkg/auth/dcr/resolver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,11 @@ type bearerTokenTransport struct {
14011401
next http.RoundTripper
14021402
}
14031403

1404+
// Compile-time assertion: a rename or typo of CloseIdleConnections would
1405+
// otherwise silently make http.Client.CloseIdleConnections a no-op on any
1406+
// client using this transport (see networking.IdleConnectionCloser).
1407+
var _ networking.IdleConnectionCloser = (*bearerTokenTransport)(nil)
1408+
14041409
// RoundTrip implements http.RoundTripper.
14051410
func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
14061411
// Clone per http.RoundTripper contract: implementations must not modify
@@ -1410,6 +1415,13 @@ func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, err
14101415
return t.next.RoundTrip(cp)
14111416
}
14121417

1418+
// CloseIdleConnections forwards to the wrapped RoundTripper so
1419+
// http.Client.CloseIdleConnections reaches the underlying connection pool
1420+
// instead of stopping at this wrapper (see networking.IdleConnectionCloser).
1421+
func (t *bearerTokenTransport) CloseIdleConnections() {
1422+
networking.ForwardCloseIdle(t.next)
1423+
}
1424+
14131425
// errDCRRedirectRefused is returned when a DCR registration endpoint
14141426
// responds with a 30x. Net/http surfaces it via *url.Error so callers
14151427
// observe a clear failure mode instead of a confusing JSON decode error.

pkg/auth/dcr/resolver_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,3 +2363,25 @@ func TestResolveDCRCredentials_MetadataSelfNamedRegistrationEndpointAllowed(t *t
23632363
require.NotNil(t, res)
23642364
assert.NotEmpty(t, res.ClientID)
23652365
}
2366+
2367+
// closeIdleSpy records CloseIdleConnections calls; RoundTrip exists only to
2368+
// satisfy http.RoundTripper.
2369+
type closeIdleSpy struct{ closed int }
2370+
2371+
func (*closeIdleSpy) RoundTrip(*http.Request) (*http.Response, error) {
2372+
return nil, errors.New("unused")
2373+
}
2374+
func (s *closeIdleSpy) CloseIdleConnections() { s.closed++ }
2375+
2376+
// TestBearerTokenTransport_CloseIdleConnections verifies the wrapper forwards
2377+
// CloseIdleConnections to its next RoundTripper rather than silently swallowing
2378+
// it, which would make http.Client.CloseIdleConnections a no-op and leave the
2379+
// pool pinned.
2380+
func TestBearerTokenTransport_CloseIdleConnections(t *testing.T) {
2381+
t.Parallel()
2382+
2383+
spy := &closeIdleSpy{}
2384+
tr := &bearerTokenTransport{token: "t", next: spy}
2385+
tr.CloseIdleConnections()
2386+
assert.Equal(t, 1, spy.closed)
2387+
}

pkg/auth/discovery/discovery.go

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,24 @@ func DefaultDiscoveryConfig() *Config {
7979
}
8080
}
8181

82+
// newDetectionClient builds the HTTP client used to probe a target server for
83+
// WWW-Authenticate. The remote MCP server is untrusted, so it refuses
84+
// cross-host / scheme-downgrade redirects to prevent the server driving the
85+
// host into an SSRF (CWE-918). Extracted so the pool bounds
86+
// networking.SetIdleConnBounds applies stay unit-testable.
87+
func newDetectionClient(config *Config) *http.Client {
88+
transport := &http.Transport{
89+
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
90+
ResponseHeaderTimeout: config.ResponseHeaderTimeout,
91+
}
92+
networking.SetIdleConnBounds(transport)
93+
return &http.Client{
94+
Timeout: config.Timeout,
95+
Transport: transport,
96+
CheckRedirect: networking.SameHostRedirectPolicy(),
97+
}
98+
}
99+
82100
// DetectAuthenticationFromServer attempts to detect authentication requirements from the target server
83101
func DetectAuthenticationFromServer(ctx context.Context, targetURI string, config *Config) (*AuthInfo, error) {
84102
if config == nil {
@@ -90,16 +108,7 @@ func DetectAuthenticationFromServer(ctx context.Context, targetURI string, confi
90108
defer cancel()
91109

92110
// Make a test request to the target server to see if it returns WWW-Authenticate.
93-
// The remote MCP server is untrusted, so refuse cross-host / scheme-downgrade
94-
// redirects to prevent it driving the host into an SSRF (CWE-918).
95-
client := &http.Client{
96-
Timeout: config.Timeout,
97-
Transport: &http.Transport{
98-
TLSHandshakeTimeout: config.TLSHandshakeTimeout,
99-
ResponseHeaderTimeout: config.ResponseHeaderTimeout,
100-
},
101-
CheckRedirect: networking.SameHostRedirectPolicy(),
102-
}
111+
client := newDetectionClient(config)
103112

104113
// First try a GET request
105114
authInfo, err := detectAuthWithRequest(detectCtx, client, targetURI, http.MethodGet, nil)
@@ -1020,6 +1029,26 @@ func buildOAuthFlowResult(
10201029
}
10211030
}
10221031

1032+
// newResourceMetadataTransport builds the transport for RFC 9728 metadata
1033+
// fetches. The metadataURL is server-supplied, so the caller pairs it with a
1034+
// same-host redirect policy; when blockPrivateIPs is true it also refuses to
1035+
// dial private/loopback/link-local addresses on every hop (and disables
1036+
// keep-alive so a pooled connection cannot skip the per-dial check).
1037+
// Extracted so the pool bounds networking.SetIdleConnBounds applies stay
1038+
// unit-testable.
1039+
func newResourceMetadataTransport(blockPrivateIPs bool) *http.Transport {
1040+
transport := &http.Transport{
1041+
TLSHandshakeTimeout: 5 * time.Second,
1042+
ResponseHeaderTimeout: 5 * time.Second,
1043+
}
1044+
networking.SetIdleConnBounds(transport)
1045+
if blockPrivateIPs {
1046+
transport.DialContext = networking.NewPrivateIPBlockingDialContext()
1047+
transport.DisableKeepAlives = true
1048+
}
1049+
return transport
1050+
}
1051+
10231052
// FetchResourceMetadata fetches RFC 9728 protected-resource metadata from a
10241053
// server-supplied URL.
10251054
//
@@ -1046,17 +1075,7 @@ func FetchResourceMetadata(ctx context.Context, metadataURL string, blockPrivate
10461075
return nil, fmt.Errorf("metadata URL must use HTTPS: %s", metadataURL)
10471076
}
10481077

1049-
// The HTTPS check above runs once on the initial URL only, so refuse
1050-
// cross-host and scheme-downgrade redirects to stop a 30x reaching an
1051-
// internal address; optionally block private dials on every hop.
1052-
transport := &http.Transport{
1053-
TLSHandshakeTimeout: 5 * time.Second,
1054-
ResponseHeaderTimeout: 5 * time.Second,
1055-
}
1056-
if blockPrivateIPs {
1057-
transport.DialContext = networking.NewPrivateIPBlockingDialContext()
1058-
transport.DisableKeepAlives = true
1059-
}
1078+
transport := newResourceMetadataTransport(blockPrivateIPs)
10601079
client := &http.Client{
10611080
Timeout: DefaultHTTPTimeout,
10621081
Transport: transport,

pkg/auth/discovery/discovery_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,3 +1474,36 @@ func TestCreateOAuthConfig_DiscoveredTokenEndpoint(t *testing.T) {
14741474
})
14751475
}
14761476
}
1477+
1478+
// TestNewDetectionClient_BoundsIdleConnectionPool pins that the auth-detection
1479+
// client bounds its idle-connection pool. This is one of the exact sites #6483
1480+
// names; a zero IdleConnTimeout would pin a socket and its goroutine pair for
1481+
// the process lifetime.
1482+
func TestNewDetectionClient_BoundsIdleConnectionPool(t *testing.T) {
1483+
t.Parallel()
1484+
1485+
client := newDetectionClient(DefaultDiscoveryConfig())
1486+
transport, ok := client.Transport.(*http.Transport)
1487+
require.True(t, ok, "detection client must use an *http.Transport")
1488+
1489+
assert.Equal(t, 90*time.Second, transport.IdleConnTimeout)
1490+
assert.Equal(t, 100, transport.MaxIdleConns)
1491+
assert.Equal(t, 4, transport.MaxIdleConnsPerHost)
1492+
}
1493+
1494+
// TestNewResourceMetadataTransport_BoundsIdleConnectionPool pins that the RFC
1495+
// 9728 metadata transport bounds its pool in both dial modes. The bounds are
1496+
// set regardless of blockPrivateIPs (they are moot only when keep-alive is
1497+
// disabled, which is a separate field).
1498+
func TestNewResourceMetadataTransport_BoundsIdleConnectionPool(t *testing.T) {
1499+
t.Parallel()
1500+
1501+
for _, blockPrivateIPs := range []bool{false, true} {
1502+
transport := newResourceMetadataTransport(blockPrivateIPs)
1503+
assert.Equal(t, 90*time.Second, transport.IdleConnTimeout)
1504+
assert.Equal(t, 100, transport.MaxIdleConns)
1505+
assert.Equal(t, 4, transport.MaxIdleConnsPerHost)
1506+
assert.Equal(t, blockPrivateIPs, transport.DisableKeepAlives,
1507+
"blockPrivateIPs must disable keep-alive so pooling cannot skip the per-dial SSRF check")
1508+
}
1509+
}

pkg/auth/oauth/oidc.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,26 @@ func discoverOIDCEndpointsWithClient(
6464
return discoverOIDCEndpointsWithClientAndValidation(ctx, issuer, client, true, insecureAllowHTTP, blockPrivateIPs)
6565
}
6666

67+
// newOIDCDiscoveryTransport builds the transport used to fetch OIDC/OAuth
68+
// discovery documents from an untrusted, remote-server-supplied issuer. The
69+
// caller pairs it with a same-host redirect policy to prevent a 30x driving the
70+
// host into an SSRF (CWE-918); when blockPrivateIPs is true it also refuses to
71+
// dial private/loopback/link-local addresses on every hop (and disables
72+
// keep-alive so a pooled connection cannot skip the per-dial check). Extracted
73+
// so the pool bounds networking.SetIdleConnBounds applies stay unit-testable.
74+
func newOIDCDiscoveryTransport(blockPrivateIPs bool) *http.Transport {
75+
transport := &http.Transport{
76+
TLSHandshakeTimeout: 10 * time.Second,
77+
ResponseHeaderTimeout: 10 * time.Second,
78+
}
79+
networking.SetIdleConnBounds(transport)
80+
if blockPrivateIPs {
81+
transport.DialContext = networking.NewPrivateIPBlockingDialContext()
82+
transport.DisableKeepAlives = true
83+
}
84+
return transport
85+
}
86+
6787
// discoverOIDCEndpointsWithClientAndValidation discovers OAuth endpoints with optional issuer validation
6888
//
6989
//nolint:gocyclo // Function complexity justified by comprehensive OIDC discovery logic
@@ -82,21 +102,9 @@ func discoverOIDCEndpointsWithClientAndValidation(
82102
}
83103

84104
if client == nil {
85-
// The issuer/metadata URL originates from untrusted remote-server
86-
// discovery, so refuse cross-host and scheme-downgrade redirects to
87-
// prevent a 30x from driving the host into an SSRF (CWE-918), and
88-
// optionally block private dials on every hop.
89-
transport := &http.Transport{
90-
TLSHandshakeTimeout: 10 * time.Second,
91-
ResponseHeaderTimeout: 10 * time.Second,
92-
}
93-
if blockPrivateIPs {
94-
transport.DialContext = networking.NewPrivateIPBlockingDialContext()
95-
transport.DisableKeepAlives = true
96-
}
97105
client = &http.Client{
98106
Timeout: 30 * time.Second,
99-
Transport: transport,
107+
Transport: newOIDCDiscoveryTransport(blockPrivateIPs),
100108
CheckRedirect: networking.SameHostRedirectPolicy(),
101109
}
102110
}

pkg/auth/oauth/oidc_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,3 +1532,20 @@ func TestBuildWellKnownURLs(t *testing.T) {
15321532
})
15331533
}
15341534
}
1535+
1536+
// TestNewOIDCDiscoveryTransport_BoundsIdleConnectionPool pins that the OIDC
1537+
// discovery transport bounds its pool in both dial modes. This is one of the
1538+
// exact sites #6483 names; a zero IdleConnTimeout would pin a socket and its
1539+
// goroutine pair for the process lifetime.
1540+
func TestNewOIDCDiscoveryTransport_BoundsIdleConnectionPool(t *testing.T) {
1541+
t.Parallel()
1542+
1543+
for _, blockPrivateIPs := range []bool{false, true} {
1544+
transport := newOIDCDiscoveryTransport(blockPrivateIPs)
1545+
assert.Equal(t, 90*time.Second, transport.IdleConnTimeout)
1546+
assert.Equal(t, 100, transport.MaxIdleConns)
1547+
assert.Equal(t, 4, transport.MaxIdleConnsPerHost)
1548+
assert.Equal(t, blockPrivateIPs, transport.DisableKeepAlives,
1549+
"blockPrivateIPs must disable keep-alive so pooling cannot skip the per-dial SSRF check")
1550+
}
1551+
}

pkg/networking/http_client.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,48 @@ type IdleConnectionCloser interface {
137137
CloseIdleConnections()
138138
}
139139

140+
// SetIdleConnBounds applies Build's idle-connection-pool bounds to a
141+
// hand-constructed *http.Transport — the transports this package cannot build
142+
// through Build because they need their own DialContext, CheckRedirect, or TLS
143+
// config. A zero IdleConnTimeout never expires a pooled connection, so a client
144+
// that performs one request and is dropped pins a socket and its goroutine pair
145+
// for the process lifetime; this is the leak Build fixes for its own clients
146+
// (see the constant block above). Setting all three fields together keeps a
147+
// call site from bounding two of the three by mistake.
148+
//
149+
// pkg/networking imports pkg/oauthproto, so leaf packages that oauthproto's
150+
// import graph reaches cannot call this without a cycle; those mirror the
151+
// values with a local const block instead.
152+
func SetIdleConnBounds(t *http.Transport) {
153+
t.IdleConnTimeout = idleConnTimeout
154+
t.MaxIdleConns = maxIdleConns
155+
t.MaxIdleConnsPerHost = maxIdleConnsPerHost
156+
}
157+
158+
// ForwardCloseIdle drains the idle-connection pool reachable through rt when rt
159+
// implements IdleConnectionCloser — as *http.Transport, and every wrapping
160+
// RoundTripper in this repo that forwards the call, do. A wrapping RoundTripper
161+
// must call this from its own CloseIdleConnections; otherwise
162+
// http.Client.CloseIdleConnections stops at the wrapper and the pool underneath
163+
// is never drained (see IdleConnectionCloser). Using this helper keeps the
164+
// wrapper half from silently regressing into an anonymous
165+
// `interface{ CloseIdleConnections() }` assertion that drifts out of sync.
166+
func ForwardCloseIdle(rt http.RoundTripper) {
167+
if closer, ok := rt.(IdleConnectionCloser); ok {
168+
closer.CloseIdleConnections()
169+
}
170+
}
171+
140172
// ValidatingTransport is for validating URLs prior to request
141173
type ValidatingTransport struct {
142174
Transport http.RoundTripper
143175
InsecureAllowHTTP bool
144176
}
145177

178+
// Compile-time assertion: a rename or typo of CloseIdleConnections would
179+
// otherwise silently re-hide the pool it wraps (see IdleConnectionCloser).
180+
var _ IdleConnectionCloser = (*ValidatingTransport)(nil)
181+
146182
// RoundTrip validates the request URL prior to forwarding
147183
func (t *ValidatingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
148184
// Skip validation if INSECURE_DISABLE_URL_VALIDATION is set or if InsecureAllowHTTP is true
@@ -172,9 +208,7 @@ func (t *ValidatingTransport) RoundTrip(req *http.Request) (*http.Response, erro
172208
// IdleConnectionCloser. The assertion is required because the field is an
173209
// http.RoundTripper; it holds an *http.Transport for every client Build produces.
174210
func (t *ValidatingTransport) CloseIdleConnections() {
175-
if closer, ok := t.Transport.(IdleConnectionCloser); ok {
176-
closer.CloseIdleConnections()
177-
}
211+
ForwardCloseIdle(t.Transport)
178212
}
179213

180214
// closeIdlerTransport wraps a RoundTripper that does not implement
@@ -185,6 +219,8 @@ type closeIdlerTransport struct {
185219
pool *http.Transport
186220
}
187221

222+
var _ IdleConnectionCloser = (*closeIdlerTransport)(nil)
223+
188224
// CloseIdleConnections closes the idle connections held by the underlying pool.
189225
func (t *closeIdlerTransport) CloseIdleConnections() {
190226
t.pool.CloseIdleConnections()
@@ -335,10 +371,8 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) {
335371
transport := &http.Transport{
336372
TLSHandshakeTimeout: b.tlsHandshakeTimeout,
337373
ResponseHeaderTimeout: b.responseHeaderTimeout,
338-
IdleConnTimeout: idleConnTimeout,
339-
MaxIdleConns: maxIdleConns,
340-
MaxIdleConnsPerHost: maxIdleConnsPerHost,
341374
}
375+
SetIdleConnBounds(transport)
342376
transport.DisableKeepAlives = b.disableKeepAlives
343377

344378
if !b.allowPrivate {

pkg/networking/http_client_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"crypto/x509"
1212
"crypto/x509/pkix"
1313
"encoding/pem"
14+
"errors"
1415
"io"
1516
"math/big"
1617
"net/http"
@@ -954,6 +955,20 @@ func TestBuild_BoundsIdleConnectionPool(t *testing.T) {
954955
assert.Equal(t, 4, transport.MaxIdleConnsPerHost)
955956
}
956957

958+
// TestSetIdleConnBounds pins the pool bounds the helper applies. These are the
959+
// single source of truth the hand-rolled transports outside Build mirror, so a
960+
// retune here must be a deliberate, visible change.
961+
func TestSetIdleConnBounds(t *testing.T) {
962+
t.Parallel()
963+
964+
transport := &http.Transport{}
965+
SetIdleConnBounds(transport)
966+
967+
assert.Equal(t, 90*time.Second, transport.IdleConnTimeout)
968+
assert.Equal(t, 100, transport.MaxIdleConns)
969+
assert.Equal(t, 4, transport.MaxIdleConnsPerHost)
970+
}
971+
957972
// TestBuild_CloseIdleConnectionsReachesPool pins that
958973
// http.Client.CloseIdleConnections is not a silent no-op on a built client. The
959974
// client discovers the capability by asserting the outermost transport, so every
@@ -999,6 +1014,38 @@ func TestBuild_CloseIdleConnectionsReachesPool(t *testing.T) {
9991014
}
10001015
}
10011016

1017+
// forwardCloseIdleSpy records CloseIdleConnections calls; the RoundTrip method
1018+
// exists only to satisfy http.RoundTripper.
1019+
type forwardCloseIdleSpy struct{ closed int }
1020+
1021+
func (*forwardCloseIdleSpy) RoundTrip(*http.Request) (*http.Response, error) {
1022+
return nil, errors.New("unused")
1023+
}
1024+
func (s *forwardCloseIdleSpy) CloseIdleConnections() { s.closed++ }
1025+
1026+
// plainRoundTripper implements http.RoundTripper but not IdleConnectionCloser.
1027+
type plainRoundTripper struct{}
1028+
1029+
func (plainRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
1030+
return nil, errors.New("unused")
1031+
}
1032+
1033+
func TestForwardCloseIdle(t *testing.T) {
1034+
t.Parallel()
1035+
1036+
t.Run("forwards to a RoundTripper that implements the capability", func(t *testing.T) {
1037+
t.Parallel()
1038+
spy := &forwardCloseIdleSpy{}
1039+
ForwardCloseIdle(spy)
1040+
assert.Equal(t, 1, spy.closed)
1041+
})
1042+
1043+
t.Run("is a safe no-op when the RoundTripper does not implement it", func(t *testing.T) {
1044+
t.Parallel()
1045+
assert.NotPanics(t, func() { ForwardCloseIdle(plainRoundTripper{}) })
1046+
})
1047+
}
1048+
10021049
// getReusedConn issues a GET and reports whether it was served from the
10031050
// client's idle connection pool.
10041051
func getReusedConn(t *testing.T, client *http.Client, target string) bool {

0 commit comments

Comments
 (0)