Skip to content

fetch: reject network errors as TypeError('fetch failed' / 'terminated') with the system error as cause - #35988

Open
robobun wants to merge 1 commit into
mainfrom
farm/e66a66f1/fetch-typeerror-cause
Open

fetch: reject network errors as TypeError('fetch failed' / 'terminated') with the system error as cause#35988
robobun wants to merge 1 commit into
mainfrom
farm/e66a66f1/fetch-typeerror-cause

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A fetch() that fails on the network rejects with a TypeError (since fetch: mark body disturbed when a reader fails; reject network errors as TypeError #35855) that carries Bun's own description and code at the top level and has no cause:

    try { await fetch(`http://127.0.0.1:${closedPort}/`) } catch (e) {
      // Bun:  TypeError: Unable to connect. Is the computer able to access the url?   code: "ConnectionRefused"   cause: undefined
      // node: TypeError: fetch failed                                                   cause: Error { code: "ECONNREFUSED", errno: -111, syscall: "connect" }
    }
  • Node code that follows the documented shape (err.cause?.code === "ECONNREFUSED") never matches on Bun (fetch() connection-refused error shape differs from Node: no e.cause.code === 'ECONNREFUSED' #34397), and is-network-error (the classifier under p-retry, ky and most retry loops), which checks name === "TypeError" plus a fixed set of messages including "fetch failed" and "terminated", returns false for every Bun failure, so those retry loops never retry on Bun (Native fetch incompatibilities with NodeJS error format and codes #20486).

  • Cause: FetchTasklet::on_reject (src/runtime/webcore/fetch/FetchTasklet.rs) builds one SystemError and returns it as ValueError::SystemTypeError, which SystemError__toTypeErrorInstance materialises as a single TypeError with the description as its message.

Fix

  • ValueError::SystemTypeError becomes ValueError::FetchFailed { cause, terminated } (src/runtime/webcore/Body.rs); both on_reject return sites (the DNS path and the general path) produce it, so every network failure gets the same shape. terminated is self.metadata.is_some(): the response headers had already been handed out, so the failure is one of the body, which undici reports as TypeError("terminated") instead of "fetch failed".
  • SystemError__toFetchFailedInstance (src/jsc/bindings/bindings.cpp) builds the cause with the existing Error constructor (so code, message, path, syscall, hostname, errno are exactly what Bun surfaced at the top level before, and Bun's error printer shows both levels), creates TypeError("fetch failed" | "terminated"), attaches the cause as a non-enumerable property the way new TypeError(msg, { cause }) does, and mirrors code onto the TypeError, so existing Bun code reading err.code keeps working and err.cause?.code ?? err.code is portable. SystemError__toTypeErrorInstance and the errorType parameter fetch: mark body disturbed when a reader fails; reject network errors as TypeError #35855 added for it are removed again; nothing else used them.
  • The cause uses node's vocabulary where a libuv equivalent exists: a refused connection is code: "ECONNREFUSED", syscall: "connect", errno: -ECONNREFUSED; a dropped connection stays ECONNRESET (already the case on main) and gains the matching negative errno, with no syscall since the failing call is not known there. Every other failure keeps its http::Error name as the code (DEPTH_ZERO_SELF_SIGNED_CERT, TooManyRedirects, Malformed_HTTP_Response, ...); DNS failures keep the getaddrinfo error Report DNS lookup failures from fetch() and Bun.connect as ENOTFOUND #32990 introduced, now on the cause.
  • Why this is correct: the Fetch spec rejects a network error with a TypeError, and node's shape for that TypeError (message, non-enumerable cause, cause.code) is the one portable code is written against; keeping Bun's description and codes on the cause, plus the code mirror, means no information that was available before is lost, only moved.
  • Verified with test/js/web/fetch/fetch-network-error.test.ts (6 cases, all failing on the released binary): the full shape, including errno < 0 and the non-enumerable cause, for a refused connection and for a connection dropped before the headers; a malformed response keeping Bun's code on both levels; "terminated" for a body cut short, both through text() and through a body-stream reader; and a hermetic DNS failure (a 64-character label, which the resolver rejects locally) in a child process with the proxy variables cleared. The existing tests that asserted the previous message or ConnectionRefused were updated to assert code / cause (13 files; a few of them were not awaiting the assertion they made). body-mixin-errors, client-fetch, fetch-redirect, fetch-gzip, fetch.tls, fetch.tls.wildcard, fetch-tls-cert, 18413-*, error-name-preservation, undici, html-rewriter and the two test/js/bun/test/parallel/test-http-* scripts were run locally.
  • Docs: a paragraph under "Error handling" in docs/runtime/networking/fetch.mdx describes the shape.

Consolidation

#34402 and #35998 were alternative fixes for the same issues. Folded in from #34402: syscall/negative errno on the cause and the check that cause is non-enumerable. Folded in from #35998: the "terminated" message for body-stage failures and the hermetic DNS test. Not adopted from #35998: capturing an Error at every fetch() call to give the rejection a caller stack (node does not do this either, its fetch failed stack has no caller frame unless an await chain supplies one, which Bun's existing async-stack attachment also provides; the capture costs an allocation per successful fetch() and had to weaken the serve.test.ts guard against per-request Error allocations), and its renaming of Bun's remaining codes to undici/http-parser names (Timeout -> ETIMEDOUT is unreachable because timeouts become TimeoutError first, InvalidContentLength -> UND_ERR_RES_CONTENT_LENGTH_MISMATCH describes a different failure, and the rest are approximations that would break the existing code checks for no portability gain). The stack being absent on errors created with no JS frames affects all natively created errors, not only fetch(), and is tracked separately.

Not covered here, as in the other two PRs: cases 3 and 4 of #20486 (an invalid URL getting a cause with ERR_INVALID_URL, an unsupported scheme) still reject as they did before, so that issue is left open; cases 1 and 2 are what this PR addresses.

Background

  • ValueError (Body.rs): the lazily materialised rejection reason stored on a body; it is turned into a JS value (to_js) only when something reads the body or the fetch promise is rejected, and can be duplicated when a Response is cloned, which is why it is a Rust enum rather than a JS value.
  • SystemError (src/jsc/SystemError.rs): the #[repr(C)] struct (code, message, path, syscall, hostname, errno, ...) that C++ turns into a JS error object; Rust keeps ownership of the strings, C++ only reads them.
  • metadata on the tasklet is set once the HTTP client has delivered the final response's status and headers; redirects followed internally never set it.

Fixes #34397
Addresses cases 1 and 2 of #20486


no test proof · iteration 7 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-server.test.ts test/js/bun/http/serve.test.ts test/js/web/fetch/fetch.test.ts

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Fetch network failures now use TypeError("fetch failed") with underlying errors exposed through cause and native codes. Fetch propagation, error-code mapping, and tests were updated across DNS, connection, redirect, stream, TLS, HTTP/2, and related scenarios.

Changes

Fetch network error shape alignment

Layer / File(s) Summary
SystemError fetch conversion
src/jsc/SystemError.rs, src/jsc/bindings/headers.h, src/jsc/bindings/bindings.cpp
Adds conversion support for TypeError("fetch failed") with the original error as cause and its native code.
Fetch error propagation
src/runtime/webcore/Body.rs, src/runtime/webcore/fetch/FetchTasklet.rs
Adds FetchError, preserves its lifecycle, maps network failures to it, and records code and syscall.
Network error shape tests
test/js/web/fetch/*, test/js/bun/*, test/bake/fixtures/deinitialization/test.ts, test/js/workerd/html-rewriter.test.js, test/vendor.json
Validates structured fetch errors, nested causes, network codes, redirect failures, stream failures, TLS failures, HTTP/2 failures, connection failures, and an updated temporary test skip.

Possibly related PRs

  • oven-sh/bun#35998: Changes fetch network error construction to expose TypeError("fetch failed") with the original error as .cause.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the network-error TypeError/cause shape and errno mapping required by #20486 and #34397.
Out of Scope Changes check ✅ Passed All code and test edits support the fetch error-shape change; no unrelated changes stand out.
Title check ✅ Passed The title clearly summarizes the main change: network fetch failures now reject as TypeError values with a cause.
Description check ✅ Passed The description explains the problem, implementation, rationale, verification, tests, documentation, and scope covered by the change.

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Aug 13th, 2026

@robobun, your commit 2aecba8 has some failures in Build #94444 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 35988

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

bun-35988 --bun

Comment thread src/jsc/SystemError.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch: mark body disturbed when a reader fails; reject network errors as TypeError #35855 - Also rejects fetch network errors as TypeError and modifies the same code paths (FetchTasklet::on_reject, SystemError bindings); references the same issues (Native fetch incompatibilities with NodeJS error format and codes #20486, fetch() connection-refused error shape differs from Node: no e.cause.code === 'ECONNREFUSED' #34397)
  2. fetch: attach a node-style cause to connection-failure errors #34402 - Fixes the same issue (fetch() connection-refused error shape differs from Node: no e.cause.code === 'ECONNREFUSED' #34397) by attaching a .cause to fetch connection-failure errors via the same FetchTasklet and bindings.cpp code paths

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Regarding the duplicate-PR flags:

Happy to close #34402 in favour of this once reviewed.

Comment thread test/bake/fixtures/deinitialization/test.ts
Comment thread test/js/web/fetch/fetch-network-error.test.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/fetch/fetch.test.ts`:
- Around line 2082-2086: Await each asynchronous rejection assertion so test
failures are propagated: update the fetch redirect matcher in
test/js/web/fetch/fetch.test.ts:2082-2086, the ECONNRESET matcher in
test/bake/fixtures/deinitialization/test.ts:61, the ECONNREFUSED matcher in
test/bake/fixtures/deinitialization/test.ts:71, and the res.text() rejection
matcher in test/js/bun/http/serve.test.ts:2679. Return or await each matcher
without changing its expectations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4b280b01-629f-47aa-9d6c-aa9f51258b3e

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 646e165.

📒 Files selected for processing (14)
  • src/jsc/SystemError.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers.h
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/bake/fixtures/deinitialization/test.ts
  • test/js/bun/http/serve.test.ts
  • test/js/bun/test/parallel/test-http-should-error-with-faulty-args.ts
  • test/js/bun/util/error-name-preservation.test.ts
  • test/js/web/fetch/client-fetch.test.ts
  • test/js/web/fetch/fetch-network-error.test.ts
  • test/js/web/fetch/fetch.stream.test.ts
  • test/js/web/fetch/fetch.test.ts
  • test/js/workerd/html-rewriter.test.js

Comment thread test/js/web/fetch/fetch.test.ts Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated
Comment thread test/js/web/fetch/client-fetch.test.ts
Comment thread test/js/workerd/html-rewriter.test.js Outdated

@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 prior feedback addressed — the five follow-up commits (646e165 through d9d67e9) cover every inline comment I left. No new issues found this pass.

Deferring to a human for the API-shape decision: this changes the user-visible rejection contract of fetch() (Error→TypeError, descriptive message→"fetch failed", ConnectionRefusedECONNREFUSED) and overlaps with #35855 / #34402.

What was reviewed

  • New SystemError__toFetchTypeErrorInstance C++ binding — the clearException() pattern matches the sibling SystemError__toErrorInstance immediately above it.
  • ValueError::FetchError variant threaded through reset/to_js/dupe — all three match arms updated.
  • Grepped remaining ConnectionRefused references in test/ — the survivors are either comments, non-fetch (installer), or already include ECONNREFUSED as an alternative in their regex.
Extended reasoning...

Overview

This PR reshapes every fetch() network-error rejection from a bare Error with a descriptive message into TypeError('fetch failed', {cause: <original Error>}), matching the Fetch spec (§4.1 step 12.3) and Node/undici. It adds a new ValueError::FetchError(SystemError) variant in Body.rs, a new C++ binding SystemError__toFetchTypeErrorInstance in bindings.cpp, routes FetchTasklet::on_reject through it, remaps ConnectionRefusedECONNREFUSED (with syscall: 'connect'), and updates 12 test files plus adds a dedicated fetch-network-error.test.ts.

Security risks

None identified. This is error-object construction on the rejection path; no new input parsing, no auth/TLS logic changes. The .code mirroring onto the outer TypeError uses putDirect on a freshly-created error object, not user-controlled data.

Level of scrutiny

High — this is a user-visible behavioral change to a core Web API. Code that currently checks err.code === 'ConnectionRefused', err.constructor === Error, or matches on err.message will observe different values. The PR mitigates this by mirroring .code onto the outer error and preserving the descriptive message on .cause.message, but it is still a break for anyone reading the outer .message or the old ConnectionRefused code string. That trade-off (spec/Node compat + is-network-error classifier support vs. Bun-specific back-compat) is a maintainer call.

Other factors

  • Prior review rounds: I left five rounds of inline findings (test-weakening, missed test updates, un-awaited .rejects, harness conventions), all now addressed by commits 646e165, ca44353, d190934, 3bb2573, d9d67e9. All threads are resolved.
  • Overlapping PRs: #35855 and #34402 touch the same code paths; the author has explained the relationship in the thread, but which approach lands is a human decision.
  • Native code: The new C++ function's DECLARE_TOP_EXCEPTION_SCOPE + clearException() on Bun::toJS failures exactly mirrors the existing SystemError__toErrorInstance in the same file (lines ~2375–2418), so it follows local convention rather than introducing a new pattern.
  • Test coverage: The new fetch-network-error.test.ts covers connection-refused, socket-closed-pre-headers, malformed-response, and body-truncated shapes, plus runs the inlined is-network-error predicate against a real rejection. I re-grepped test/ for remaining ConnectionRefused string matches — none should break (all either non-fetch, comments, or already regex-alternate on ECONNREFUSED).
  • CI: Build #82742 for the latest commit was still running at review time.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Opened #35998 which covers this reshape plus caller-stack capture (so .stack points at the fetch() call site for .catch() consumers too), the 'terminated' body-stage message, and a broader errno-code map (ETIMEDOUT, HPE_INVALID_CONSTANT, HPE_INVALID_CHUNK_SIZE, UND_ERR_HEADERS_OVERFLOW, UND_ERR_RES_CONTENT_LENGTH_MISMATCH, Z_DATA_ERROR). Happy to fold anything from here that #35998 is missing.

@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.

The prior review feedback has all been addressed and I didn't find new issues, but this is a user-facing behavioral change to fetch()'s rejection shape (message/name/instanceof/code all change, a vendor test is skipped, and it overlaps with #35855/#34402), so it needs a maintainer to sign off on the API decisions rather than a bot approval.

What was reviewed:

  • SystemError__toFetchTypeErrorInstance follows the same DECLARE_TOP_EXCEPTION_SCOPE + clearException pattern as the adjacent SystemError__toErrorInstance.
  • ValueError::FetchError is wired into reset/to_js/dupe alongside SystemError, so no lifecycle path drops it.
  • All previously-flagged un-awaited .rejects matchers and stale outer-message assertions (bun-server, undici, untrusted-cert, html-rewriter, client-fetch) are fixed in the current diff.
Extended reasoning...

Overview

This PR changes every fetch() network-error rejection from a plain Error with a descriptive message to TypeError('fetch failed', {cause: <original Error>}), matching the Fetch spec §4.1.12.3 and Node/undici. It touches FetchTasklet::on_reject (Rust), adds a ValueError::FetchError variant to Body.rs, adds a new C++ binding SystemError__toFetchTypeErrorInstance, remaps ConnectionRefusedECONNREFUSED, and updates ~12 test files plus adds a new dedicated shape test and a vendor-test skip.

Security risks

None identified. The change reshapes error objects on the rejection path; it doesn't touch validation, TLS decisions, or any allow/deny gate. The new C++ reads only from the Rust-owned SystemError struct.

Level of scrutiny

High — this is a user-visible breaking change to the most-used Web API. Every existing user check like err.message.includes('Unable to connect'), err.code === 'ConnectionRefused', or err.name === 'Error' changes behavior. The PR itself had to update a dozen tests and skip an elysia vendor test, which is a direct signal of ecosystem impact. REVIEW.md's API-design section calls for maintainer agreement on user-facing surface changes; the specific choices here (mirroring .code onto the outer TypeError when Node does not, mapping only two errors to libuv errno names while keeping Bun names for the rest, the exact 'fetch failed' message) are design calls a human should ratify.

Other factors

  • Overlapping PRs: #35855 and #34402 cover the same ground with different tradeoffs; a maintainer needs to coordinate which lands and close the others.
  • Prior feedback addressed: the earlier 🔴 finding (three tests still asserting the old outer message) was fixed in d190934; the un-awaited .rejects matchers were fixed in ca44353 and 3bb2573; the html-rewriter regex weakening was fixed in d9d67e9; the deinitialization test-precision nit was fixed. All inline threads are resolved.
  • C++ pattern: the new binding's scope.clearException() usage matches the immediately-adjacent SystemError__toErrorInstance byte-for-byte, so it's consistent with local convention for error-construction helpers.
  • Test coverage: the new fetch-network-error.test.ts is thorough (connection-refused, pre-header close, malformed response, mid-body truncation, and the inlined is-network-error predicate).
  • CodeRabbit's linked-issues check notes #20486 also covers invalid-URL/protocol TypeError shapes not addressed here; whether to keep that Fixes tag is a maintainer call.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/fetch/fetch-http2-client.test.ts`:
- Around line 855-858: Update the subprocess assertions in
test/js/web/fetch/fetch-http2-client.test.ts at lines 855-858, 1157-1160, and
1179-1182: retain concurrent draining of stdout, stderr, and process exit,
assert exitCode is 0, and include stderr alongside stdout in failure diagnostics
while preserving each case’s expected stdout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3740e85b-adac-4689-b403-d29a483c8309

📥 Commits

Reviewing files that changed from the base of the PR and between d9d67e9 and 592f8b9.

📒 Files selected for processing (2)
  • test/js/web/fetch/fetch-http2-client.test.ts
  • test/vendor.json

Comment thread test/js/web/fetch/fetch-http2-client.test.ts

@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 earlier review feedback has been addressed and no new issues surfaced this pass, but this is a user-facing change to fetch()'s error shape (outer .message, .code for ConnectionRefused, instanceof TypeError) that breaks existing consumers keying on the old shape — the elysia vendor skip is one example — so it needs a maintainer's call on the compatibility trade-off. Note also that #35998 was opened after this PR and is described as superseding it with a broader errno map and stack capture; worth deciding which one to land.

What was reviewed:

  • SystemError__toFetchTypeErrorInstance: the scope.clearException() pattern matches the sibling SystemError__toErrorInstance immediately above it, so it's consistent with local convention.
  • ValueError::FetchError lifecycle: reset/to_js/dupe all handle the new variant symmetrically with SystemError.
  • Re-checked the three test files flagged in the earlier 🔴 comment (bun-server.test.ts, undici.test.ts, test-http-should-not-accept-untrusted-certificates.ts) plus the html-rewriter regex and the un-awaited .rejects in client-fetch — all now updated to match on .code / .cause and awaited.
Extended reasoning...

Overview

Reshapes every fetch() network-error rejection from a bare Error to TypeError('fetch failed', {cause: <SystemError>}), mirroring the Fetch spec (§4.1.12.3) and Node/undici. Native side: a new ValueError::FetchError variant in Body.rs, a SystemError::to_fetch_type_error_instance bridge in SystemError.rs, the C++ SystemError__toFetchTypeErrorInstance in bindings.cpp, and FetchTasklet::on_reject now returns FetchError and maps ConnectionRefusedECONNREFUSED with syscall: "connect". Fourteen test files updated for the new shape, one new dedicated test file, and one elysia vendor test skipped.

Security risks

None identified. The change is confined to how an already-constructed error is wrapped before rejection; no new user input reaches parsing, allocation, or auth paths.

Level of scrutiny

High. This is a deliberate, ecosystem-visible behavior change to a core Web API on the hot path: .message goes from descriptive to the fixed string "fetch failed", .code for connection-refused changes from "ConnectionRefused" to "ECONNREFUSED", and instanceof TypeError flips. That is the correct direction per spec/Node, but it will break user code that pattern-matches the old shape (the elysia vendor test being the in-tree example). REVIEW.md's API-design guidance calls for maintainer agreement on user-facing surface changes, which this is.

Other factors

  • All five of my earlier inline comments (test precision in deinitialization/test.ts, Buffer.alloc vs .repeat, three missed message-asserting tests, un-awaited .rejects in client-fetch, dead regex arms in html-rewriter) are resolved in the current diff, as is CodeRabbit's un-awaited-rejection batch.
  • The new C++ function's clearException() after toJS follows the exact pattern of SystemError__toErrorInstance a few lines above; not a new violation.
  • The ValueError::FetchError variant is wired into every lifecycle method (reset, to_js, dupe) symmetrically with SystemError, so no leak/double-free asymmetry.
  • The author has since opened #35998, described as covering this reshape plus caller-stack capture, a 'terminated' body-stage message, and a broader errno map. There are also two older overlapping PRs (#35855, #34402). A human should decide whether to land this incrementally or consolidate into #35998.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status (updated): the branch is now a single commit (2aecba8) on top of current main, 20 files. Since the earlier green runs it was reworked on top of #35855 (which landed in the meantime): the SystemTypeError variant from that PR is replaced by FetchFailed { cause, terminated }, body-stage failures reject as TypeError("terminated") like undici, the cause carries libuv-style negative errno values, and the fetch docs describe the shape. The PR description above reflects the current diff.

Verified locally on the squashed commit: test/js/web/fetch/fetch-network-error.test.ts (6 cases) fails 6/6 on the released binary and passes 6/6 with this build; the other touched test files pass (the three bun-server.test.ts failures in this container fail identically on unmodified main and are environmental).

The ~250 comment-cop threads on files this PR does not touch were posted while GitHub was showing a stale 3133-file diff after a merge of main into the branch; squashing fixed the diff (GitHub now reports 20 files), and comment-cop resolves its own threads whose blocks are no longer in the diff on its next run.

@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 prior review feedback has been addressed and CI is green, but this changes the user-visible fetch() rejection shape (Error→TypeError, descriptive message→"fetch failed", ConnectionRefusedECONNREFUSED) and overlaps with #35998 — a maintainer should sign off on the API surface and decide which PR to land.

What was reviewed:

  • SystemError__toFetchTypeErrorInstance follows the same DECLARE_TOP_EXCEPTION_SCOPE / clearException() pattern as its sibling SystemError__toErrorInstance in the same file.
  • New ValueError::FetchError variant is wired into every match site (reset, to_js, dupe).
  • All 5 previously-flagged test-assertion issues (missing awaits, weakened assertions, three CI-breaking message checks) are fixed in the current diff.
  • The vendor.json elysia skip is documented with the upstream fix condition.
Extended reasoning...

Overview

This PR reshapes every fetch() network-error rejection from a bare Error with a descriptive .message into TypeError('fetch failed', {cause: <original Error>}), matching Fetch spec §4.1 step 12.3 and Node/undici. The native change is small (~60 lines across SystemError.rs, bindings.cpp, headers.h, Body.rs, FetchTasklet.rs); the bulk of the diff is 13 test files updated for the new shape plus one new dedicated test file and a vendor.json skip for an elysia test that asserts the old message.

Security risks

None identified. This only changes how already-failing network requests are reported to JS; no new input parsing, no auth/TLS logic changes. The .cause chain preserves all diagnostic fields (.code/.syscall/.path/.hostname) that were previously on the top-level error.

Level of scrutiny

High — user-facing API design. While the implementation is straightforward and well-tested, this is a deliberate behavioral break for a core Web API: existing Bun code checking err.code === 'ConnectionRefused' or err.message.includes('Unable to connect') will stop matching. The PR mitigates this by mirroring .code onto the outer TypeError, but the ConnectionRefusedECONNREFUSED remap is still a break. REVIEW.md's guidance on API surface changes and the note that #35998 covers a superset of this reshape (plus caller-stack capture, a 'terminated' body-stage message, and a broader errno map) both point to a maintainer decision.

Other factors

  • Prior feedback fully addressed: my four earlier inline comments (weakened assertions, harness convention, three would-fail tests, un-awaited .rejects, html-rewriter regex) and CodeRabbit's await-rejection findings are all resolved in the current diff.
  • CI green: build #82837 passed on all fetch-related lanes; the two red jobs are documented as unrelated (stale binary-size baseline, worker-thread stress SIGABRT on one ASAN lane).
  • C++ exception handling: the new binding's scope.clearException() usage mirrors the existing SystemError__toErrorInstance immediately above it in the same file, so it's consistent with local convention rather than a new pattern.
  • Enum exhaustiveness: the new ValueError::FetchError arm is present in reset(), to_js(), and dupe() — no missed match sites found.
  • Competing PR: robobun opened #35998 which extends this same reshape; landing order/scope is a maintainer call.

Comment thread src/js/node/url.ts
Comment on lines +1220 to +1222
// Ports of node's getPathFromURLWin32/getPathFromURLPosix
// (lib/internal/url.js), used when the `windows` option is explicit; the
// host-platform path stays on the native fast path below.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/url.ts
Comment on lines +1377 to +1378
// The node-parity implementations (UNC/backslash/err.input fidelity), not
// the Bun.* globals, which stay unchanged.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/util.ts
Comment on lines +36 to +38
// Node semantics (includes the [[Prototype]] identity check Bun.deepEquals omits) plus the
// skipPrototype third argument, which is public API in node v26.3.0 (fn.length === 3).
// https://github.com/nodejs/node/blob/main/lib/internal/util/comparisons.js

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/util.ts
Comment on lines +412 to +413
// libuv's uv_strerror() messages keyed by error name (target-independent).
// The per-target codes come from the native uv_e table (internalErrorEntries).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/util.ts
Comment on lines +518 to +519
// Fresh Map with fresh entry arrays: node's binding materialises a new map
// per call, and callers may mutate the [name, message] pairs.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/util.ts
Comment on lines +535 to +536
// ErrnoException validates err via getSystemErrorName (type + range) and
// builds node's exact `${syscall} ${code}[ ${original}]` shape.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +48 to +50
// JSC manages one undivided heap, so a record carries a single space instead
// of V8's thirteen, and counters JSC does not track are reported as 0 rather
// than invented.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +82 to +84
// A profiler that is started and then dropped without stop() would otherwise
// leave its native session open for the life of the VM; the registry releases
// it when the wrapper is collected, matching node's BaseObject finalizer.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +112 to +114
// A JavaScriptCore eden collection only scans newly allocated objects,
// and a full collection sweeps the whole heap, so they line up with
// V8's minor and major collection types.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +135 to +137
// Node derives this tag from the V8 version, command-line flags, and CPU
// features; Bun mirrors that with its own version plus the flags recorded by
// setFlagsFromString, so the tag is stable until the flags change.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +201 to +203
// V8 divides its heap into fixed spaces; JSC manages one undivided heap, so
// the JSC totals are reported under "old_space" and the other V8 space names
// exist for shape compatibility.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +237 to +239
// JSC does not expose a per-category code size breakdown; report zeros rather
// than invented numbers, like node does for counters V8 is not tracking
// (e.g. cpu_profiler_metadata_size).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +250 to +251
// V8 flags have no JSC equivalent; record them so cachedDataVersionTag
// changes like node's does, and otherwise ignore them.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +255 to +256
// Bun has no cppgc (Oilpan) C++ heap, so the statistics are always empty;
// this matches node's shape with nothing allocated through cppgc.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/node/v8.ts
Comment on lines +268 to +270
// Buffer-bearing payloads are framed as MAGIC + version + SSV([value, buffers]) so deserialize
// can restore Buffer prototypes (see internal/serialization_buffers). Leading 0xFF cannot collide
// with bare SSV output; Buffer-free payloads stay bare SSV so older readers keep working.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun
robobun force-pushed the farm/e66a66f1/fetch-typeerror-cause branch from 4755cf1 to 9da055a Compare August 13, 2026 08:42
Comment thread src/jsc/SystemError.rs Outdated
Comment on lines +91 to +95
/// The rejection shape of a failed `fetch()` in node/undici:
/// `TypeError("fetch failed", { cause })`, or `"terminated"` once the
/// response headers had arrived, where `cause` is this error as a plain
/// `Error`. `.code` is mirrored onto the `TypeError` for code that reads it
/// there. Consumes `self` like `to_error_instance`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment on lines +2649 to +2653
// undici rejects a failed fetch() with TypeError("fetch failed") whose `cause`
// is the underlying error, and with TypeError("terminated") once the response
// headers had arrived and the body failed. `cause` is non-enumerable as with
// `new TypeError(msg, { cause })`. `.code` is mirrored onto the TypeError for
// code that already reads it there.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/Body.rs Outdated
Comment on lines +569 to +571
/// A failed `fetch()`: `TypeError("fetch failed", { cause })`, or
/// `"terminated"` when the response headers had already arrived (see
/// `SystemError::to_fetch_failed_instance`).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1329 to +1330
// Once the response headers have been handed out, the failure is one of
// the body, which undici reports as "terminated" instead of "fetch failed".

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1373 to +1375
// Failures with a libuv equivalent use node's vocabulary (`code`, negative
// `errno`, `syscall` where the failing call is known); the rest keep
// their `http::Error` name as the code.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun
robobun force-pushed the farm/e66a66f1/fetch-typeerror-cause branch from 9da055a to 49529cc Compare August 13, 2026 08:51
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

@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.

I reviewed this PR again and found no bugs; every item from my earlier passes (the three missed test files, the un-awaited .rejects at client-fetch.test.ts:518, the weakened assertions in deinitialization/test.ts and html-rewriter.test.js, and the Buffer.alloc convention) has been addressed in the current diff. Because this changes the user-visible fetch() error surface and encodes several API-shape decisions (mirroring .code onto the outer TypeError, the "terminated" vs "fetch failed" split, which http::Error variants get libuv names), a maintainer sign-off on those choices would still be worthwhile.

What was reviewed:

  • SystemError__toFetchFailedInstance: the clearException() after Bun::toJS(code) matches the identical pattern already used for every field in systemErrorToErrorInstance in the same file.
  • ValueError::FetchFailed in Body.rs: reset/to_js/dupe all handle the new variant; SystemError::clone() on cause bumps refcounts as with the sibling SystemError arm.
  • on_reject in FetchTasklet.rs: both return sites (DNS and general) produce the new variant; terminated = self.metadata.is_some() and the UV_E errno mapping look correct.
  • html-rewriter.test.js: connectionError is now an asymmetric matcher passed to .rejects.toThrow(...) at five sites — verified those sites and that the PR ran this file locally.
Extended reasoning...

Overview

The PR reshapes fetch network-error rejections to match Node/undici: TypeError('fetch failed' | 'terminated') with the underlying system error attached as a non-enumerable cause, and .code mirrored onto the outer TypeError for backward compatibility. Native changes span src/jsc/SystemError.rs (extern signature), src/jsc/bindings/bindings.cpp + headers.h (new SystemError__toFetchFailedInstance, removal of the errorType parameter from #35855), src/runtime/webcore/Body.rs (ValueError::SystemTypeErrorFetchFailed { cause, terminated }), and src/runtime/webcore/fetch/FetchTasklet.rs (on_reject now emits the new variant with libuv-style errno/syscall for ConnectionRefused/ConnectionClosed). One new test file plus 13 updated test files and a docs paragraph.

Security risks

None identified. The change is purely error-object construction on an already-failing path; no new inputs are parsed, no validation is loosened, and the TLS/redirect/DNS handling itself is unchanged.

Level of scrutiny

High. fetch() is critical user-facing surface, and this PR makes deliberate API-design tradeoffs — which http::Error variants map to libuv codes vs keep Bun's names, mirroring .code on the outer error, choosing not to adopt #35998's per-call stack capture or its broader code renames. The description explains each choice and the consolidation with #34402/#35998, but these are the kind of Node-compat decisions a maintainer should ratify rather than an automated review.

Other factors

All five of my earlier inline threads are resolved and the fixes are visible in the current 20-file diff. The new clearException() in bindings.cpp initially looked like a REVIEW.md violation, but systemErrorToErrorInstance (which the new function calls) already uses the identical DECLARE_TOP_EXCEPTION_SCOPE + per-field clearException() pattern for code/path/syscall/hostname/dest, so this follows the file's local convention. Test coverage is thorough (6 new dedicated cases covering shape, non-enumerable cause, negative errno, both body-read paths, and a hermetic DNS case). The hundreds of comment-cop threads on unrelated files are noise from a stale GitHub diff after a merge and do not concern the 20 files this PR actually touches.

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.

fetch() connection-refused error shape differs from Node: no e.cause.code === 'ECONNREFUSED'

2 participants