diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index afe904b75e09..6af9bdf40e7c 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -336,6 +336,19 @@ Bun's fetch implementation includes several specific error cases: - TLS certificate validation failures when `rejectUnauthorized` is true (or undefined) - S3 operations may throw specific errors related to authentication or permissions +Network failures (connection refused, DNS lookup failures, TLS errors, a connection dropped before or during the response, malformed responses, too many redirects) reject with the same shape Node.js uses: a `TypeError` whose message is `"fetch failed"`, or `"terminated"` when the response headers had already arrived and reading the body failed. The underlying error is the `cause`, and carries an error code (`ECONNREFUSED`, `ECONNRESET`, `ENOTFOUND`, a TLS code such as `DEPTH_ZERO_SELF_SIGNED_CERT`, or one of Bun's own names such as `TooManyRedirects`), a descriptive message, and the URL as `path`. The code is also available as `code` on the `TypeError` itself. + +```ts +try { + await fetch("http://127.0.0.1:1/"); +} catch (error) { + error.message; // "fetch failed" + error.cause.code; // "ECONNREFUSED" + error.cause.message; // "Unable to connect. Is the computer able to access the url?" + error.code; // "ECONNREFUSED" (same as error.cause.code) +} +``` + ### Content-Type handling Bun automatically sets the `Content-Type` header for request bodies when not explicitly provided: diff --git a/src/jsc/SystemError.rs b/src/jsc/SystemError.rs index 52a48ffca872..3f773746c9d2 100644 --- a/src/jsc/SystemError.rs +++ b/src/jsc/SystemError.rs @@ -69,9 +69,10 @@ impl From for SystemError { // is ABI-identical to a non-null `JSGlobalObject*` with write provenance. unsafe extern "C" { safe fn SystemError__toErrorInstance(this: &SystemError, global: &JSGlobalObject) -> JSValue; - safe fn SystemError__toTypeErrorInstance( + safe fn SystemError__toFetchFailedInstance( this: &SystemError, global: &JSGlobalObject, + terminated: bool, ) -> JSValue; safe fn SystemError__toErrorInstanceWithInfoObject( this: &SystemError, @@ -87,9 +88,9 @@ impl SystemError { SystemError__toErrorInstance(&self, global) } - /// `to_error_instance` but as a JS `TypeError` (keeps `.code`/`.path`/...). - pub fn to_type_error_instance(self, global: &JSGlobalObject) -> JSValue { - SystemError__toTypeErrorInstance(&self, global) + /// undici's `TypeError("fetch failed" | "terminated", { cause: })`, with `.code` mirrored onto the `TypeError`. + pub fn to_fetch_failed_instance(self, global: &JSGlobalObject, terminated: bool) -> JSValue { + SystemError__toFetchFailedInstance(&self, global, terminated) } /// Like `to_error_instance` but populates the error's stack trace with async diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 40e19b3e571e..8bf4eafd7c4c 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2568,7 +2568,7 @@ JSC::EncodedJSValue JSGlobalObject__createOutOfMemoryError(JSC::JSGlobalObject* return JSValue::encode(exception); } -static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject, JSC::ErrorType errorType) +static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject) { SystemError err = *arg0; @@ -2582,7 +2582,7 @@ static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, J auto& names = WebCore::builtinNames(vm); - JSC::JSObject* result = createError(globalObject, errorType, message); + JSC::JSObject* result = createError(globalObject, ErrorType::Error, message); auto clientData = WebCore::clientData(vm); @@ -2643,12 +2643,29 @@ static JSC::EncodedJSValue systemErrorToErrorInstance(const SystemError* arg0, J JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject) { - return systemErrorToErrorInstance(arg0, globalObject, ErrorType::Error); + return systemErrorToErrorInstance(arg0, globalObject); } -JSC::EncodedJSValue SystemError__toTypeErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject) +// undici's rejection shape: TypeError("fetch failed" | "terminated") with the system error as a non-enumerable `cause` and `.code` mirrored. +JSC::EncodedJSValue SystemError__toFetchFailedInstance(const SystemError* arg0, JSC::JSGlobalObject* globalObject, bool terminated) { - return systemErrorToErrorInstance(arg0, globalObject, ErrorType::TypeError); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + + JSC::JSValue cause = JSC::JSValue::decode(systemErrorToErrorInstance(arg0, globalObject)); + JSC::JSObject* result = createError(globalObject, ErrorType::TypeError, terminated ? "terminated"_s : "fetch failed"_s); + result->putDirect(vm, vm.propertyNames->cause, cause, static_cast(JSC::PropertyAttribute::DontEnum)); + + if (arg0->code.tag != BunStringTag::Empty) { + JSC::JSValue code = Bun::toJS(globalObject, arg0->code); + if (scope.exception()) { + scope.clearException(); + } else { + result->putDirect(vm, WebCore::clientData(vm)->builtinNames().codePublicName(), code, JSC::PropertyAttribute::DontDelete | 0); + } + } + + return JSC::JSValue::encode(result); } JSC::EncodedJSValue SystemError__toErrorInstanceWithInfoObject(const SystemError* arg0, JSC::JSGlobalObject* globalObject) diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 208be793da65..deb766a0e4fc 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -95,7 +95,7 @@ CPP_DECL bool WebCore__FetchHeaders__isEmpty(WebCore::FetchHeaders* arg0); CPP_DECL JSC::EncodedJSValue WebCore__FetchHeaders__toJS(WebCore::FetchHeaders* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void WebCore__FetchHeaders__toUWSResponse(WebCore::FetchHeaders* arg0, UWSResponseKind kind, void* arg2); CPP_DECL JSC::EncodedJSValue SystemError__toErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* arg1); -CPP_DECL JSC::EncodedJSValue SystemError__toTypeErrorInstance(const SystemError* arg0, JSC::JSGlobalObject* arg1); +CPP_DECL JSC::EncodedJSValue SystemError__toFetchFailedInstance(const SystemError* arg0, JSC::JSGlobalObject* arg1, bool terminated); #pragma mark - JSC::JSCell diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 93041722ba08..c60df1eaaa51 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -566,8 +566,11 @@ pub enum Tag { pub enum ValueError { AbortReason(CommonAbortReason), SystemError(SystemError), - /// `SystemError` surfaced as a JS `TypeError` (fetch network errors). - SystemTypeError(SystemError), + /// `TypeError("fetch failed" | "terminated", { cause })`; see `SystemError::to_fetch_failed_instance`. + FetchFailed { + cause: SystemError, + terminated: bool, + }, Message(BunString), /// Surfaces as a JS `TypeError`. The fetch spec maps every "network /// error" to TypeError, so use this for fetch-layer rejections that @@ -582,7 +585,7 @@ impl ValueError { pub fn reset(&mut self) { match self { // The bun.String fields are dropped by the assignment below. - ValueError::SystemError(_) | ValueError::SystemTypeError(_) => {} + ValueError::SystemError(_) | ValueError::FetchFailed { .. } => {} ValueError::Message(message) => message.deref(), ValueError::TypeError(message) => message.deref(), ValueError::JSValue(v) => v.deinit(), @@ -616,8 +619,8 @@ impl ValueError { ValueError::SystemError(system_error) => { core::mem::take(system_error).to_error_instance(global_object) } - ValueError::SystemTypeError(system_error) => { - core::mem::take(system_error).to_type_error_instance(global_object) + ValueError::FetchFailed { cause, terminated } => { + core::mem::take(cause).to_fetch_failed_instance(global_object, *terminated) } ValueError::Message(message) => message.to_error_instance(global_object), ValueError::TypeError(message) => message.to_type_error_instance(global_object), @@ -635,7 +638,10 @@ impl ValueError { // `.clone()` on BunString/SystemError already bumps the refcount (paired // with their Drop deref); an extra `.ref_()` here would leak +1 per dupe. ValueError::SystemError(e) => ValueError::SystemError(e.clone()), - ValueError::SystemTypeError(e) => ValueError::SystemTypeError(e.clone()), + ValueError::FetchFailed { cause, terminated } => ValueError::FetchFailed { + cause: cause.clone(), + terminated: *terminated, + }, ValueError::Message(m) => ValueError::Message(m.clone()), ValueError::TypeError(m) => ValueError::TypeError(m.clone()), ValueError::JSValue(js_ref) => { diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 1e8ea8278f0c..230cc86ed6da 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -1326,6 +1326,8 @@ impl FetchTasklet { } let fail = self.result.fail.unwrap(); + // Headers already delivered: a body failure, which undici reports as "terminated". + let terminated = self.metadata.is_some(); if fail == http::Error::RequestBodyNotReusable { return BodyValueError::TypeError(BunString::static_( @@ -1360,15 +1362,22 @@ impl FetchTasklet { hostname, ); err.path = path.into(); - return BodyValueError::SystemTypeError(err); + return BodyValueError::FetchFailed { + cause: err, + terminated, + }; } } - let code = if fail == http::Error::ConnectionClosed { - BunString::static_("ECONNRESET") - } else { - BunString::static_(fail.name()) + // Failures with a libuv equivalent use node's code/errno/syscall; the rest keep their `http::Error` name. + let (code, errno, syscall) = match fail { + http::Error::ConnectionRefused => { + ("ECONNREFUSED", -bun_sys::UV_E::CONNREFUSED, Some("connect")) + } + http::Error::ConnectionClosed => ("ECONNRESET", -bun_sys::UV_E::CONNRESET, None), + _ => (fail.name(), 0, None), }; + let code = BunString::static_(code); let message = match fail { http::Error::ConnectionClosed => BunString::static_( @@ -1593,14 +1602,16 @@ impl FetchTasklet { )), }; - let fetch_error = jsc::SystemError { + let cause = jsc::SystemError { + errno, code: code.into(), message: message.into(), path: path.into(), + syscall: syscall.map_or(BunString::EMPTY, BunString::static_).into(), ..Default::default() }; - BodyValueError::SystemTypeError(fetch_error) + BodyValueError::FetchFailed { cause, terminated } } fn on_readable_stream_available( diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index 5addb57ce4c4..f72a50903318 100644 --- a/test/bake/fixtures/deinitialization/test.ts +++ b/test/bake/fixtures/deinitialization/test.ts @@ -58,7 +58,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web if (sendAnyRequests) { if (closeActiveConnections) { - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("closed unexpectedly"); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNRESET" }); } else { const response = await fetch(server.url.origin, { keepalive: false }); expect(response.status).toBe(200); @@ -68,7 +68,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web } // Server is closed - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect"); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toMatchObject({ code: "ECONNREFUSED" }); } try { diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 68b4af62e10f..5150ab5c2493 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -460,14 +460,16 @@ describe.concurrent("Server", () => { // Test that HTTPS keep-alive doesn't cause it to re-use the connection on // the next attempt, when the next attempt has reject unauthorized enabled { - expect( - async () => await fetch(server.url, { tls: { rejectUnauthorized: true } }).then(res => res.text()), - ).toThrow("self signed certificate"); + await expect( + fetch(server.url, { tls: { rejectUnauthorized: true } }).then(res => res.text()), + ).rejects.toMatchObject({ code: "DEPTH_ZERO_SELF_SIGNED_CERT" }); } { using _ = rejectUnauthorizedScope(true); - expect(async () => await fetch(server.url).then(res => res.text())).toThrow("self signed certificate"); + await expect(fetch(server.url).then(res => res.text())).rejects.toMatchObject({ + code: "DEPTH_ZERO_SELF_SIGNED_CERT", + }); } { diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 342f62110092..11d03a31a14b 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -95,7 +95,7 @@ it("should be able to abruptly stop the server many times", async () => { await fetch(url, { keepalive: true }).then(res => res.text()); expect.unreachable(); } catch (e) { - expect(["ECONNRESET", "ConnectionRefused"]).toContain(e.code); + expect(["ECONNRESET", "ECONNREFUSED"]).toContain(e.code); } } @@ -2846,9 +2846,9 @@ it.concurrent( const res = await fetch(new URL(pathname, server.url.origin)); expect(res.status).toBe(200); if (success) { - expect(res.text()).resolves.toBe("Hello, World!"); + await expect(res.text()).resolves.toBe("Hello, World!"); } else { - expect(res.text()).rejects.toThrow(/The socket connection was closed unexpectedly./); + await expect(res.text()).rejects.toMatchObject({ code: "ECONNRESET" }); } } await Promise.all([testTimeout("/ok", true), testTimeout("/timeout", false)]); diff --git a/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts b/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts index ae347c443b4b..1074ecc9956d 100644 --- a/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts +++ b/test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts @@ -30,5 +30,6 @@ try { expect(true).toBe("unreacheable"); } catch (err) { expect(err.code).toBe("FailedToOpenSocket"); - expect(err.message).toBe("Was there a typo in the url or port?"); + expect(err.message).toBe("fetch failed"); + expect(err.cause.message).toBe("Was there a typo in the url or port?"); } diff --git a/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts b/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts index ad4425d1fd95..14ca10a7d7bf 100644 --- a/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts +++ b/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts @@ -32,5 +32,6 @@ try { expect.unreachable(); } catch (err) { expect(err.code).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - expect(err.message).toBe("unable to verify the first certificate"); + expect(err.message).toBe("fetch failed"); + expect(err.cause.message).toBe("unable to verify the first certificate"); } diff --git a/test/js/bun/util/error-name-preservation.test.ts b/test/js/bun/util/error-name-preservation.test.ts index bf42635db393..bbb39a1cd52a 100644 --- a/test/js/bun/util/error-name-preservation.test.ts +++ b/test/js/bun/util/error-name-preservation.test.ts @@ -47,9 +47,10 @@ describe("native error name/code preservation", () => { expect(err).toBeDefined(); // The exact message string from the fetch error match arm (was a // ~40-arm `e if e == err!(X)` guard chain, now a real match). - expect({ code: err.code, message: String(err.message) }).toEqual({ - code: "ConnectionRefused", - message: "Unable to connect. Is the computer able to access the url?", + expect({ code: err.code, message: String(err.message), cause: String(err.cause?.message) }).toEqual({ + code: "ECONNREFUSED", + message: "fetch failed", + cause: "Unable to connect. Is the computer able to access the url?", }); }); }); diff --git a/test/js/first_party/undici/undici.test.ts b/test/js/first_party/undici/undici.test.ts index 374ac06a7429..60b93ace0dec 100644 --- a/test/js/first_party/undici/undici.test.ts +++ b/test/js/first_party/undici/undici.test.ts @@ -186,9 +186,9 @@ describe("undici.request maxRedirections", () => { // redirect may be followed, so the client stops at /redirect/1 instead // of chasing the chain to the end. hits.length = 0; - await expect(request(`${origin}/redirect/0`, { maxRedirections: 1 })).rejects.toThrow( - "redirected too many times", - ); + await expect(request(`${origin}/redirect/0`, { maxRedirections: 1 })).rejects.toMatchObject({ + code: "TooManyRedirects", + }); expect(hits).toEqual(["/redirect/0", "/redirect/1"]); // A cap large enough for the whole chain still reaches the final response. diff --git a/test/js/web/fetch/client-fetch.test.ts b/test/js/web/fetch/client-fetch.test.ts index 26443a1d7598..a6a978a458e0 100644 --- a/test/js/web/fetch/client-fetch.test.ts +++ b/test/js/web/fetch/client-fetch.test.ts @@ -422,7 +422,7 @@ test("unresolvable hostname rejects with the resolver error", async () => { `const out = []; const report = p => p.then( () => "resolved", - ({ name, code, syscall, hostname, message }) => ({ name, code, syscall, hostname, message }), + e => ({ name: e.name, code: e.code, cause: e.cause && { code: e.cause.code, syscall: e.cause.syscall, hostname: e.cause.hostname, message: e.cause.message } }), ); for (let i = 0; i < 3; i++) { out.push(await report(fetch("http://" + ${JSON.stringify(host)} + "/"))); @@ -450,9 +450,12 @@ test("unresolvable hostname rejects with the resolver error", async () => { const notFound = (hostname: string) => ({ name: "TypeError", code: "ENOTFOUND", - syscall: "getaddrinfo", - hostname, - message: `getaddrinfo ENOTFOUND ${hostname}`, + cause: { + code: "ENOTFOUND", + syscall: "getaddrinfo", + hostname, + message: `getaddrinfo ENOTFOUND ${hostname}`, + }, }); // `out` is the raw stdout if it is not JSON (the subprocess crashed), so the // failure diff shows what the child actually printed alongside stderr. @@ -508,11 +511,11 @@ test("error on redirect", async () => { }).listen(0); await once(server, "listening"); - expect( + await expect( fetch(`http://localhost:${server.address().port}`, { redirect: "error", }), - ).rejects.toThrow(/UnexpectedRedirect/); + ).rejects.toMatchObject({ name: "TypeError", code: "UnexpectedRedirect" }); }); test("Receiving non-Latin1 headers", async () => { diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 22e6dacecf2d..3f8fd98d21d0 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -306,7 +306,11 @@ describe("fetch() decodes Content-Encoding case-insensitively", () => { await once(server.listen(0, "127.0.0.1"), "listening"); try { const { port } = server.address() as import("node:net").AddressInfo; - expect(async () => await fetch(`http://127.0.0.1:${port}/`)).toThrow("UnsupportedTransferEncoding"); + await expect(fetch(`http://127.0.0.1:${port}/`)).rejects.toMatchObject({ + name: "TypeError", + code: "UnsupportedTransferEncoding", + cause: expect.objectContaining({ code: "UnsupportedTransferEncoding" }), + }); } finally { server.close(); } diff --git a/test/js/web/fetch/fetch-http2-client.test.ts b/test/js/web/fetch/fetch-http2-client.test.ts index 96a5f9de2641..4145b882d12e 100644 --- a/test/js/web/fetch/fetch-http2-client.test.ts +++ b/test/js/web/fetch/fetch-http2-client.test.ts @@ -852,10 +852,10 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL async url => { await using proc = await spawnFetch(` try { await fetch("${url}", { tls: { rejectUnauthorized: false } }); console.log("ok"); } - catch (e) { console.log("rejected", String(e).includes("Refused")); } + catch (e) { console.log("rejected", e?.code); } `); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("rejected true"); + expect(stdout.trim()).toBe("rejected HTTP2RefusedStream"); expect(exitCode).toBe(0); // initial + 5 retries expect(attempts).toBe(6); @@ -1154,10 +1154,10 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL const r = await fetch("${url}", { tls: { rejectUnauthorized: false } }); await r.text(); console.log("ok"); - } catch (e) { console.log("rejected", String(e).includes("ContentLength")); } + } catch (e) { console.log("rejected", e?.code); } `); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("rejected true"); + expect(stdout.trim()).toBe("rejected HTTP2ContentLengthMismatch"); expect(exitCode).toBe(0); }, ); @@ -1176,10 +1176,10 @@ describe.concurrent("fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CL try { const r = await fetch("${url}", { tls: { rejectUnauthorized: false } }); console.log("ok", r.status, (await r.text()).length); - } catch (e) { console.log("rejected", String(e).includes("ContentLength")); } + } catch (e) { console.log("rejected", e?.code); } `); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("rejected true"); + expect(stdout.trim()).toBe("rejected HTTP2ContentLengthMismatch"); expect(exitCode).toBe(0); }, ); diff --git a/test/js/web/fetch/fetch-network-error.test.ts b/test/js/web/fetch/fetch-network-error.test.ts new file mode 100644 index 000000000000..729e487af47b --- /dev/null +++ b/test/js/web/fetch/fetch-network-error.test.ts @@ -0,0 +1,242 @@ +// Fetch spec: a network error rejects fetch() with a TypeError. Node (undici) +// rejects with TypeError("fetch failed") whose non-enumerable `cause` is the +// underlying error (an errno-style `code`, `syscall`, ...), and a body that +// fails after the headers arrived rejects with TypeError("terminated"). The +// ecosystem keys off that shape: is-network-error (under p-retry and ky) checks +// `name === "TypeError"` plus the message, and portable code reads +// `err.cause?.code`. Bun additionally mirrors `code` onto the TypeError so +// existing `err.code` checks keep working. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { once } from "node:events"; +import net from "node:net"; + +function shape(e: any) { + return { + name: e?.name, + isTypeError: e instanceof TypeError, + message: e?.message, + code: e?.code, + causeIsEnumerable: e == null ? undefined : Object.getOwnPropertyDescriptor(e, "cause")?.enumerable, + cause: + e?.cause == null + ? e?.cause + : { + name: e.cause.name, + isError: e.cause instanceof Error, + message: e.cause.message, + code: e.cause.code, + errno: e.cause.errno, + syscall: e.cause.syscall, + path: e.cause.path, + hostname: e.cause.hostname, + }, + }; +} + +async function rejectionOf(promise: Promise) { + try { + await promise; + } catch (e) { + return e; + } + throw new Error("expected the promise to reject"); +} + +async function refusedPort() { + const server = net.createServer(); + await new Promise(r => server.listen(0, "127.0.0.1", r)); + const { port } = server.address() as net.AddressInfo; + await new Promise(r => server.close(() => r())); + return port; +} + +// A raw TCP server; open sockets are destroyed on dispose so a failing +// assertion reports as such instead of hanging in server.close(). +async function tcpServer(onConnection: (socket: net.Socket) => void) { + const sockets = new Set(); + const server = net.createServer(socket => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + onConnection(socket); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as net.AddressInfo; + return { + url: `http://127.0.0.1:${port}/`, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise(r => server.close(() => r())); + }, + }; +} + +// Inlined from sindresorhus/is-network-error v1.1.0 (MIT). +const networkErrorMessages = new Set([ + "network error", + "Failed to fetch", + "NetworkError when attempting to fetch resource.", + "The Internet connection appears to be offline.", + "Load failed", + "Network request failed", + "fetch failed", + "terminated", +]); +const isNetworkError = (error: any) => + error instanceof Error && error.name === "TypeError" && networkErrorMessages.has(error.message); + +describe("fetch network errors reject as TypeError('fetch failed') with a cause", () => { + test("connection refused", async () => { + const url = `http://127.0.0.1:${await refusedPort()}/`; + const err = await rejectionOf(fetch(url)); + expect(shape(err)).toEqual({ + name: "TypeError", + isTypeError: true, + message: "fetch failed", + code: "ECONNREFUSED", + causeIsEnumerable: false, + cause: { + name: "Error", + isError: true, + message: expect.any(String), + code: "ECONNREFUSED", + errno: expect.any(Number), + syscall: "connect", + path: url, + hostname: undefined, + }, + }); + // libuv convention, as on node's own ECONNREFUSED errors. + expect((err as any).cause.errno).toBeLessThan(0); + expect(isNetworkError(err)).toBe(true); + }); + + test("socket closed before the response headers", async () => { + await using server = await tcpServer(socket => socket.destroy()); + const err = await rejectionOf(fetch(server.url)); + expect(shape(err)).toEqual({ + name: "TypeError", + isTypeError: true, + message: "fetch failed", + code: "ECONNRESET", + causeIsEnumerable: false, + cause: { + name: "Error", + isError: true, + message: expect.any(String), + code: "ECONNRESET", + errno: expect.any(Number), + syscall: undefined, + path: server.url, + hostname: undefined, + }, + }); + expect((err as any).cause.errno).toBeLessThan(0); + }); + + test("malformed HTTP response keeps Bun's own code on both levels", async () => { + await using server = await tcpServer(socket => socket.end("not http at all\r\n\r\n")); + const err: any = await rejectionOf(fetch(server.url)); + expect(err).toBeInstanceOf(TypeError); + expect(err.message).toBe("fetch failed"); + expect(err.cause).toBeInstanceOf(Error); + expect(err.cause.code).toEqual(expect.any(String)); + expect(err.code).toBe(err.cause.code); + }); + + test("body failing after the headers arrived rejects with 'terminated'", async () => { + await using server = await tcpServer(socket => { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n" + Buffer.alloc(1000, "x").toString()); + }); + const res = await fetch(server.url); + expect(res.status).toBe(200); + const err = await rejectionOf(res.text()); + expect(shape(err)).toEqual({ + name: "TypeError", + isTypeError: true, + message: "terminated", + code: "ECONNRESET", + causeIsEnumerable: false, + cause: { + name: "Error", + isError: true, + message: expect.any(String), + code: "ECONNRESET", + errno: expect.any(Number), + syscall: undefined, + path: server.url, + hostname: undefined, + }, + }); + expect(isNetworkError(err)).toBe(true); + }); + + test("the same error reaches a reader of the body stream", async () => { + await using server = await tcpServer(socket => { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n" + Buffer.alloc(1000, "x").toString()); + }); + const res = await fetch(server.url); + const reader = res.body!.getReader(); + let err: any; + try { + while (!(await reader.read()).done) {} + } catch (e) { + err = e; + } + expect({ name: err?.name, message: err?.message, causeCode: err?.cause?.code }).toEqual({ + name: "TypeError", + message: "terminated", + causeCode: "ECONNRESET", + }); + }); + + test("DNS failure puts the resolver error on the cause", async () => { + // A 64-character label violates RFC 1035, so the resolver rejects the name + // locally on every platform without touching the network. Run in a child + // with the proxy variables cleared so a configured proxy cannot take over + // the lookup. + const host = Buffer.alloc(64, "a").toString() + ".invalid"; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `fetch("http://" + process.env.BAD_HOST + "/").then( + () => process.stdout.write("resolved"), + e => process.stdout.write(JSON.stringify({ + name: e.name, + message: e.message, + code: e.code, + cause: { code: e.cause?.code, syscall: e.cause?.syscall, hostname: e.cause?.hostname }, + })), + );`, + ], + env: { + ...bunEnv, + BAD_HOST: host, + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + ALL_PROXY: "", + all_proxy: "", + NO_PROXY: "", + no_proxy: "", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // The code depends on the resolver (ENOTFOUND, EAI_NONAME, ...); the + // outer and inner codes must agree whatever it is. + const out = JSON.parse(stdout); + expect(out).toEqual({ + name: "TypeError", + message: "fetch failed", + code: out.cause.code, + cause: { code: expect.any(String), syscall: "getaddrinfo", hostname: host }, + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 20a0e898e0d8..b7514d1906a3 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -2084,7 +2084,11 @@ describe("maxRedirects", () => { }); it("rejects once the chain exceeds maxRedirects", async () => { - expect(fetch(`${server.url}hop/0`, { maxRedirects: 2 })).rejects.toThrow("redirected too many times"); + await expect(fetch(`${server.url}hop/0`, { maxRedirects: 2 })).rejects.toMatchObject({ + name: "TypeError", + code: "TooManyRedirects", + cause: expect.objectContaining({ message: expect.stringContaining("redirected too many times") }), + }); }); it("follows the chain when maxRedirects is large enough", async () => { diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 51425aeb8152..38abb78b331f 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -863,8 +863,7 @@ describe("HTMLRewriter", () => { const fullBody = "

hello world

"; // Ties the rejection to the connection failure so an unrelated rejection // ("Body already used", an internal rewriter error) can't keep this green. - // The exact RST message varies by platform, so match loosely. - const connectionError = /socket|connection|ECONNRESET/i; + const connectionError = expect.objectContaining({ code: "ECONNRESET" }); async function withPartialBodyServer(fn) { let release; @@ -903,17 +902,19 @@ describe("HTMLRewriter", () => { function settle(promise) { return promise.then( value => ({ rejected: false, value }), - error => ({ rejected: true, name: error?.name, message: String(error?.message) }), + error => ({ rejected: true, name: error?.name, code: error?.code, message: String(error?.message) }), ); } const rejectedWithConnectionError = { rejected: true, name: "TypeError", - message: expect.stringMatching(connectionError), + code: "ECONNRESET", + message: expect.any(String), }; const rejectedWithBodyAlreadyUsed = { rejected: true, name: "TypeError", + code: "ERR_BODY_ALREADY_USED", message: "Body already used", }; diff --git a/test/vendor.json b/test/vendor.json index 31edf3111e88..4abf1b33e6e7 100644 --- a/test/vendor.json +++ b/test/vendor.json @@ -5,7 +5,7 @@ "tag": "1.4.28", "skipTests": { "ws*connection.test.ts": "TEMPORARY: elysia 1.4.28 asserts wasClean=false for a server-initiated ws.close(), but that's a clean Close handshake — Bun now reports wasClean=true to match Node/WHATWG (oven-sh/bun#31518). Fixed upstream in elysiajs/elysia#1908; remove this skip on the next elysia bump. (glob * = path separator, so it also matches Windows backslash paths)", - "core*stop.test.ts": "TEMPORARY: elysia 1.4.28's 'does not shut down the server when stop(false) is called' awaits server.stop(false) and then expects a second fetch on the same keep-alive connection to still be served. As of oven-sh/bun#35130 a graceful stop(false) resolves only once every open connection has closed, so the await blocks until the pooled keep-alive times out and the follow-up fetch then hits a closed listener. Remove this skip once the upstream test is updated (the companion 'stop(true)' assertion in the same file is unaffected).", + "core*stop.test.ts": "TEMPORARY: elysia 1.4.28's 'does not shut down the server when stop(false) is called' awaits server.stop(false) and then expects a second fetch on the same keep-alive connection to still be served. As of oven-sh/bun#35130 a graceful stop(false) resolves only once every open connection has closed, so the await blocks until the pooled keep-alive times out and the follow-up fetch then hits a closed listener. Remove this skip once the upstream test is updated (the companion 'stop(true)' assertion in the same file is unaffected). The same file also asserts error.message === 'Unable to connect' for a refused fetch(); fetch() network errors now reject as TypeError('fetch failed') with the description on error.cause.message and error.code === 'ECONNREFUSED', so that assertion needs updating upstream too before this skip can go.", "adapter*web-standard*map-response.test.ts": "TEMPORARY: Bun's Response.redirect now puts the WHATWG serialization of the url into Location (oven-sh/bun#33126), matching Node and https://fetch.spec.whatwg.org/#dom-response-redirect, so Response.redirect('https://cunny.school') yields 'https://cunny.school/'. elysia 1.4.28's 'map redirect' test asserts the unserialized string; remove this skip once the upstream assertion expects the trailing slash.", "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value." }