Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/runtime/networking/fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions src/jsc/SystemError.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ impl From<bun_sys::SystemError> 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,
Expand All @@ -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: <self as Error> })`, 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
Expand Down
27 changes: 22 additions & 5 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);

Expand Down Expand Up @@ -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<unsigned>(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)
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/headers.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 12 additions & 6 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -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),
Expand All @@ -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) => {
Expand Down
25 changes: 18 additions & 7 deletions src/runtime/webcore/fetch/FetchTasklet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_(
Expand Down Expand Up @@ -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_(
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions test/bake/fixtures/deinitialization/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
robobun marked this conversation as resolved.
const response = await fetch(server.url.origin, { keepalive: false });
expect(response.status).toBe(200);
Expand All @@ -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 {
Expand Down
10 changes: 6 additions & 4 deletions test/js/bun/http/bun-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}

{
Expand Down
6 changes: 3 additions & 3 deletions test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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)]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
7 changes: 4 additions & 3 deletions test/js/bun/util/error-name-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
});
});
});
6 changes: 3 additions & 3 deletions test/js/first_party/undici/undici.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions test/js/web/fetch/client-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)} + "/")));
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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" });
Comment thread
robobun marked this conversation as resolved.
});

test("Receiving non-Latin1 headers", async () => {
Expand Down
6 changes: 5 additions & 1 deletion test/js/web/fetch/fetch-gzip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading