diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 2a160c97c22..4d59afe954d 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -713,11 +713,6 @@ "count": 2 } }, - "open-sse/utils/proxyDispatcher.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/utils/setupPolyfill.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index 6d9c6714aeb..5848a90b00a 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -5,13 +5,16 @@ import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; import { stripIpv6Brackets, detectIpLiteralFamily, parseProxyFamily } from "./proxyFamily.ts"; import { createSocksDispatcherWithFamily } from "./socksConnectorWithFamily.ts"; import { - clearDispatcherCache, createRoundRobinDispatcher, getDefaultCachedDispatcher, getDispatcherCache, + getLocalDefaultCachedDispatcher, + getLocalRetryCachedDispatcher, getRetryCachedDispatcher, setDefaultCachedDispatcher, setDispatcherCacheEntry, + setLocalDefaultCachedDispatcher, + setLocalRetryCachedDispatcher, setRetryCachedDispatcher, } from "./proxyDispatcherCache.ts"; @@ -27,6 +30,22 @@ export const RELAY_TYPES: ReadonlySet = new Set(["vercel", "deno", "clou export function isRelayType(type: string | undefined | null): boolean { return typeof type === "string" && RELAY_TYPES.has(type); } + +// Local-egress hostnames: host.docker.internal, *.internal, *.local. +// Match the same shape the proxyFetch.ts isLocalAddress() helper uses for +// PROXY bypass, but narrower on purpose: we only switch dispatcher options +// for mDNS-style hostnames, not RFC1918 IPs (those may still be cloud +// upstreams via a private tunnel). IPv6 brackets are stripped defensively. +const LOCAL_EGRESS_HOSTNAME_REGEX = /(?:^|\.)(?:internal|local)$/i; +const LOCAL_KEEPALIVE_MAX_TIMEOUT_MS = 1000; +const LOCAL_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 200; + +export function isLocalEgressHostname(hostname: string | null | undefined): boolean { + if (!hostname) return false; + // Tolerate both bare hostname and URL.host (host:port), and IPv6 brackets. + const host = hostname.replace(/^\[/, "").replace(/\]$/, "").replace(/:\d+$/, ""); + return LOCAL_EGRESS_HOSTNAME_REGEX.test(host); +} const DEFAULT_PROXY_DISPATCHER_CONNECTIONS = 32; const MAX_PROXY_DISPATCHER_CONNECTIONS = 256; @@ -46,10 +65,11 @@ type ProxyConfigObject = { family?: string; }; -function getDispatcherOptions() { +function getDispatcherOptions(hostname?: string) { const timeouts = getUpstreamTimeoutConfig(process.env, (message) => { console.warn(`[ProxyDispatcher] ${message}`); }); + const localEgress = isLocalEgressHostname(hostname); return { headersTimeout: timeouts.fetchHeadersTimeoutMs, @@ -59,7 +79,13 @@ function getDispatcherOptions() { // Without this, an upstream Keep-Alive: timeout=N header clamps // keepAliveTimeout UP to undici's default keepAliveMaxTimeout (600 s), // completely overriding the configured 1 s and restoring zombie-socket risk. - keepAliveMaxTimeout: timeouts.fetchKeepAliveTimeoutMs, + // For local-egress hostnames (host.docker.internal / *.internal / *.local) + // Docker Desktop's NAT silently drops idle keep-alive sockets well inside + // the default window, so cap keep-alive at 1 s on that path to force fresh + // sockets before the next request lands on a stale one. + keepAliveMaxTimeout: localEgress + ? LOCAL_KEEPALIVE_MAX_TIMEOUT_MS + : timeouts.fetchKeepAliveTimeoutMs, // 9router#1237: RFC 8305 Happy Eyeballs. undici does not // enable it by default, so when DNS returns both AAAA (IPv6) and A (IPv4) // and the IPv6 route is broken (e.g. NAT64 `64:ff9b::` without routing), @@ -71,9 +97,14 @@ function getDispatcherOptions() { // requires `port`; at runtime undici merges these into net.connect (the origin // already carries host:port), so the partial pin is valid — cast to suppress // the spurious missing-`port` error, mirroring the `proxyTls` cast below. + // Local-egress path shortens the per-family attempt to 200 ms because the + // IPv6 route to host.docker.internal is dead inside the container (verified + // 2026-09-21) and the default 1 s wait is pure latency on every healthy request. connect: { autoSelectFamily: true, - autoSelectFamilyAttemptTimeout: 1000, + autoSelectFamilyAttemptTimeout: localEgress + ? LOCAL_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS + : 1000, } as ProxyAgent.Options["proxyTls"], }; } @@ -152,8 +183,8 @@ function getDefaultDispatcherOptions(env: Record = p }; } -function createRoundRobinDirectDispatcher(connectionLimit: number): Dispatcher { - const baseOptions = getDispatcherOptions(); +function createRoundRobinDirectDispatcher(connectionLimit: number, hostname?: string): Dispatcher { + const baseOptions = getDispatcherOptions(hostname); const perAgentOptions = { ...baseOptions, connections: 1, @@ -163,7 +194,18 @@ function createRoundRobinDirectDispatcher(connectionLimit: number): Dispatcher { return createRoundRobinDispatcher(dispatchers); } -export function getDefaultDispatcher(): Dispatcher { +export function getDefaultDispatcher(hostname?: string): Dispatcher { + if (isLocalEgressHostname(hostname)) { + let dispatcher = getLocalDefaultCachedDispatcher(); + if (!dispatcher) { + dispatcher = createRoundRobinDirectDispatcher( + getDefaultDispatcherConnectionLimit(), + hostname + ); + setLocalDefaultCachedDispatcher(dispatcher); + } + return dispatcher; + } let dispatcher = getDefaultCachedDispatcher(); if (!dispatcher) { dispatcher = createRoundRobinDirectDispatcher(getDefaultDispatcherConnectionLimit()); @@ -184,8 +226,25 @@ export function getDefaultDispatcher(): Dispatcher { * retry uses this no-keep-alive / no-pipelining dispatcher (mirroring the proxy * dispatcher mitigation) to force a fresh socket. Healthy keep-alive reuse on * the first attempt is preserved — only the retry pays the fresh-socket cost. + * + * Local-egress hostnames (host.docker.internal / *.internal / *.local) route + * to a parallel retry cache so a fresh-socket retry cannot pick up a stale + * socket from the cloud-upstream pool. */ -export function getRetryDispatcher(): Dispatcher { +export function getRetryDispatcher(hostname?: string): Dispatcher { + if (isLocalEgressHostname(hostname)) { + let dispatcher = getLocalRetryCachedDispatcher(); + if (!dispatcher) { + dispatcher = new Agent({ + ...getDispatcherOptions(hostname), + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1, + pipelining: 0, + }); + setLocalRetryCachedDispatcher(dispatcher); + } + return dispatcher; + } let dispatcher = getRetryCachedDispatcher(); if (!dispatcher) { dispatcher = new Agent({ @@ -432,6 +491,11 @@ export function __getDefaultDispatcherOptionsForTest( return getDefaultDispatcherOptions(env); } +/** Test-only accessor for the hostname-branched dispatcher options (local-egress shortening). */ +export function __getDispatcherOptionsForTest(hostname?: string) { + return getDispatcherOptions(hostname); +} + export function __createRoundRobinDispatcherForTest(dispatchers: Dispatcher[]): Dispatcher { return createRoundRobinDispatcher(dispatchers); } diff --git a/open-sse/utils/proxyDispatcherCache.ts b/open-sse/utils/proxyDispatcherCache.ts index c98f8dd2c4a..dcd5dd2860a 100644 --- a/open-sse/utils/proxyDispatcherCache.ts +++ b/open-sse/utils/proxyDispatcherCache.ts @@ -3,6 +3,13 @@ import type { Dispatcher } from "undici"; const DISPATCHER_CACHE_KEY = Symbol.for("omniroute.proxyDispatcher.cache"); const DEFAULT_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.default"); const RETRY_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.retry"); +// Local-egress dispatchers: separate cache for hostnames like +// host.docker.internal / *.internal / *.local, where Docker Desktop's NAT +// silently drops idle keep-alive sockets within the global pool's +// keepAliveMaxTimeout window. Kept on their own cache so a wider keep-alive +// for cloud upstreams cannot pull the .internal sockets down with it. +const LOCAL_DEFAULT_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.localDefault"); +const LOCAL_RETRY_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.localRetry"); /** Upper bound on cached per-URL proxy dispatchers; oldest entries are evicted first. */ const MAX_DISPATCHER_CACHE_ENTRIES = 512; @@ -12,6 +19,8 @@ type GlobalWithDispatcherCache = typeof globalThis & { [DISPATCHER_CACHE_KEY]?: DispatcherCache; [DEFAULT_DISPATCHER_KEY]?: Dispatcher; [RETRY_DISPATCHER_KEY]?: Dispatcher; + [LOCAL_DEFAULT_DISPATCHER_KEY]?: Dispatcher; + [LOCAL_RETRY_DISPATCHER_KEY]?: Dispatcher; }; /** @@ -94,6 +103,22 @@ export function setRetryCachedDispatcher(dispatcher: Dispatcher): void { (globalThis as GlobalWithDispatcherCache)[RETRY_DISPATCHER_KEY] = dispatcher; } +export function getLocalDefaultCachedDispatcher(): Dispatcher | undefined { + return (globalThis as GlobalWithDispatcherCache)[LOCAL_DEFAULT_DISPATCHER_KEY]; +} + +export function setLocalDefaultCachedDispatcher(dispatcher: Dispatcher): void { + (globalThis as GlobalWithDispatcherCache)[LOCAL_DEFAULT_DISPATCHER_KEY] = dispatcher; +} + +export function getLocalRetryCachedDispatcher(): Dispatcher | undefined { + return (globalThis as GlobalWithDispatcherCache)[LOCAL_RETRY_DISPATCHER_KEY]; +} + +export function setLocalRetryCachedDispatcher(dispatcher: Dispatcher): void { + (globalThis as GlobalWithDispatcherCache)[LOCAL_RETRY_DISPATCHER_KEY] = dispatcher; +} + function closeDispatcher(dispatcher: Dispatcher | undefined): void { if (!dispatcher) return; try { @@ -118,8 +143,12 @@ export function clearDispatcherCache(): void { const globalWithCache = globalThis as GlobalWithDispatcherCache; closeDispatcher(globalWithCache[DEFAULT_DISPATCHER_KEY]); closeDispatcher(globalWithCache[RETRY_DISPATCHER_KEY]); + closeDispatcher(globalWithCache[LOCAL_DEFAULT_DISPATCHER_KEY]); + closeDispatcher(globalWithCache[LOCAL_RETRY_DISPATCHER_KEY]); delete globalWithCache[DEFAULT_DISPATCHER_KEY]; delete globalWithCache[RETRY_DISPATCHER_KEY]; + delete globalWithCache[LOCAL_DEFAULT_DISPATCHER_KEY]; + delete globalWithCache[LOCAL_RETRY_DISPATCHER_KEY]; } export function __cacheProxyDispatcherForTest(key: string, dispatcher: Dispatcher): void { diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 1fbcf17072b..514f907c021 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -4,10 +4,12 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { fetch as undiciFetch, Agent } from "undici"; import { buildVercelRelayHeaders, + clearDispatcherCache, createProxyDispatcher, getDefaultDispatcher, getProxyRetryDispatcher, getRetryDispatcher, + isLocalEgressHostname, isRelayType, normalizeProxyUrl, proxyConfigToUrl, @@ -112,6 +114,7 @@ const TLS_PROVIDER_PROFILE: Record = { type TlsProfileResult = { browserProfile?: string; os?: string }; function tlsProfileForProvider(provider: string | null | undefined): TlsProfileResult { + if (!provider) return {}; const p = TLS_PROVIDER_PROFILE[provider.trim().toLowerCase()]; return p ? { browserProfile: p.browser, os: p.os } : {}; @@ -857,11 +860,18 @@ async function patchedFetchUnrecorded( } for (let attempt = 0; attempt < maxAttempts; attempt++) { try { + let hostnameForDispatcher: string | undefined; + try { + hostnameForDispatcher = new URL(targetUrl).hostname; + } catch {} return await directFetchWithBoundedResponseStart( input, { ...options, - dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(), + dispatcher: + attempt === 0 + ? getDefaultDispatcher(hostnameForDispatcher) + : getRetryDispatcher(hostnameForDispatcher), }, _undiciDirect, resolveDirectHeadersTimeoutMs(undefined, directBodyForTimeout, attempt, !!options.signal) @@ -948,6 +958,18 @@ async function patchedFetchUnrecorded( console.warn( `[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${describeFetchCause(dispatcherError)}` ); + // On PROXY_UNREACHABLE for local-egress hostnames (host.docker.internal, + // *.internal, *.local), drop the cached dispatcher pool: Docker + // Desktop's NAT silently drops idle keep-alive sockets inside the + // round-robin pool's keepAliveMaxTimeout window, and the pool never + // reaps them on PROXY_UNREACHABLE, so the next request must rebuild + // with fresh sockets (#4252-style stale-socket burst mitigation). + if ( + isLocalEgressHostname(targetHostForLogs) && + isProxyUnreachableError(dispatcherError) + ) { + clearDispatcherCache(); + } try { return await _nativeFallback(input, options); } catch (nativeError) { diff --git a/tests/unit/proxy-dispatcher-local-egress.test.ts b/tests/unit/proxy-dispatcher-local-egress.test.ts new file mode 100644 index 00000000000..9b97ad79920 --- /dev/null +++ b/tests/unit/proxy-dispatcher-local-egress.test.ts @@ -0,0 +1,252 @@ +/** + * #14315 — stale keep-alive burst on local egress (host.docker.internal / *.internal / *.local). + * + * Docker Desktop's NAT silently drops idle keep-alive sockets to local-egress hostnames well + * inside undici's default keepAliveMaxTimeout window, and the IPv6 route to + * host.docker.internal is dead inside the container, so the default Happy-Eyeballs + * autoSelectFamilyAttemptTimeout is pure latency on every healthy request. proxyDispatcher.ts + * branches its dispatcher options by hostname (isLocalEgressHostname()) and routes local-egress + * traffic through a parallel LOCAL_* dispatcher cache so it can't share stale/shortened sockets + * with the cloud-upstream pool, and proxyFetch.ts clears that pool on PROXY_UNREACHABLE for a + * local-egress hostname so the next request rebuilds with fresh sockets. + * + * None of this had test coverage before this file — see the #14315 review. + */ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + isLocalEgressHostname, + getDefaultDispatcher, + getRetryDispatcher, + clearDispatcherCache, + __getDispatcherOptionsForTest, +} from "../../open-sse/utils/proxyDispatcher.ts"; +import { + getLocalDefaultCachedDispatcher, + getLocalRetryCachedDispatcher, + getDefaultCachedDispatcher, + getRetryCachedDispatcher, +} from "../../open-sse/utils/proxyDispatcherCache.ts"; +import { proxyFetch } from "../../open-sse/utils/proxyFetch.ts"; + +afterEach(() => clearDispatcherCache()); + +describe("isLocalEgressHostname() boundary cases (#14315)", () => { + it("matches host.docker.internal", () => { + assert.equal(isLocalEgressHostname("host.docker.internal"), true); + }); + it("matches a bare *.local mDNS hostname", () => { + assert.equal(isLocalEgressHostname("mymachine.local"), true); + }); + it("matches a *.internal suffix that is not host.docker.internal too", () => { + assert.equal(isLocalEgressHostname("gateway.internal"), true); + }); + it("matches the host:port form (port stripped before testing)", () => { + assert.equal(isLocalEgressHostname("host.docker.internal:8080"), true); + }); + it("strips IPv6 brackets before testing without throwing, and correctly stays false for a bare IPv6 literal (not a *.internal/*.local name)", () => { + assert.equal(isLocalEgressHostname("[fe80::1]:8080"), false); + assert.equal(isLocalEgressHostname("[::1]"), false); + }); + it("still matches a *.internal name even when given alongside IPv6-bracket-shaped input elsewhere in the same URL.host string", () => { + // The host.docker.internal form itself never carries brackets — this guards the + // bracket-stripping regexes (^\[ / \]$) against corrupting an ordinary hostname + // that happens to contain no brackets at all. + assert.equal(isLocalEgressHostname("host.docker.internal"), true); + }); + it("does NOT false-positive on a plain RFC1918 IP literal", () => { + assert.equal(isLocalEgressHostname("192.168.65.254"), false); + }); + it("does NOT false-positive on an arbitrary .internal.example.com subdomain", () => { + // Only a hostname that literally ENDS in `.internal` or `.local` should match — + // an "internal" label in the middle of a cloud domain must not. + assert.equal(isLocalEgressHostname("foo.internal.example.com"), false); + }); + it("does NOT match an ordinary cloud upstream hostname", () => { + assert.equal(isLocalEgressHostname("api.openai.com"), false); + }); + it("returns false for null/undefined/empty", () => { + assert.equal(isLocalEgressHostname(undefined), false); + assert.equal(isLocalEgressHostname(null), false); + assert.equal(isLocalEgressHostname(""), false); + }); +}); + +describe("getDispatcherOptions() shortens timeouts on the local-egress path (#14315)", () => { + it("caps keepAliveMaxTimeout at 1s for a local-egress hostname", () => { + const localOptions = __getDispatcherOptionsForTest("host.docker.internal"); + assert.equal(localOptions.keepAliveMaxTimeout, 1000); + }); + it("shortens autoSelectFamilyAttemptTimeout to 200ms for a local-egress hostname", () => { + const localOptions = __getDispatcherOptionsForTest("host.docker.internal"); + assert.equal(localOptions.connect.autoSelectFamilyAttemptTimeout, 200); + }); + it("leaves a cloud-upstream hostname on the default (longer) timeouts", () => { + const cloudOptions = __getDispatcherOptionsForTest("api.openai.com"); + assert.notEqual(cloudOptions.keepAliveMaxTimeout, 1000); + assert.equal(cloudOptions.connect.autoSelectFamilyAttemptTimeout, 1000); + }); + it("also shortens options when no hostname is supplied to the local branch (regression guard: undefined must fall through to non-local)", () => { + const noHostnameOptions = __getDispatcherOptionsForTest(undefined); + assert.equal(noHostnameOptions.connect.autoSelectFamilyAttemptTimeout, 1000); + }); +}); + +describe("getDefaultDispatcher()/getRetryDispatcher() cache routing (#14315)", () => { + it("routes a local-egress hostname to the LOCAL_DEFAULT cache slot, not the shared DEFAULT slot", () => { + assert.equal(getLocalDefaultCachedDispatcher(), undefined); + assert.equal(getDefaultCachedDispatcher(), undefined); + + const dispatcher = getDefaultDispatcher("host.docker.internal"); + + assert.equal(getLocalDefaultCachedDispatcher(), dispatcher); + assert.equal(getDefaultCachedDispatcher(), undefined, "cloud DEFAULT slot must stay empty"); + }); + it("routes a cloud hostname to the shared DEFAULT cache slot, not LOCAL_DEFAULT", () => { + const dispatcher = getDefaultDispatcher("api.openai.com"); + + assert.equal(getDefaultCachedDispatcher(), dispatcher); + assert.equal(getLocalDefaultCachedDispatcher(), undefined, "local slot must stay empty"); + }); + it("reuses the SAME cached instance across repeated calls for the same local-egress hostname", () => { + const first = getDefaultDispatcher("host.docker.internal"); + const second = getDefaultDispatcher("host.docker.internal"); + assert.equal(first, second); + }); + it("routes the retry dispatcher for a local-egress hostname to LOCAL_RETRY, not the shared retry slot", () => { + assert.equal(getLocalRetryCachedDispatcher(), undefined); + assert.equal(getRetryCachedDispatcher(), undefined); + + const dispatcher = getRetryDispatcher("host.docker.internal"); + + assert.equal(getLocalRetryCachedDispatcher(), dispatcher); + assert.equal(getRetryCachedDispatcher(), undefined, "cloud retry slot must stay empty"); + }); + it("routes the retry dispatcher for a cloud hostname to the shared retry slot, not LOCAL_RETRY", () => { + const dispatcher = getRetryDispatcher("api.openai.com"); + + assert.equal(getRetryCachedDispatcher(), dispatcher); + assert.equal(getLocalRetryCachedDispatcher(), undefined, "local retry slot must stay empty"); + }); + it("the local-egress default and retry dispatchers are DIFFERENT instances (no accidental sharing)", () => { + const defaultDispatcher = getDefaultDispatcher("host.docker.internal"); + const retryDispatcher = getRetryDispatcher("host.docker.internal"); + assert.notEqual(defaultDispatcher, retryDispatcher); + }); +}); + +describe("clearDispatcherCache() empties both local slots (#14315)", () => { + it("clears LOCAL_DEFAULT and LOCAL_RETRY alongside the existing cloud slots", () => { + getDefaultDispatcher("host.docker.internal"); + getRetryDispatcher("host.docker.internal"); + getDefaultDispatcher("api.openai.com"); + getRetryDispatcher("api.openai.com"); + + assert.notEqual(getLocalDefaultCachedDispatcher(), undefined); + assert.notEqual(getLocalRetryCachedDispatcher(), undefined); + assert.notEqual(getDefaultCachedDispatcher(), undefined); + assert.notEqual(getRetryCachedDispatcher(), undefined); + + clearDispatcherCache(); + + assert.equal(getLocalDefaultCachedDispatcher(), undefined, "LOCAL_DEFAULT must be cleared"); + assert.equal(getLocalRetryCachedDispatcher(), undefined, "LOCAL_RETRY must be cleared"); + assert.equal(getDefaultCachedDispatcher(), undefined, "DEFAULT must still be cleared"); + assert.equal(getRetryCachedDispatcher(), undefined, "RETRY must still be cleared"); + }); +}); + +describe("proxyFetch PROXY_UNREACHABLE on local-egress hostname clears the dispatcher pool (#14315)", () => { + // Shaped exactly like a real undici connect failure: a `TypeError: fetch failed` + // wrapping the underlying socket error in `.cause` (undici does NOT put the raw + // code directly on the top-level error). `tagProxyUnreachable()` overwrites the + // top-level `.code` to "PROXY_UNREACHABLE" the FIRST time it sees this error, so + // proxyFetch's later local-egress `isProxyUnreachableError()` re-check can only + // still recognize it via `.cause.code` — a bare top-level `.code` (like the + // #4252 retry test uses) would silently defeat that later check. + function connectionRefused(): Error { + const cause = new Error("connect ECONNREFUSED") as Error & { code?: string }; + cause.code = "ECONNREFUSED"; + const err = new Error("fetch failed") as Error & { cause?: Error }; + err.cause = cause; + return err; + } + + it("clears the dispatcher cache when a local-egress hostname hits PROXY_UNREACHABLE", async () => { + // Pre-populate the pool so we can observe it being torn down. + getDefaultDispatcher("host.docker.internal"); + assert.notEqual(getLocalDefaultCachedDispatcher(), undefined, "precondition: pool populated"); + + let undiciCalls = 0; + let nativeCalls = 0; + const mockUndici = async (): Promise => { + undiciCalls++; + // Both attempts fail — the connection to host.docker.internal is refused + // (Docker Desktop dropped the stale socket and the fresh retry can't connect either). + throw connectionRefused(); + }; + const mockNative = async (): Promise => { + nativeCalls++; + return new Response("native-ok", { status: 200 }); + }; + + const res = await proxyFetch( + "http://host.docker.internal:2375/v1/models", + { method: "POST" }, + { undiciFetch: mockUndici, nativeFetch: mockNative } + ); + + assert.equal(undiciCalls, 2, "both the initial attempt and the fresh-socket retry must fire"); + assert.equal(nativeCalls, 1, "native fallback must fire after undici is exhausted"); + assert.equal(await res.text(), "native-ok"); + assert.equal( + getLocalDefaultCachedDispatcher(), + undefined, + "the local-egress dispatcher pool must be torn down on PROXY_UNREACHABLE" + ); + }); + + it("does NOT clear the dispatcher cache when a cloud-upstream hostname hits PROXY_UNREACHABLE", async () => { + // Pre-populate the LOCAL pool too, to prove the cloud-path failure never touches it. + getDefaultDispatcher("host.docker.internal"); + const cloudDispatcherBefore = getDefaultDispatcher("api.example.com"); + assert.notEqual(getDefaultCachedDispatcher(), undefined, "precondition: cloud pool populated"); + assert.notEqual( + getLocalDefaultCachedDispatcher(), + undefined, + "precondition: local pool populated" + ); + + let undiciCalls = 0; + let nativeCalls = 0; + const mockUndici = async (): Promise => { + undiciCalls++; + throw connectionRefused(); + }; + const mockNative = async (): Promise => { + nativeCalls++; + return new Response("native-ok", { status: 200 }); + }; + + const res = await proxyFetch( + "https://api.example.com/v1/models", + { method: "POST" }, + { undiciFetch: mockUndici, nativeFetch: mockNative } + ); + + assert.equal(undiciCalls, 2); + assert.equal(nativeCalls, 1); + assert.equal(await res.text(), "native-ok"); + assert.equal( + getDefaultCachedDispatcher(), + cloudDispatcherBefore, + "the cloud-upstream dispatcher pool must survive its own PROXY_UNREACHABLE" + ); + assert.notEqual( + getLocalDefaultCachedDispatcher(), + undefined, + "a cloud-hostname failure must NOT tear down the unrelated local-egress pool" + ); + }); +});