Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
680001be20 fix(providers): surface real transport cause instead of bare "fetch failed" (#14309)
open-sse/utils/proxyFetch.ts already computes a detailed transport
diagnosis (DNS/socket error code, syscall, address) whenever a direct
fetch fails on both the pooled undici dispatcher and the native-fetch
fallback, attaching it to the thrown error as `.proxyFetchDetail`. But
src/lib/providers/validation/transport.ts::toValidationErrorResult()
only ever read `error.message` — always the generic "fetch failed"
string by undici/native-fetch design — and never `error.cause` (where
SafeOutboundFetchError's normalizeFetchFailure() stores the original
error carrying `.proxyFetchDetail`). The computed diagnosis was
silently discarded before it ever reached the dashboard.

toValidationErrorResult() now walks one level of `error.cause` when
the message is the generic "fetch failed" string and, if a
`.proxyFetchDetail` is found there, surfaces it (still routed through
sanitizeErrorMessage()) instead of the bare message.

Also extracted the direct-path detail-string builder (and the existing
redactProxyDetailsInMessage() redaction helper) out of proxyFetch.ts
into a new open-sse/utils/proxyFetchRedaction.ts module, applying the
same proxy-URL/credential redaction to the direct-path detail that the
proxy-path message already had — closing a latent redaction gap
between the two branches — without growing proxyFetch.ts past its
frozen file-size baseline.

This does not explain the reporter's own underlying transport failure
(likely local network/DNS/firewall config on their machine); it makes
that failure diagnosable instead of opaque.
2026-09-22 02:12:55 -03:00
5 changed files with 129 additions and 16 deletions

View File

@@ -0,0 +1 @@
- fix(providers): surface the real transport diagnosis (DNS/socket cause) instead of a bare "fetch failed" in provider validation errors (#14309)

View File

@@ -15,6 +15,7 @@ import {
} from "./proxyDispatcher.ts";
import tlsClient, { type TlsFetchOptions, guardTlsFirstByte } from "./tlsClient.ts";
import { withUpstreamStatusCapture } from "./upstreamStatusCapture.ts";
import { describeFallbackFailure, redactProxyDetailsInMessage } from "./proxyFetchRedaction.ts";
import { isProxyReachable } from "@/lib/proxyHealth";
import {
isControlPlaneProxyDirectFallbackEnabled,
@@ -340,20 +341,6 @@ function isWreqProxySupported(proxyUrl: string): boolean {
}
}
/**
* Redact proxy URLs (and any bare `user:pass@host` credential tokens) from an
* upstream transport-error message before it is surfaced. #10032 keeps the
* underlying failure reason in the propagated error for diagnosability, but
* the raw message can embed the full proxy URL — including userinfo
* credentials — which must never bubble into response bodies (#9837, Hard
* Rule #12).
*/
function redactProxyDetailsInMessage(message: string): string {
return message
.replace(/\b(?:https?|socks[45][ah]?|socks):\/\/\S+/gi, "[redacted-proxy]")
.replace(/\b[^\s:@/]+:[^\s@/]*@\S+/g, "[redacted-proxy]");
}
function sanitizeTransportError(
error: unknown,
message: string,
@@ -908,7 +895,10 @@ async function patchedFetchUnrecorded(
continue;
}
if (hasNonReplayableBody) {
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[skipped: non-replayable request body]`;
const detail = describeFallbackFailure(
describeFetchCause(dispatcherError),
"skipped: non-replayable request body"
);
console.warn(
`[ProxyFetch] skipping native fetch fallback for non-replayable body: ${detail}`
);
@@ -952,7 +942,10 @@ async function patchedFetchUnrecorded(
return await _nativeFallback(input, options);
} catch (nativeError) {
// Surface both dispatcher and native causes immediately.
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`;
const detail = describeFallbackFailure(
describeFetchCause(dispatcherError),
describeFetchCause(nativeError)
);
console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`);
if (nativeError instanceof Error) {
(nativeError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail;

View File

@@ -0,0 +1,27 @@
// Extracted from proxyFetch.ts (frozen file-size baseline — #14309) so the
// transport-error diagnostics built there can be redacted without growing
// the frozen file.
//
// #10032 keeps the underlying transport failure reason in the propagated
// error for diagnosability, but the raw message can embed a full proxy URL
// — including userinfo credentials — which must never bubble into response
// bodies (#9837, Hard Rule #12).
/**
* Redact proxy URLs (and any bare `user:pass@host` credential tokens) from an
* upstream transport-error message before it is surfaced.
*/
export function redactProxyDetailsInMessage(message: string): string {
return message
.replace(/\b(?:https?|socks[45][ah]?|socks):\/\/\S+/gi, "[redacted-proxy]")
.replace(/\b[^\s:@/]+:[^\s@/]*@\S+/g, "[redacted-proxy]");
}
/**
* Builds the `.proxyFetchDetail` diagnosis for proxyFetch.ts's direct-path
* (pooled undici dispatcher + native fetch fallback) branches, redacted the
* same way as the proxy-path message (see redactProxyDetailsInMessage above).
*/
export function describeFallbackFailure(dispatcherCause: string, nativeDetail: string): string {
return redactProxyDetailsInMessage(`dispatcher=[${dispatcherCause}] native=[${nativeDetail}]`);
}

View File

@@ -178,6 +178,26 @@ export function toWebCookieValidationErrorResult(provider: string, error: unknow
return toValidationErrorResult(error);
}
/**
* proxyFetch.ts computes a detailed transport diagnosis (DNS/socket error
* code, syscall, address) whenever a direct fetch fails on both the pooled
* undici dispatcher and the native-fetch fallback, and attaches it to the
* thrown error as `.proxyFetchDetail`. safeOutboundFetch's
* normalizeFetchFailure() then wraps that error in a SafeOutboundFetchError
* whose `.message` is copied from the generic "fetch failed" string and
* whose `.cause` is the original error carrying `.proxyFetchDetail`. Without
* this, the computed diagnosis never reaches the caller (#14309).
*/
function extractProxyFetchDetail(error: unknown): string | undefined {
if (!(error instanceof Error)) return undefined;
const cause = (error as Error & { cause?: unknown }).cause;
if (!(cause instanceof Error)) return undefined;
const detail = (cause as Error & { proxyFetchDetail?: unknown }).proxyFetchDetail;
return typeof detail === "string" && detail.length > 0 ? detail : undefined;
}
const GENERIC_TRANSPORT_FAILURE_PATTERN = /^fetch failed$/i;
export function toValidationErrorResult(error: unknown) {
let rawMessage: unknown = error || "Validation failed";
try {
@@ -185,6 +205,17 @@ export function toValidationErrorResult(error: unknown) {
} catch {
rawMessage = "Validation failed";
}
try {
if (
typeof rawMessage === "string" &&
GENERIC_TRANSPORT_FAILURE_PATTERN.test(rawMessage.trim())
) {
const detail = extractProxyFetchDetail(error);
if (detail) rawMessage = `Network error: ${detail}`;
}
} catch {
// Diagnostic enrichment is advisory; never let it break error reporting.
}
const message = sanitizeErrorMessage(rawMessage);
let statusCode: number | null = null;
let timeout = false;

View File

@@ -0,0 +1,61 @@
// Repro for #14309 — "all provider validation fails with 'fetch failed'".
//
// open-sse/utils/proxyFetch.ts already computes a rich diagnostic string
// (dispatcher cause + native-fallback cause, including the real DNS/socket
// error code) whenever BOTH the pooled undici dispatcher path AND the
// native-fetch fallback fail, and attaches it to the thrown error as
// `.proxyFetchDetail` (open-sse/utils/proxyFetch.ts:953-961; proven attached
// by the existing tests/unit/proxyfetch-undici-retry.test.ts).
//
// That thrown error then reaches safeOutboundFetch()'s catch block
// (src/shared/network/safeOutboundFetch.ts::normalizeFetchFailure), which
// wraps it into a `SafeOutboundFetchError` whose `.message` is copied from
// the ORIGINAL error's generic "fetch failed" message and whose `.cause` is
// the original error (carrying `.proxyFetchDetail`).
//
// `toValidationErrorResult()` in src/lib/providers/validation/transport.ts
// — the function that turns that thrown error into the JSON body
// `/api/providers/validate` sends to the dashboard — only ever reads
// `error.message`. It never looks at `error.cause`, so the diagnostic detail
// that was carefully computed two layers down is silently discarded before
// it ever reaches the user, and the dashboard always shows the bare,
// non-actionable "fetch failed" string regardless of the real underlying
// cause (DNS failure, connection refused, TLS error, etc.) — exactly what
// #14309 reports.
import { test } from "node:test";
import assert from "node:assert/strict";
import { toValidationErrorResult } from "../../src/lib/providers/validation/transport";
import { SafeOutboundFetchError } from "../../src/shared/network/safeOutboundFetch";
test("toValidationErrorResult should surface the computed proxyFetchDetail diagnosis (via error.cause) instead of the generic 'fetch failed' message (#14309)", () => {
// Mirrors exactly what proxyFetch.ts's native-fallback-also-failed branch
// attaches to the original error (open-sse/utils/proxyFetch.ts:955-958).
const nativeError = new Error("fetch failed") as Error & { proxyFetchDetail?: string };
nativeError.proxyFetchDetail =
"dispatcher=[fetch failed code=UND_ERR_SOCKET] native=[getaddrinfo ENOTFOUND api.mistral.ai code=ENOTFOUND syscall=getaddrinfo]";
// Mirrors exactly what safeOutboundFetch.ts's normalizeFetchFailure() produces
// for a generic (non-SafeOutboundFetchError, non-FetchTimeoutError) transport
// failure: message copied from the original error, cause = the original error.
const wrapped = new SafeOutboundFetchError(nativeError.message, {
code: "NETWORK_ERROR",
url: "https://api.mistral.ai/v1/models",
method: "GET",
attempts: 1,
isRetryable: true,
cause: nativeError,
});
const result = toValidationErrorResult(wrapped);
assert.notEqual(
result.error,
"fetch failed",
"expected behavior: a concrete transport diagnosis was computed two layers down (error.cause.proxyFetchDetail), so the response must not collapse to the bare, non-actionable 'fetch failed' string"
);
assert.match(
result.error || "",
/ENOTFOUND|UND_ERR_SOCKET/,
"expected behavior: the underlying DNS/socket error code should reach the dashboard so the operator can actually diagnose the failure"
);
});