Bound and close HTTP client connection pools - #6480
Conversation
Every client from networking.HttpClientBuilder.Build had an unbounded,
undrainable connection pool: IdleConnTimeout was left at zero ("never
expire"), and CloseIdleConnections was a silent no-op because the
ValidatingTransport wrapper did not implement it. A client that made one
request and was dropped held a socket and its goroutine pair for the
lifetime of the process, with no remediation available.
On the auth-server upstream path this compounded — one transport per
configured upstream, released by nothing on shutdown, so every server
reconstruction in a long-lived process leaked the whole set.
Implements changes for issue #6479:
- Set IdleConnTimeout, MaxIdleConns and MaxIdleConnsPerHost in Build so
every client's pool is bounded and self-draining
- Forward CloseIdleConnections through ValidatingTransport, and wrap the
auth-token-file oauth2.Transport so the call reaches the pool there too
- Add upstream.IdleConnectionCloser as an optional capability, implemented
by BaseOAuth2Provider; keeping it out of OAuth2Provider avoids breaking
external implementations of that open interface
- Add Server.CloseIdleConnections, distinct from Close, so an embedder can
retire a superseded server without closing storage a replacement server
is still serving through; Close now drains upstreams first
- Add Config.UpstreamFactory (with exported UpstreamProviderFactory and
DefaultUpstreamFactory) so callers can share HTTP clients across
reconstructions and isolate per-upstream construction failures
Fixed issues from code review: - HIGH: OIDCProviderImpl satisfied upstream.IdleConnectionCloser only by promotion from its embedded *BaseOAuth2Provider, so losing the promotion would restore the leak silently — exactly the failure mode this change exists to remove. Pinned it with a var _ assertion and added a test that builds a server through DefaultUpstreamFactory against an httptest OIDC issuer and asserts Close drains the pool, observed from the issuer side. - HIGH: newServer abandoned already-constructed providers when a later upstream failed — the retry case the issue calls pathological. Extracted buildUpstreams and closeUpstreamIdleConnections; buildUpstreams drains what it built before returning, and a named error return with a deferred drain covers every failure after the loop. - MEDIUM: draining a caller-supplied HTTP client cold-started the pool a live server was still using, defeating the shared-client pattern Config.UpstreamFactory recommends. BaseOAuth2Provider now tracks whether it owns its client and skips one it does not. - MEDIUM: both provider constructors built (and, with CAFilePath, read a CA bundle for) a client before applying options, so an injected client still cost a discarded transport per construction. Options now apply first. - MEDIUM: the pool-bounds comment claimed all three zero values mean unlimited; MaxIdleConnsPerHost already defaults to 2, so 4 is a deliberate increase. Rewrote the rationale per field. - MEDIUM: documented why Server requires CloseIdleConnections while the provider-level capability is optional.
Fixed issues from code review: - HIGH: the new Config.UpstreamFactory doc told callers a factory could "skip" a failing upstream, but there is no skip protocol — returning (nil, nil) wired a nil Provider into the authorization chain, which panicked on the first /oauth/authorize request rather than failing at boot. buildUpstreams now rejects a nil provider by name, and the doc says how to actually omit or substitute an upstream. - MEDIUM: Server.CloseIdleConnections' doc prescribes a reconstruct-and- retire pattern, so it now scopes what it releases: upstream HTTP pools only, not the per-issuer JWKS refresh workers a TrustedIssuers server holds. That structural fix is pre-existing and left for a follow-up. - MEDIUM: limitedBodyTransport was a third wrapper swallowing CloseIdleConnections, harmless only because keep-alives happen to be disabled on that client. Added the forwarder so it stays correct if that changes. - MEDIUM: the OIDCProviderImpl var _ assertion claimed to pin behavior it cannot (a shadowing method would still satisfy it); reworded to what it pins and named the test that guards the behavior. - MEDIUM: skipping the default client build when one is injected also skipped the CA bundle read, silently voiding an operator-set CAFilePath. Now logged at WARN, with both option docs stating that an injected client supersedes the config's TLS and SSRF settings. - MEDIUM: added the missing ownership coverage on the OIDC path, so dropping ownsHTTPClient from WithHTTPClient fails a test.
Fixed issues from code review: - MEDIUM: the limitedBodyTransport forwarder was inserted between RoundTrip's doc comment and RoundTrip, so go doc rendered the body-cap security rationale under the wrong method and left RoundTrip — which enforces the JWKS response bound — undocumented. Moved it below. - MEDIUM: a provider that failed inside its own constructor after building a client abandoned that client's pool, unreachable because nothing is returned. NewOIDCProvider and newBaseOAuth2Provider now drain a self-built client on their own error paths, guarded on ownership so an injected client is left alone. - LOW: EmbeddedAuthServer now delegates CloseIdleConnections, so the reconstruct-and-retire pattern works through the in-repo wrapper. - LOW: renamed stale defaultUpstreamFactory comment references. - LOW: documented why the injected-client warning covers CAFilePath but not AllowPrivateIPs or InsecureAllowHTTP.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6480 +/- ##
==========================================
+ Coverage 78.09% 78.17% +0.07%
==========================================
Files 768 768
Lines 74551 74638 +87
==========================================
+ Hits 58220 58347 +127
+ Misses 16326 16286 -40
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-agent review — 5 specialists (concurrency/lifecycle, security, API design, test coverage, quality/reviewability)
The core fix is correct and well-targeted. Verified independently: closeIdlerTransport reaches the same *http.Transport captured before wrapping; ownsHTTPClient is construction-only write / drain-only read, so no race and a safe zero value; newServer's retErr defer registers strictly after buildUpstreams returns, so no double-drain; Close() drains before closing storage; the pool bounds (90s / 100 / 4) are safe for all ~12 Build() call sites — every one is a single-host control-plane client, none on the MCP data path, and 4 raises the previous effective per-host limit of 2. task gen produces no diff. 9 of the new tests were verified load-bearing by reverting the corresponding production code.
Three things I would want resolved before merge, none of them a defect in the shipped code path:
1. Closes #6479 looks premature. The issue's failure mode is a long-lived process that reconstructs the auth server repeatedly. That still grows goroutines unboundedly through jwk.NewCache(context.Background(), ...) in multi_issuer_validator.go — one worker pool per trusted issuer, rooted at context.Background(), released by neither Close() nor CloseIdleConnections(). This construction happens after buildUpstreams, so the pathological retry loop the new defer targets still leaks a worker pool per attempt once construction gets past the upstream stage. The PR documents this honestly inside the Server.CloseIdleConnections doc comment, which makes it a scoped partial fix rather than an oversight — but the connection half is fixed and the goroutine half is not. Either open the follow-up for shutting down MultiIssuerTokenValidator and reference it, or downgrade to Refs #6479.
2. An injected HTTP client now fails open on CAFilePath. Moving option application ahead of the default-client build means newHTTPClientForHost is skipped for an injecting caller, and with it the read+parse of config.CAFilePath — which previously returned an error on an unreadable or malformed bundle. Now it is a slog.Warn. The effective TLS posture is unchanged (the CA-configured client was already built-then-discarded), and no in-repo caller injects a client, so this is confined to external Go embedders — but it turns a boot-time failure on a trust-anchor decision into a log line, and Config.UpstreamFactory exists precisely to encourage injected clients. Three of five reviewers landed on the same recommendation: treat "injected client + non-empty CAFilePath" as a configuration contradiction and return an error. It is also untested in either direction.
3. Scope. #6479 says of its item 3: "This one is a genuine API surface commitment ... so it deserves its own discussion and shouldn't block 1 and 2." This PR lands all three at 384 added production lines across 9 files — right at the repo's 400-line / 10-file guidance — and item 3 consumes a large share of the reviewer's attention: Config.UpstreamFactory, UpstreamProviderFactory, the DefaultUpstreamFactory export, resolveUpstreamFactory, the nil-provider policy, the constructor reordering, and warnInjectedClientSupersedesCABundle. Items 1+2 alone are a tight ~200-line fix that could land today; item 3 is a policy question (should the component that validates upstream ID tokens be caller-substitutable?) with no in-repo caller. Splitting is a judgment call, but if it stays as one PR that should be a stated decision in the body rather than silent.
Also worth noting
- Breaking change not declared.
authserver.Servergains a required method. The single-implementation claim is accurate for production code (onlypkg/authserver.server), but this is a compile break for any external implementer or test fake — the PR itself had to updatestubServer. Belongs in the "user-facing change" section, along with theCAFilePathbehavior change, so both reach the release notes. - Comment volume. 215 of the 384 added production lines are comments (56%, against a 25–46% baseline in the same files). The optional-capability rationale is restated in 4 places and the injected-client ownership rule in 7; the
Scope:paragraph documents an unrelated unfixed leak inside a public method's contract. Stating each rationale once at its natural home and pointing at it elsewhere would cut the diff meaningfully without losing anything. - Commit history. "Address code review feedback" ×3 are not cosmetic — they add ownership tracking, nil-provider rejection, the constructor reordering, the CA warning, and the failure-path drains, i.e. behavior that changes the first commit's design, with severity labels from review output in the bodies. Worth rebasing into logical commits before merge.
- Coverage gaps.
newServer's deferred drain is uncovered (removing it leaves the suite green);warnInjectedClientSupersedesCABundle,limitedBodyTransport.CloseIdleConnections,EmbeddedAuthServer.CloseIdleConnectionsdelegation, and the headlineConfig.UpstreamFactoryshared-client contract are all untested.
No HIGH-severity findings. Nothing here says the code is wrong; the substantive asks are the issue-closure scope, the fail-open CA path, and declaring the breaking change.
🤖 Multi-agent review generated with Claude Code
Addresses #6480 review comments: - MEDIUM pkg/authserver/upstream/oauth2.go (3906424359): moving option application ahead of the default client build also skipped the CAFilePath read, turning a malformed trust anchor from a boot error into a slog.Warn. Both constructors now return ErrCABundleWithInjectedClient for that contradiction, tested in both directions plus the bundle-alone path. - LOW pkg/authserver/upstream/oidc.go (3906424438): the option docs claimed InsecureAllowHTTP is not applied with an injected client; it still gates config-level scheme validation via ValidateWithInsecure. Corrected to name what the client actually takes over (request-time scheme enforcement and dial-time private-IP blocking), and keyed the shared-client advice in Config.UpstreamFactory by trust posture rather than by host. - LOW pkg/authserver/upstream/oauth2.go (3906424471): the drain defer in newBaseOAuth2Provider implied a leak that does not exist — nothing below it issues a request. Reworded as a guard for a future I/O step.
Addresses #6480 review comments: - MEDIUM pkg/authserver/server_impl.go (3906424383): the unexported withUpstreamFactory seam silently overrode the new public Config.UpstreamFactory. Deleted the seam along with serverOption, serverOptions and resolveUpstreamFactory; buildUpstreams now reads cfg.UpstreamFactory and falls back to DefaultUpstreamFactory. All in-package callers set the field, so the precedence question is gone. - LOW pkg/authserver/server_impl.go (3906424429): a typed nil provider passed the `== nil` check and would nil-deref on the embedded *BaseOAuth2Provider during the drain. isNilProvider now rejects it, with both cases covered. This also surfaced that the integration helper relied on a nil provider being accepted; it now supplies a placeholder. - MEDIUM pkg/authserver/server_impl.go (3906424373): documented that newServer's deferred drain guards no currently-reachable failure — verified all five post-buildUpstreams steps are unreachable with a Config that passes Validate — rather than contriving a test for a path that cannot fire.
Addresses #6480 review comments: - LOW pkg/networking/http_client.go (3906424445): declared networking.IdleConnectionCloser once and asserted against it from both new forwarders instead of adding a fourth and fifth anonymous `interface{ CloseIdleConnections() }`. Its doc states the rule for future wrappers; upstream.IdleConnectionCloser now cross-references it as the provider-level analogue. - LOW pkg/networking/http_client_test.go (3906424407): the pool-bounds assertions compared the constants to themselves, so zeroing a constant passed. Now literals. - LOW pkg/authserver/server_test.go (3906424455): the exact live-connection count after construction was brittle against an added discovery fetch or retry; relaxed to GreaterOrEqual, keeping the exact-zero check after Close. Added idempotent storage cleanup, and the factory test now drains the real provider it builds. - LOW pkg/authserver/runner/embeddedauthserver.go (3906424462): the delegation was unasserted — an empty body passed the suite. stubServer counts calls and the new test also covers the safe post-Close call.
Addresses #6480 review comments: - MEDIUM pkg/authserver/upstream/oauth2.go (3906424400): the optional-capability rationale was restated in 4 places and the injected-client ownership rule in 7. Each now lives once at its natural home — upstream.IdleConnectionCloser and BaseOAuth2Provider.CloseIdleConnections — with one-line pointers elsewhere, and the review narrative is out of the doc comments. - MEDIUM pkg/authserver/server.go (3906424390): dropped the "Server has a single in-repo implementation" clause, which this PR's own diff contradicts (stubServer needed the method, and EmbeddedAuthServer mirrors the set), keeping the compile-time-safety reason that stands on its own.
jhrozek
left a comment
There was a problem hiding this comment.
Approved.
Non-blocking completeness note: adding CloseIdleConnections() to the exported authserver.Server interface (pkg/authserver/server.go:62) is a source-incompatible API expansion for out-of-tree implementations and test doubles. We control the current consumers, so this is acceptable for this PR, but it is worth recording as an intentional public API compatibility trade-off.
Adding CloseIdleConnections() to the exported authserver.Server interface was a source-incompatible change for out-of-tree implementations and test doubles, and it bought exactly one in-tree call site: EmbeddedAuthServer, whose field is typed as the interface. That is a poor trade for a public API break. Server is unchanged again. The drain is now a package-level CloseIdleConnections(Server) bool that detects an unexported capability interface, mirroring how upstream.IdleConnectionCloser already works one layer down — so both layers use capability detection, for the same reason, and the asymmetry that needed explaining is gone. The compile-time safety that motivated requiring the method is kept by `var _ idleConnectionCloser = (*server)(nil)`: a server built by New can never silently no-op. The bool return covers the remaining case, so a caller holding a foreign implementation learns it did nothing rather than assuming success. Public API delta is now one added function rather than one added required interface method.
|
Thanks for the approval — but on reflection I'd rather not spend the compatibility break, so I've removed it in 22b52be. What replaced it. A package-level function that detects an unexported capability: type idleConnectionCloser interface{ CloseIdleConnections() }
func CloseIdleConnections(s Server) bool {
closer, ok := s.(idleConnectionCloser)
if ok {
closer.CloseIdleConnections()
}
return ok
}plus Why this is not a downgrade. The reason an earlier review round argued for a required method was compile-time safety — "the call cannot silently degrade to a no-op". That is preserved: the What tipped it. The interface method bought exactly one in-tree call site — Two side benefits: the public API delta is now +1 function instead of +1 required interface method, and both layers use capability detection for the same reason, so the Coverage. I kept the interface unexported on purpose: exporting PR body updated — the compile break is no longer listed as a user-facing change. |
"re-declaring" -> "redeclaring" in the networking.IdleConnectionCloser comment, flagged by the Spellcheck / Codespell CI check.
Summary
Every
*http.Clientproduced bynetworking.HttpClientBuilder.Build()had a connection pool that could never be drained.IdleConnTimeoutwas left at its zero value, which inhttp.Transportmeans never expire, so a client that performed a single request and was then dropped pinned a socket and itsreadLoop/writeLoopgoroutine pair for the lifetime of the process. The obvious remediation was already unavailable and silently so:Buildalways wraps the pool in aValidatingTransport(and, on theauthTokenFilepath, anoauth2.Transport), neither of which implementsCloseIdleConnections, andhttp.Client.CloseIdleConnectionsis a type assertion on the outermost transport — so the call returned having done nothing, with no error and no log.On the auth-server upstream path this compounded into a real leak.
authserver.Newbuilds a private HTTP client per configured upstream with keep-alives deliberately on,server.Close()closed storage only, and nothing walkeds.upstreams. An embedder that reconstructs the server to change its upstream set — a pattern the API otherwise supports, sinceNewaccepts an existingstorage.StorageandKeyProvider— leaked one transport per OIDC upstream per reconstruction, unbounded, with no correct call available to reclaim them:Close()would also close the storage the new server was now serving through.This lands all three changes proposed in the issue:
networking.Build— finiteIdleConnTimeout, plusMaxIdleConnsandMaxIdleConnsPerHost. This alone converts every leak of this shape in the repo from unbounded to self-draining and makes an abandoned transport garbage-collectable.CloseIdleConnectionsforwarders on the transport wrappers, onBaseOAuth2Provider(promoted toOIDCProviderImpl), and a new package-levelauthserver.CloseIdleConnections(Server) boolkept deliberately distinct fromClose(). The split is the load-bearing part for embedders: it is what lets a superseded server release its own upstream connections without touching shared storage. It is a function rather than aServermethod so thatServerstays source-compatible — see the user-facing-change section.Config.UpstreamFactory, withDefaultUpstreamFactoryexported so a custom factory can delegate. This makes reconstruction structurally free (share a client per issuer host via the already-publicWithHTTPClient/WithOAuth2HTTPClient) and makes per-upstream failure isolation possible.What this closes, and what it does not. #6479 is scoped to HTTP client pools — "HTTP client pools are unbounded and uncloseable" — and all three of its proposed changes land here. Closing it is therefore accurate for its stated scope.
One adjacent resource is deliberately left open and tracked separately: a
TrustedIssuersserver starts one JWKS refresh worker pool per issuer rooted atcontext.Background(), released by neitherClosenorCloseIdleConnections, so a process that reconstructs the auth server repeatedly still grows goroutines through that path. That is a different resource than the connection pools this issue is about, it is pre-existing, and it is now tracked in #6482 and documented in theScope:paragraph onServer.CloseIdleConnectionsso the remaining gap is not silent. #6483 tracks the hand-rolled transports outsidenetworking.Buildthat share the original defect.Scope decision. The issue says of its item 3: "This one is a genuine API surface commitment ... so it deserves its own discussion and shouldn't block 1 and 2." This PR lands it anyway, as an explicit choice rather than an oversight: it is the piece that makes reconstruction structurally free rather than merely bounded, which is the scenario the issue is about, so splitting it would leave the headline case only half-addressed across two PRs. It is separable — items 1+2 are a self-contained ~200-line fix — so if a reviewer would rather decide the API question on its own, say so and I will split it out. Note that
Config.UpstreamFactorycurrently has no in-repo caller.Closes #6479
Type of change
Test plan
task test) — full unit suite passestask test-e2e)task lint-fix) — 0 issues;task license-checkalso passesNew tests:
TestBuild_BoundsIdleConnectionPoolandTestBuild_CloseIdleConnectionsReachesPool(regression guards against the two builder defects, the latter asserting the call reaches the real pool through the wrappers);TestServer_CloseIdleConnections,TestServer_Close_DrainsRealOIDCUpstream(end-to-end against anhttptestOIDC issuer — the actual guard on the promoted method),TestNewServer_UpstreamFactoryPrecedence,TestBuildUpstreams_DrainsAlreadyBuiltProvidersOnFailure,TestBuildUpstreams_RejectsNilProvider;TestBaseOAuth2Provider_CloseIdleConnections,TestBaseOAuth2Provider_CloseIdleConnections_InjectedClientNotDrained(ownership),TestNewOIDCProvider_DrainsOwnClientOnFailure.Changes
pkg/networking/http_client.goIdleConnTimeout(90s),MaxIdleConns(100),MaxIdleConnsPerHost(4) inBuild. Add(*ValidatingTransport).CloseIdleConnections; addcloseIdlerTransportto carry the capability pastoauth2.Transporton theauthTokenFilepath.pkg/authserver/server.goCloseIdleConnections(Server) booland the unexported capability it detects, documenting theClose/CloseIdleConnectionssplit and its scope.Serveritself is unchanged. Add the exportedUpstreamProviderFactorytype.pkg/authserver/config.goConfig.UpstreamFactorywith the connection-reuse and failure-isolation rationale and a SECURITY note (the provider validates upstream ID tokens).pkg/authserver/server_impl.goDefaultUpstreamFactory; extractbuildUpstreams/closeUpstreamIdleConnections/resolveUpstreamFactory; drain already-built providers when construction aborts and when any laternewServerstep fails;Closenow drains before closing storage. Reject a nil provider from a custom factory, and name the offending upstream in the error.pkg/authserver/upstream/oauth2.goIdleConnectionCloserinterface and(*BaseOAuth2Provider).CloseIdleConnections. TrackownsHTTPClientso an injected client is never drained. Apply options before building the default client, and warn when a configuredCAFilePathis superseded by an injected client.pkg/authserver/upstream/oidc.go*OIDCProviderImpl.pkg/authserver/server/tokenexchange/multi_issuer_validator.goCloseIdleConnectionsforwarder onlimitedBodyTransportso the wrapper does not silently swallow the call (no pool today — the client disables keep-alives — but keeps that an optimization, not a correctness dependency).pkg/authserver/runner/embeddedauthserver.goCloseIdleConnectionsto the embedded server viaauthserver.CloseIdleConnections.pkg/authserver/upstream/mocks/mock_provider.goMockIdleConnectionCloser.pkg/authserver/upstream/doc.goIdleConnectionCloseras an optional capability for externalOAuth2Providerimplementations.Does this introduce a user-facing change?
Yes, two — both additive. No interface is widened and nothing that compiles today stops compiling, other than case 2 below.
An earlier revision of this PR added
CloseIdleConnections()as a required method on the exportedauthserver.Serverinterface, which would have been a source-incompatible change for any out-of-tree implementation or test double. That is gone. The drain is now a package-levelauthserver.CloseIdleConnections(Server) bool, which detects an unexported capability interface — the same shapeupstream.IdleConnectionCloseralready uses one layer down, so both layers now use capability detection for the same reason and the asymmetry that previously needed explaining is gone.The compile-time safety that motivated requiring the method is preserved by
var _ idleConnectionCloser = (*server)(nil): a server built byNewcan never silently no-op. Theboolreturn covers the remaining case, so a caller holding an implementation that predates the capability learns it did nothing instead of assuming success. Both new tests assign a capability-freeServerimplementation to the interface, which is a compile-time proof of the source compatibility claim.1. Behavioral change in retained connections.
MaxIdleConnsPerHostgoes from Go's default of 2 to 4 for every client produced bynetworking.HttpClientBuilder.Build. UnlikeIdleConnTimeoutandMaxIdleConns(whose zero values are "unlimited", so setting them only imposes bounds), the per-host zero value was already bounded athttp.DefaultMaxIdleConnsPerHost— so this is an increase, not a newly imposed cap. A client may now retain up to 4 idle connections per host instead of 2. The trade is against the now-finite 90sIdleConnTimeout: most clients this builder produces are host-scoped and drive a single upstream, so a slightly larger per-host pool serves concurrent requests without dialing, and retention is now time-bounded where before it was forever.2. A CA bundle configured alongside an injected HTTP client is now an error. Options are applied before the default client is built, so
newHTTPClientForHost— and with it the read and parse ofconfig.CAFilePath— is skipped when a caller injects a client viaupstream.WithHTTPClient/WithOAuth2HTTPClient. Rather than let a trust-anchor decision be silently dropped, both provider constructors now returnupstream.ErrCABundleWithInjectedClientfor that combination. An external embedder that today sets both will start failing at construction; the combination was already meaningless (the injected client carries its own TLS trust, and the CA-configured client was built and then discarded), so this converts a silent no-op into a boot-time error. No in-repo caller injects a client.Special notes for reviewers
Two follow-ups are deliberately out of scope for this PR:
Bound the hand-rolled http.Transport pools outside networking.Build #6483 — hand-rolled transports outside
networking.Build. Roughly five&http.Transport{}literals elsewhere in the tree — inpkg/auth/discovery,pkg/oauthproto, andpkg/auth/oauth/oidc.go— share the same zero-IdleConnTimeoutdefect, and four more wrappers (bearerTokenTransport,oauthproto.UserAgentTransport,auth.WrapTransport,pkg/authz/authorizers/http) still swallowCloseIdleConnectionson a builder-built client. The right long-term answer is routing them through the builder, which is a larger refactor than this PR should carry.Release per-issuer JWKS refresh workers on auth-server shutdown #6482 — per-issuer JWKS refresh workers. A server configured with
TrustedIssuersstarts one JWKS refresh worker pool per issuer inMultiIssuerTokenValidator, againstcontext.Background(). These are released by neitherClosenorCloseIdleConnections, so an embedder that reconstructs repeatedly still grows goroutines through that path. Documented in the Scope paragraph onServer.CloseIdleConnectionsso the remaining gap is not silent.Two points worth extra scrutiny:
CAFilePathwould no longer be read or validated in that case, so the combination is rejected outright (see user-facing change 2).CAFilePathis the only field needing this treatment:AllowPrivateIPswas already inert with an injected client, andInsecureAllowHTTPstill gates config-level scheme validation viaValidateWithInsecure. What the injected client does take over is request-time scheme enforcement and dial-time private-IP blocking, which the option docs now say explicitly.CloseIdleConnectionsis a no-op on a caller-injected client, since the caller may be sharing it across providers and draining it would cold-start connections a live provider is using. This is what makes theConfig.UpstreamFactoryconnection-sharing pattern safe, and it is covered by a dedicated test.Generated with Claude Code