Skip to content

Report DNS lookup failures from fetch() and Bun.connect as ENOTFOUND - #32990

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/9227c0dc/dns-error-not-econnrefused
Jun 28, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/9227c0dc/dns-error-not-econnrefused

Conversation

@robobun

@robobun robobun commented Jun 28, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes NXDOMAIN (and every other resolver failure) from fetch() and Bun.connect() being reported as a refused connection.

Repro

const host = "a".repeat(64) + ".com"; // >63-byte DNS label: getaddrinfo rejects it locally

await fetch(`http://${host}/`).catch(e => console.log(e.code, e.syscall, e.hostname));
// before:  ConnectionRefused  undefined  undefined   "Unable to connect. Is the computer able to access the url?"
// (a second fetch to the same host hits the DNS cache and gets a *different* wrong error:
//  FailedToOpenSocket  "Was there a typo in the url or port?")

await Bun.connect({ hostname: host, port: 80, socket: { open() {}, data() {} } })
  .catch(e => console.log(e.code, e.syscall, e.hostname));
// before:  ECONNREFUSED  connect  undefined   "Failed to connect"

// node, and Bun's own node:dns / node:net for the same name:
//          ENOTFOUND  getaddrinfo  <host>   "getaddrinfo ENOTFOUND <host>"

"name does not resolve" and "host refused the connection" drive opposite reactions (fix the hostname vs. retry and back off), and HTTP client libraries and retry wrappers branch on exactly this distinction, so under Bun they all misdiagnose NXDOMAIN.

Cause

The getaddrinfo(3) return code was discarded twice in uSockets:

  • us_internal_socket_after_resolve dropped result->error, so us_connecting_socket_close fabricated ECONNABORTED (c->error was still 0). The Rust handlers then mapped that to ConnectionRefused (fetch) / ECONNREFUSED (Bun.connect).
  • For a cached resolver failure, us_socket_group_connect stuffed the EAI code into errno and returned NULL, which surfaced as FailedToOpenSocket. So the same bad hostname produced two different wrong errors on consecutive lookups.

Fix

  • Store the raw getaddrinfo return code in us_connecting_socket_t.error and tag its namespace with a new 1-bit error_is_dns field in the existing bitfield (getaddrinfo return codes and errnos overlap numerically, so the tag is what disambiguates them). This also removes the fabricated ECONNABORTED that error carried on the resolver-failure path.
  • Remove the cached-failure short-circuit so a cached resolver failure takes the same connecting-socket path (and the same error callback) as an uncached one, and invalidate the DNS cache entry on a resolver failure so a negative result is never served from the cache.
  • HTTPContext / NewSocket read dns_error from the connecting socket and, when it is nonzero, build the error with the same init_eai + getaddrinfo <CODE> <hostname> machinery Bun's node:dns already uses (factored into system_error_with_syscall_and_hostname).
  • The reported hostname is captured as an owned copy on the HTTP thread at the failure, from connected_url.hostname: the exact name HTTPContext::connect was told to resolve (the proxy's when one is configured, the post-redirect target after a redirect). The JS side never reconstructs it from the client's borrowed URL buffers, which the HTTP thread frees after the result callback returns.

After:

fetch:        ENOTFOUND  getaddrinfo  <host>  "getaddrinfo ENOTFOUND <host>"
Bun.connect:  ENOTFOUND  getaddrinfo  <host>  "getaddrinfo ENOTFOUND <host>"

and the second fetch to the same host now reports the same error as the first.

On Windows, init_eai only matches UV_EAI_* constants in its #[cfg(windows)] arm (it already carries a TODO: revisit this), so the raw ws2_32::getaddrinfo code falls through to the ENOTFOUND catch-all. NXDOMAIN (WSAHOST_NOT_FOUND) lands on the right answer, and the syscall/hostname are correct, but a transient WSATRY_AGAIN is also reported as ENOTFOUND. The POSIX arm has the same class of imprecision (EAI_AGAIN falls through to ENOTIMP, which test/js/bun/dns/resolve-dns.test.ts already pins). Both halves of init_eai's non-NXDOMAIN mapping deserve one follow-up with platform test coverage rather than a partial, untested patch here; every case is still a strict improvement over the unconditional ECONNREFUSED it replaces.

Intentionally not covered here: the HTTP/3 (QUIC) connect path has its own DNS registration (Bun__addrinfo_registerQuic) and still maps every connect failure to ConnectionRefused.

Tests

  • test/js/bun/net/socket-dns-error.test.ts (new): Bun.connect reports ENOTFOUND / getaddrinfo / hostname through both connectError and the rejected promise, exactly once, and consistently across consecutive (cached) attempts. Uses a >63-byte DNS label (illegal per RFC 1035 section 2.3.4), which getaddrinfo rejects locally with no network, the same fixture as Node's own test-net-dns-error.js.
  • test/js/web/fetch/client-fetch.test.ts: the existing "invalid url" test asserted the buggy "Unable to connect. Is the computer able to access the url?" message from inside a catch block that never fires when an HTTP proxy is configured. Rewritten to a subprocess with the proxy env cleared, asserting the full error shape across five cases: three consecutive direct fetches (the uncached and the cached path), one through an explicit unresolvable proxy: option (must name the proxy, not the origin), and one to a local server that 302s to an unresolvable host (must name the redirect target, not the original origin).

Every other consumer of HTTPClientResult.fail (bun install, S3, the sync CLI requests) formats the error name generically, so the new DNSResolveFailed variant falls through to, for example, error: DNSResolveFailed downloading package manifest ... instead of ConnectionRefused.

Fixes #11345

This also addresses Case 1 (NXDOMAIN reported as ConnectionRefused instead of ENOTFOUND) of #20486. The other cases in that issue (wrapping the rejection in TypeError: fetch failed with a cause, ERR_INVALID_URL, and the invalid-protocol code) are about the overall fetch error shape and are not covered here.

fetch() to a hostname that does not resolve rejected with
ConnectionRefused ("Unable to connect. Is the computer able to access
the url?") and Bun.connect with ECONNREFUSED / syscall "connect", with
no hostname on either, so callers could not tell a DNS failure from a
refused connection. Bun's own node:dns already reports ENOTFOUND for
the same lookup.

The getaddrinfo return code was discarded in uSockets:
us_internal_socket_after_resolve dropped result->error, so
us_connecting_socket_close fabricated ECONNABORTED, and the
cached-failure short-circuit in us_socket_group_connect stuffed the EAI
code into errno and returned NULL. The same bad hostname fetched twice
produced two different wrong errors (ConnectionRefused, then
FailedToOpenSocket).

Preserve the code in a new dns_error field on us_connecting_socket_t
(kept separate from `error`: the EAI_* and errno constant sets
overlap), route the cached-failure case through the same
connecting-socket path as the uncached one, and have the connect-error
handlers report a nonzero dns_error as the same
"getaddrinfo <CODE> <hostname>" SystemError that node:dns produces,
via the existing init_eai mapping.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 36 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c0ec549a-6d75-4764-8100-98766ca54283

📥 Commits

Reviewing files that changed from the base of the PR and between 5034e26 and d7e7b51.

📒 Files selected for processing (16)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/socket.c
  • src/http/HTTPContext.rs
  • src/http/InternalState.rs
  • src/http/lib.rs
  • src/runtime/dns_jsc/cares_jsc.rs
  • src/runtime/socket/Listener.rs
  • src/runtime/socket/WindowsNamedPipeContext.rs
  • src/runtime/socket/socket_body.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • src/uws_sys/ConnectingSocket.rs
  • src/uws_sys/socket.rs
  • test/js/bun/net/socket-dns-error.test.ts
  • test/js/web/fetch/client-fetch.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:25 AM PT - Jun 28th, 2026

❌ @robobun, your commit d7e7b51 has 1 failures in Build #66235 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32990

That installs a local version of the PR into your bun-32990 executable, so you can run:

bun-32990 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Native fetch incompatibilities with NodeJS error format and codes #20486 - Case 1 explicitly shows fetch("http://non-existing-domain-ever.com") returning ConnectionRefused instead of Node's ENOTFOUND with syscall: "getaddrinfo" and hostname — exactly the bug this PR fixes
  2. different error codes between Node and Bun in axios #11345 - Shows DNS-unresolvable domains producing ConnectionRefused from Bun's fetch instead of ENOTFOUND as Node does — the same root cause

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #20486
Fixes #11345

🤖 Generated with Claude Code

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Verified both suggestions against the issues and added Fixes #11345 to the description: that issue is exactly this bug (fetch to unresolvable hosts reporting ConnectionRefused where Node reports ENOTFOUND).

I did not add Fixes #20486. That issue lists four separate fetch error-format incompatibilities, and this PR only resolves Case 1 (the ENOTFOUND / getaddrinfo / hostname shape for an unresolvable hostname). Cases 2 through 4 (wrapping the rejection in a TypeError: fetch failed with a cause, ERR_INVALID_URL, and the invalid-protocol code) are about the overall fetch error shape and are out of scope here, so auto-closing it would be wrong. It is referenced as a partial in the description instead.

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread test/js/web/fetch/client-fetch.test.ts Outdated
…resolve

With an HTTP proxy configured, the HTTP thread resolves the proxy
hostname, not the origin's, so the ENOTFOUND error must name the proxy.
Cover it in the fetch test with an explicit unresolvable proxy option,
and include stderr in the subprocess assertion so a child crash
surfaces the actual output instead of an opaque JSON parse error.
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/runtime/socket/socket_body.rs
…cting it

After a redirect, `AsyncHTTP.url` still holds the original request URL
(only `client.url` is updated), so the resolver error named a host that
resolved fine instead of the redirect target. Reading the post-redirect
`client.url.hostname` from the JS thread is a use-after-free under ASAN:
that URL slices into the buffer `handle_response_metadata` allocates,
which the HTTP thread frees right after the result callback returns.

Capture the hostname once, on the HTTP thread, at the failure:
`connected_url.hostname` is the exact name `HTTPContext::connect` was
told to resolve (the proxy's when one is configured, the post-redirect
target otherwise), so one owned copy on `HTTPClientResult` covers every
case and the JS side never dereferences the client's borrowed URL
buffers.

Cover it with a fifth case in the fetch test: a local 302 to an
unresolvable host must name the redirect target.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why both error and dns_error instead of a 1 bit marker to say the error is a dns error?

The getaddrinfo return code and the connect errno are mutually
exclusive, so store whichever one happened in the existing `error`
field and tag its namespace with a bit in the existing bitfield (the
two constant sets overlap numerically). This drops the second int and
the fabricated ECONNABORTED that `error` carried on the DNS path.
`us_connecting_socket_get_dns_error` derives its value from the pair,
so nothing past that accessor changes.
@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Done in d7e7b51. error_is_dns is now a bit in the existing bitfield, error carries the raw getaddrinfo return code on the resolver-failure path instead of the fabricated ECONNABORTED it used to get, and us_connecting_socket_get_dns_error derives its value from the pair, so nothing past that accessor changed.

The reason I originally used a second int: the getaddrinfo and errno constant sets overlap numerically (on macOS EAI_NONAME is 8, which is also ENOEXEC), so a caller reading us_connecting_socket_get_error() or the dispatched code without checking the tag would silently misinterpret a DNS code as a plausible errno, while the second field left error always errno-shaped. Auditing the consumers showed that is a non-issue: every reader of the connecting socket's error is either a nonzero check (is_closed_or_has_error) or ignores the code entirely, and the one that interprets it (NewSocket::handle_connect_error) already branches on the DNS tag first. The bit is strictly better: one fewer int and no lie in error.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my earlier feedback has been addressed and the current revision looks correct to me, but this threads error state through uSockets C internals, the cross-thread HTTP client result path, and per-platform errno mapping (with an acknowledged Windows follow-up), so it warrants a human pass before merge.

Extended reasoning...

Overview

This PR plumbs the raw getaddrinfo(3) return code from uSockets up through Bun.connect and fetch() so DNS resolution failures surface as ENOTFOUND/getaddrinfo/hostname instead of being collapsed into ECONNREFUSED. It touches 16 files across three layers: the C uSockets library (context.c, socket.c, internal.h, libusockets.h — adds an error_is_dns tag bit, removes the cached-failure short-circuit, changes DNS-cache invalidation), the Rust FFI/HTTP/socket layer (uws_sys, HTTPContext, InternalState, lib.rs, socket_body.rs, FetchTasklet, cares_jsc), and tests. New owned dns_error/dns_hostname fields flow through HTTPClientResult from the HTTP thread to the JS thread.

Security risks

None identified. This is error-reporting plumbing on the failure path; no new parsing of untrusted input, no auth/crypto changes. The hostname copied into the error is already user-/server-supplied and was previously displayed in other error messages.

Level of scrutiny

High. Every fetch() and Bun.connect() traverses these code paths. The change spans a C struct bitfield layout, a behavioral change to DNS-cache invalidation in uSockets, cross-thread ownership of a hostname buffer (the author found and fixed a use-after-free under ASan during review when trying my first suggested fix), and per-platform errno-namespace handling. Two correctness bugs (proxy hostname, redirect hostname) were found and fixed during review, and a third (Windows WSA→UV_EAI namespace mismatch in init_eai) is deliberately deferred to a follow-up with the rationale documented in the PR description.

Other factors

All four of my prior inline comments are resolved and the fixes look right — in particular the final approach (capturing connected_url.hostname as an owned Box<[u8]> on the HTTP thread) is cleaner and safer than the borrowed-slice alternatives. Test coverage is good (uncached/cached, proxy, redirect, both connectError and promise-rejection paths). The bug-hunting pass on the latest revision found nothing. That said, the combination of C-side control-flow changes, the deferred Windows mapping gap, and the number of HTTPClientResult plumbing sites makes this worth a maintainer's eyes rather than a bot approval.

@Jarred-Sumner
Jarred-Sumner merged commit a1c39de into main Jun 28, 2026
71 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/9227c0dc/dns-error-not-econnrefused branch June 28, 2026 06:58
Jarred-Sumner pushed a commit that referenced this pull request Jun 28, 2026
## Problem

`docs/runtime/networking/dns.mdx` says:

> If any connections to a host fail, we remove the entry from the cache.

This has not worked since #13249. The eviction in
`us_connecting_socket_close` is gated on `c->error == ECONNREFUSED`, but
nothing assigns the connect outcome to `c->error` anymore. The field
only ever receives the `ECONNABORTED` default (meaning "caller
aborted"), so the gate is always false and a dead host keeps serving
cache hits for its full TTL (30s by default).

```js
const stat = () => { const s = Bun.dns.getCacheStats(); return `hits=${s.cacheHitsCompleted} size=${s.size} errors=${s.errors}`; };
await Bun.connect({ hostname: "localhost", port: 1, socket: { data() {}, open() {} } }).catch(() => {});
console.log(stat()); // hits=0 size=1 errors=0
await Bun.connect({ hostname: "localhost", port: 1, socket: { data() {}, open() {} } }).catch(() => {});
console.log(stat()); // hits=1 size=1 errors=0   <- dead host served from the cache, never evicted
```

## Cause

Before #13249, `us_internal_socket_after_open` and
`us_internal_socket_after_resolve` set `c->error = ECONNREFUSED`
themselves once every resolved address had been tried and failed, then
evicted the entry. That PR consolidated the teardown into
`us_connecting_socket_close` but dropped those assignments, leaving the
consolidated eviction check unreachable.

## Fix

Restore the two assignments. `us_connecting_socket_close` already
distinguishes a real connect failure (`ECONNREFUSED`, evict) from a
caller abort (the `ECONNABORTED` default, do not evict); the two paths
that mean "every address failed" just need to say so again.

No user-visible error code changes: the socket layer normalizes
unrecognized connect errnos (including the previous `ECONNABORTED`) to
`ECONNREFUSED` before it reaches JS, and the HTTP client ignores the
code entirely.

## Intentionally not changed

- A completed cache hit that resolves to a single address takes a
shortcut in `us_socket_group_connect` (`entries->info.ai_next == NULL`)
that returns a plain `us_socket_t` and releases the DNS request before
the connect outcome is known, so a failure on that path still cannot
evict. Wiring eviction into it means giving raw connect sockets a
connecting-state backpointer and reconciling two different close and
dispatch contracts, which is a larger change than this fix should carry.
- Failed resolutions are still cached. The pre-#13249 code deliberately
did not evict those, and the next synchronous lookup that consumes a
cached failure already evicts it (`us_socket_group_connect`). #32990
handles that side (stop caching resolver failures and report them as
`ENOTFOUND`); it does not overlap with this change.

## Verification

`test/js/bun/dns/dns-prefetch.test.ts`: a subprocess fails three
connects to a localhost port with no listener and asserts, after each
one, that the entry was evicted (`size` back to 0, `errors` incremented)
and that `cacheHitsCompleted` stays 0. Without the fix it fails with
`size: 1, errors: 0, cacheHitsCompleted: 2`.

The existing `dns.prefetch > should prefetch` test in that file resolved
and fetched `example.com`, so it could not pass in an environment
without internet access. It now runs against a local `Bun.serve` on
`localhost` with the same assertions; `localhost` is still a real DNS
lookup, so the prefetch and the fetch still share one cache entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

different error codes between Node and Bun in axios

2 participants