From 2aecba8bef24b7ef224d4c8e578e85a6d249529e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:09:43 +0000 Subject: [PATCH] fetch: reject network errors as TypeError('fetch failed') with a cause fetch() network failures (connection refused, DNS failure, TLS errors, socket closed before the response, mid-body truncation, malformed responses, too many redirects, ...) rejected with a TypeError (since #35855; a bare Error before that) carrying Bun's own description and code at the top level and no .cause. Every fetch-error classifier in the ecosystem keys on the undici shape instead: - is-network-error (the predicate under p-retry, ky and most hand-rolled retry loops) requires name === "TypeError" and message === "fetch failed" / "terminated", so it returned false for every Bun failure and retry gates never retried. - The Node idiom err.cause?.code === "ECONNREFUSED" read undefined. Network errors now reject as TypeError("fetch failed", { cause }), or TypeError("terminated", { cause }) once the response headers had been delivered and the body failed, matching undici. The cause is the same system error Bun used to surface directly (code, path, syscall, errno, and the descriptive message), so nothing diagnostic is lost, and .code is mirrored onto the TypeError so existing Bun code reading err.code keeps working. ConnectionRefused is reported as ECONNREFUSED (syscall "connect") and ConnectionClosed as ECONNRESET, both with libuv-style negative errno values; everything else keeps its http::Error name as the code. Replaces the SystemTypeError variant from #35855. Fixes #34397 Addresses cases 1 and 2 of #20486 --- docs/runtime/networking/fetch.mdx | 13 + src/jsc/SystemError.rs | 9 +- src/jsc/bindings/bindings.cpp | 27 +- src/jsc/bindings/headers.h | 2 +- src/runtime/webcore/Body.rs | 18 +- src/runtime/webcore/fetch/FetchTasklet.rs | 25 +- test/bake/fixtures/deinitialization/test.ts | 4 +- test/js/bun/http/bun-server.test.ts | 10 +- test/js/bun/http/serve.test.ts | 6 +- ...test-http-should-error-with-faulty-args.ts | 3 +- ...hould-not-accept-untrusted-certificates.ts | 3 +- .../bun/util/error-name-preservation.test.ts | 7 +- test/js/first_party/undici/undici.test.ts | 6 +- test/js/web/fetch/client-fetch.test.ts | 15 +- test/js/web/fetch/fetch-gzip.test.ts | 6 +- test/js/web/fetch/fetch-http2-client.test.ts | 12 +- test/js/web/fetch/fetch-network-error.test.ts | 242 ++++++++++++++++++ test/js/web/fetch/fetch.test.ts | 6 +- test/js/workerd/html-rewriter.test.js | 9 +- test/vendor.json | 2 +- 20 files changed, 366 insertions(+), 59 deletions(-) create mode 100644 test/js/web/fetch/fetch-network-error.test.ts 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." }