Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
fbcce8b2de fix: match Node.js fetch error format with TypeError and .cause (#20486)
Fetch errors now match Node.js behavior:
- Network errors throw TypeError with message "fetch failed" and a .cause
  containing the detailed error (with code, syscall, hostname properties)
- DNS failures use ENOTFOUND code with getaddrinfo syscall
- Connection refused uses ECONNREFUSED code with connect syscall
- Invalid URL wraps cause with ERR_INVALID_URL code and input property
- Invalid protocol wraps cause with "unknown scheme" message
- Backward-compatible .code property preserved on outer error

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:25:19 +00:00
8 changed files with 203 additions and 12 deletions

View File

@@ -638,6 +638,8 @@ void us_internal_socket_after_resolve(struct us_connecting_socket_t *c) {
}
struct addrinfo_result *result = Bun__addrinfo_getRequestResult(c->addrinfo_req);
if (result->error) {
/* Propagate the DNS error code so it can be distinguished from connection errors */
c->error = result->error;
us_connecting_socket_close(c->ssl, c);
return;
}
@@ -681,6 +683,8 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) {
#endif
/* It is perfectly possible to come here with an error */
if (error) {
/* Retrieve the actual socket error code (e.g., ECONNREFUSED) */
int sock_error = us_socket_get_error(0, s);
/* Emit error, close without emitting on_close */
@@ -691,6 +695,13 @@ void us_internal_socket_after_open(struct us_socket_t *s, int error) {
We differentiate between these two cases by checking if the connect_state is null.
*/
if (c) {
/* Store the socket error in the connecting socket so it can be
* propagated to the on_connect_error callback. This allows us to
* distinguish ECONNREFUSED from other connection errors. */
if (sock_error && !c->error) {
c->error = sock_error;
}
// remove this connecting socket from the list of connecting sockets
// if it was the last one, signal the error to the user
for (struct us_socket_t **next = &c->connecting_head; *next; next = &(*next)->connect_next) {

View File

@@ -358,11 +358,19 @@ fn fetchImpl(
}
url = ZigURL.fromString(allocator, url_str) catch {
const err = ctx.toTypeError(.INVALID_URL, "fetch() URL is invalid", .{});
// Create cause TypeError with ERR_INVALID_URL code and input property (Node.js compat)
const cause = ctx.toTypeError(.INVALID_URL, "Invalid URL", .{});
var input_slice = url_str.toUTF8WithoutRef(allocator);
defer input_slice.deinit();
var input_zstr = jsc.ZigString.init(input_slice.slice());
cause.put(globalThis, "input", input_zstr.toJS(globalThis));
// Create outer TypeError with descriptive message
const outer_err = globalThis.createTypeErrorInstance("Failed to parse URL from {s}", .{input_slice.slice()});
outer_err.put(globalThis, "cause", cause);
is_error = true;
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(
globalThis,
err,
outer_err,
);
};
if (url.isFile()) {
@@ -1071,9 +1079,13 @@ fn fetchImpl(
if (url.protocol.len > 0) {
if (!(url.isHTTP() or url.isHTTPS() or url.isS3())) {
const err = globalThis.toTypeError(.INVALID_ARG_VALUE, "protocol must be http:, https: or s3:", .{});
// Create cause Error with "unknown scheme" message (Node.js compat)
const cause = bun.String.static("unknown scheme").toErrorInstance(globalThis);
// Create outer TypeError with "fetch failed" message
const outer = globalThis.createTypeErrorInstance("fetch failed", .{});
outer.put(globalThis, "cause", cause);
is_error = true;
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err);
return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, outer);
}
}

View File

@@ -708,6 +708,8 @@ pub const FetchTasklet = struct {
return .{ .AbortReason = reason };
}
const globalThis = this.global_this;
// some times we don't have metadata so we also check http.url
const path = if (this.metadata) |metadata|
bun.String.cloneUTF8(metadata.url)
@@ -716,16 +718,28 @@ pub const FetchTasklet = struct {
else
bun.String.empty;
const hostname_str = if (this.http) |http_|
bun.String.cloneUTF8(http_.url.hostname)
else
bun.String.empty;
const fetch_error = jsc.SystemError{
.code = bun.String.static(switch (this.result.fail.?) {
error.ConnectionClosed => "ECONNRESET",
error.ConnectionRefused => "ECONNREFUSED",
error.DNSLookupFailed => "ENOTFOUND",
else => |e| @errorName(e),
}),
.message = switch (this.result.fail.?) {
error.ConnectionClosed => bun.String.static("The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()"),
error.DNSLookupFailed => bun.String.createFormat("getaddrinfo ENOTFOUND {f}", .{
hostname_str,
}) catch |err| bun.handleOom(err),
error.FailedToOpenSocket => bun.String.static("Was there a typo in the url or port?"),
error.TooManyRedirects => bun.String.static("The response redirected too many times. For more information, pass `verbose: true` in the second argument to fetch()"),
error.ConnectionRefused => bun.String.static("Unable to connect. Is the computer able to access the url?"),
error.ConnectionRefused => bun.String.createFormat("connect ECONNREFUSED {f}", .{
path,
}) catch |err| bun.handleOom(err),
error.RedirectURLInvalid => bun.String.static("Redirect URL in Location header is invalid."),
error.UNABLE_TO_GET_ISSUER_CERT => bun.String.static("unable to get issuer certificate"),
@@ -800,10 +814,32 @@ pub const FetchTasklet = struct {
path,
}) catch |err| bun.handleOom(err),
},
.syscall = switch (this.result.fail.?) {
error.DNSLookupFailed => bun.String.static("getaddrinfo"),
error.ConnectionRefused => bun.String.static("connect"),
else => bun.String.empty,
},
.hostname = hostname_str,
.path = path,
};
return .{ .SystemError = fetch_error };
// Create the inner cause error from SystemError
const cause_js = fetch_error.toErrorInstance(globalThis);
// Wrap in a TypeError with message "fetch failed" to match Node.js behavior
const outer_error = globalThis.createTypeErrorInstance("fetch failed", .{});
outer_error.put(globalThis, "cause", cause_js);
// Also set .code on the outer error for backward compatibility with existing Bun code
const code_str = switch (this.result.fail.?) {
error.ConnectionClosed => bun.String.static("ECONNRESET"),
error.ConnectionRefused => bun.String.static("ECONNREFUSED"),
error.DNSLookupFailed => bun.String.static("ENOTFOUND"),
else => |e| bun.String.static(@errorName(e)),
};
outer_error.put(globalThis, "code", code_str.toJS(globalThis) catch .js_undefined);
return .{ .JSValue = .create(outer_error, globalThis) };
}
pub fn onReadableStreamAvailable(ctx: *anyopaque, globalThis: *jsc.JSGlobalObject, readable: jsc.WebCore.ReadableStream) void {

View File

@@ -648,7 +648,16 @@ pub fn NewSocketHandler(comptime is_ssl: bool) type {
if (allowHalfOpen) uws.LIBUS_SOCKET_ALLOW_HALF_OPEN else 0,
@sizeOf(*anyopaque),
&did_dns_resolve,
) orelse return error.FailedToOpenSocket;
) orelse {
// When connect() returns null for a cached DNS failure,
// the C code sets errno to the EAI error code (negative value).
// Use this to distinguish DNS lookup failures from other socket errors.
const c_errno = std.c._errno().*;
if (c_errno < 0) {
return error.DNSLookupFailed;
}
return error.FailedToOpenSocket;
};
const socket = if (did_dns_resolve == 1)
ThisSocket{
.socket = .{ .connected = @ptrCast(socket_ptr) },

View File

@@ -265,9 +265,16 @@ pub fn onTimeout(
}
pub fn onConnectError(
client: *HTTPClient,
errno: c_int,
) void {
log("onConnectError {s}\n", .{client.url.href});
client.fail(error.ConnectionRefused);
log("onConnectError {s} errno={d}\n", .{ client.url.href, errno });
// Negative errno values indicate EAI (DNS) errors from getaddrinfo.
// Positive values are system errno codes (e.g. ECONNREFUSED).
if (errno < 0) {
client.fail(error.DNSLookupFailed);
} else {
client.fail(error.ConnectionRefused);
}
}
pub inline fn getAllocator() std.mem.Allocator {

View File

@@ -361,12 +361,12 @@ pub fn NewHTTPContext(comptime ssl: bool) type {
pub fn onConnectError(
ptr: *anyopaque,
socket: HTTPSocket,
_: c_int,
code: c_int,
) void {
const tagged = getTagged(ptr);
markTaggedSocketAsDead(socket, tagged);
if (tagged.get(HTTPClient)) |client| {
client.onConnectError();
client.onConnectError(code);
}
// us_connecting_socket_close is always called internally by uSockets
}

View File

@@ -29,6 +29,11 @@ try {
await res.text();
expect(true).toBe("unreacheable");
} catch (err) {
// fetch errors are now TypeError with "fetch failed" message and .cause (Node.js compat)
expect(err).toBeInstanceOf(TypeError);
expect(err.message).toBe("fetch failed");
expect(err.code).toBe("FailedToOpenSocket");
expect(err.message).toBe("Was there a typo in the url or port?");
expect(err.cause).toBeDefined();
expect(err.cause.code).toBe("FailedToOpenSocket");
expect(err.cause.message).toBe("Was there a typo in the url or port?");
}

View File

@@ -0,0 +1,111 @@
import { expect, test } from "bun:test";
// GitHub Issue #20486: Native fetch incompatibilities with NodeJS error format and codes
// Fetch errors should be TypeError with "fetch failed" message and a .cause property
// containing the detailed error information, matching Node.js behavior.
test("fetch DNS failure returns TypeError with ENOTFOUND cause", async () => {
try {
await fetch("http://non-existing-domain-ever.com/");
expect.unreachable();
} catch (e: any) {
// Outer error should be a TypeError with message "fetch failed"
expect(e).toBeInstanceOf(TypeError);
expect(e.message).toBe("fetch failed");
// Should have a .cause property
expect(e.cause).toBeDefined();
expect(e.cause).toBeInstanceOf(Error);
// Cause should have ENOTFOUND code and getaddrinfo syscall
expect(e.cause.code).toBe("ENOTFOUND");
expect(e.cause.syscall).toBe("getaddrinfo");
expect(e.cause.hostname).toBe("non-existing-domain-ever.com");
}
}, 30_000);
test("fetch connection refused returns TypeError with ECONNREFUSED cause", async () => {
try {
await fetch("http://localhost:19999/");
expect.unreachable();
} catch (e: any) {
// Outer error should be a TypeError with message "fetch failed"
expect(e).toBeInstanceOf(TypeError);
expect(e.message).toBe("fetch failed");
// Should have a .cause property
expect(e.cause).toBeDefined();
expect(e.cause).toBeInstanceOf(Error);
// Cause should have ECONNREFUSED code and connect syscall
expect(e.cause.code).toBe("ECONNREFUSED");
expect(e.cause.syscall).toBe("connect");
}
});
test("fetch invalid URL returns TypeError with ERR_INVALID_URL cause", async () => {
try {
await fetch("invalid-url");
expect.unreachable();
} catch (e: any) {
// Outer error should be a TypeError
expect(e).toBeInstanceOf(TypeError);
expect(e.message).toBe("Failed to parse URL from invalid-url");
// Should have a .cause property that is also a TypeError
expect(e.cause).toBeDefined();
expect(e.cause).toBeInstanceOf(TypeError);
// Cause should have ERR_INVALID_URL code and input property
expect(e.cause.code).toBe("ERR_INVALID_URL");
expect(e.cause.input).toBe("invalid-url");
}
});
test("fetch invalid protocol returns TypeError with cause", async () => {
try {
await fetch("ftp://example.com");
expect.unreachable();
} catch (e: any) {
// Outer error should be a TypeError with "fetch failed" message
expect(e).toBeInstanceOf(TypeError);
expect(e.message).toBe("fetch failed");
// Should have a .cause property
expect(e.cause).toBeDefined();
expect(e.cause).toBeInstanceOf(Error);
expect(e.cause.message).toBe("unknown scheme");
}
});
test("fetch DNS failure is distinguishable from connection refused", async () => {
// DNS failure
let dnsError: any;
try {
await fetch("http://non-existing-domain-ever.com/");
} catch (e: any) {
dnsError = e;
}
// Connection refused
let connError: any;
try {
await fetch("http://localhost:19999/");
} catch (e: any) {
connError = e;
}
// Both should be TypeErrors with "fetch failed" message
expect(dnsError).toBeInstanceOf(TypeError);
expect(connError).toBeInstanceOf(TypeError);
expect(dnsError.message).toBe("fetch failed");
expect(connError.message).toBe("fetch failed");
// But their causes should have different error codes
expect(dnsError.cause.code).toBe("ENOTFOUND");
expect(connError.cause.code).toBe("ECONNREFUSED");
// And different syscalls
expect(dnsError.cause.syscall).toBe("getaddrinfo");
expect(connError.cause.syscall).toBe("connect");
}, 30_000);