Skip to content

fetch: reject network errors as TypeError('fetch failed') with cause, errno codes, and a caller stack - #35998

Closed
robobun wants to merge 15 commits into
mainfrom
farm/6ca72a7e/fetch-error-shape
Closed

fetch: reject network errors as TypeError('fetch failed') with cause, errno codes, and a caller stack#35998
robobun wants to merge 15 commits into
mainfrom
farm/6ca72a7e/fetch-error-shape

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

fetch() network failures were rejecting with a plain Error (name === "Error", instanceof TypeError === false) carrying Bun-specific PascalCase code values and no .cause. The error was minted inside an event-loop task where the JS interpreter stack is empty, so .catch() consumers saw err.stack === undefined.

try { await fetch("http://127.0.0.1:1/"); } catch (e) {
  // before: Error{code:"ConnectionRefused", message:"Unable to connect..."}, e.stack === undefined for .catch()
  // after:  TypeError{message:"fetch failed", code:"ECONNREFUSED", cause:Error{code:"ECONNREFUSED",...}}
  // node:   TypeError{message:"fetch failed", cause:Error{code:"ECONNREFUSED"}}
}

This broke is-network-error (the classifier under p-retry, ky, and most hand-rolled retry loops: it keys on name === "TypeError" and message === "fetch failed"), and the common Node idiom err.cause?.code === "ECONNREFUSED".

Change

FetchTasklet::on_reject now returns a new ValueError::FetchFailed { cause, terminated, stack_source }:

  • Outer TypeError: message is "fetch failed", or "terminated" once response headers have arrived (undici's body-stream error message). .cause (DontEnum) is the same system-error Error Bun previously surfaced at the top level (code/message/path/syscall/hostname/errno), so no diagnostic information is lost. .code is mirrored from the cause onto the outer TypeError so existing err.code checks keep working.

  • .stack: fetch() captures an ErrorInstance at the call site (while the caller is still on the interpreter stack) and holds it in the FetchTasklet. Bun__createFetchFailedTypeError transplants those frames onto the outer TypeError, so .stack points at the fetch() call even for .catch()/top-level consumers that have no await chain.

  • errno codes: http::Error::errno_code() maps Bun's labels to Node/undici vocabulary where one exists:

    before after
    ConnectionRefused ECONNREFUSED
    Timeout ETIMEDOUT
    Malformed_HTTP_Response HPE_INVALID_CONSTANT
    InvalidHTTPResponse HPE_INVALID_CHUNK_SIZE
    ResponseHeadersTooLarge UND_ERR_HEADERS_OVERFLOW
    InvalidContentLength UND_ERR_RES_CONTENT_LENGTH_MISMATCH
    ZlibError (decompression) Z_DATA_ERROR

    ECONNRESET, ENOTFOUND, TLS cert codes, and Bun-specific labels without a Node equivalent (FailedToOpenSocket, TooManyRedirects, UnexpectedRedirect, brotli/zstd decompression, HTTP/2 and HTTP/3 codes) are unchanged.

Tests

New test/js/web/fetch/fetch-error-shape.test.ts asserts the full shape for ECONNREFUSED, ECONNRESET, DNS failure; that .catch() consumers receive a string .stack pointing at the call site; and that the inlined is-network-error predicate now returns true. All five tests fail on the released binary.

Existing tests that asserted the old name === "Error", a PascalCase .code, or the raw message string have been updated (12 files). serve.test.ts's "should not instanciate error instances in each request" now GCs before its heap-count check since fetch() allocates one transient Error per call for stack capture.

Supersedes the narrower reshape in #35988 (adds stack capture, terminated body-stage message, and the full errno map). #34402 is a subset of this.


no test proof · iteration 6 · 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-syscall-fault.test.ts test/js/web/fetch/fetch.stream.test.ts test/js/web/fetch/fetch.test.ts

Fixes #34397
Fixes #20486

…and caller stack

fetch() network failures now reject with a TypeError whose message is
'fetch failed' (or 'terminated' once the response body is streaming),
matching Node.js/undici and the WHATWG Fetch spec. The underlying
system error (connection refused, DNS failure, TLS error, parse error,
decompression error, ...) is attached as .cause, and .code is mirrored
onto the outer TypeError so existing err.code checks keep working.

The outer TypeError's .stack is populated from a snapshot of the
caller's stack taken at the fetch() call site. Previously the error was
created from an event-loop task with an empty interpreter stack, so
.catch() consumers saw .stack === undefined.

Error codes now use Node.js errno / parser vocabulary where one exists:
  ConnectionRefused       -> ECONNREFUSED
  ConnectionClosed        -> ECONNRESET (unchanged)
  Timeout                 -> ETIMEDOUT
  Malformed_HTTP_Response -> HPE_INVALID_CONSTANT
  InvalidHTTPResponse     -> HPE_INVALID_CHUNK_SIZE
  ResponseHeadersTooLarge -> UND_ERR_HEADERS_OVERFLOW
  InvalidContentLength    -> UND_ERR_RES_CONTENT_LENGTH_MISMATCH
  Zlib errors             -> Z_DATA_ERROR
ENOTFOUND, cert codes, and Bun-specific labels without a Node
equivalent are unchanged.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:41 PM PT - Jul 26th, 2026

@robobun, your commit c686d67 has 1 failures in Build #82872 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.48 MB71.95 MB+544.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+574.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+537.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35998

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

bun-35998 --bun

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

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. fetch() connection-refused error shape differs from Node: no e.cause.code === 'ECONNREFUSED' #34397 - Directly requests err.cause.code === 'ECONNREFUSED' instead of err.code === 'ConnectionRefused', which is exactly the error shape transformation this PR implements
  2. Native fetch incompatibilities with NodeJS error format and codes #20486 - Umbrella issue requesting fetch errors match Node.js/undici format (TypeError with .cause.code for ECONNREFUSED, ENOTFOUND, etc.); cases 1 and 2 are directly fixed by this PR

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

Fixes #34397
Fixes #20486

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/runtime/webcore/Body.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Fetch failures now use Node.js/undici-compatible codes, nested causes, standardized TypeError results, and preserved caller stacks. Fetch tasklets propagate stack sources, while HTTP, TLS, DNS, compression, redirect, HTTP/2, and lifecycle tests validate the updated error shapes.

Changes

Fetch error shaping

Layer / File(s) Summary
Compatibility error-code mapping
src/http/error.rs
Adds Node.js/undici-style codes for connection, parsing, compression, timeout, and content-length errors.
JavaScript fetch error construction
src/jsc/bindings/bindings.cpp, src/jsc/lib.rs, src/runtime/webcore/Body.rs
Captures caller stacks and constructs TypeError("fetch failed") or TypeError("terminated") with .cause, mirrored .code, and transplanted stack frames.
Fetch rejection propagation
src/runtime/webcore/fetch.rs, src/runtime/webcore/fetch/FetchTasklet.rs
Carries caller stack sources through fetch options and tasklets, converting DNS and system failures to FetchFailed values.
Error-shape and code coverage
test/js/web/fetch/*, test/js/bun/**/*, test/js/first_party/undici/undici.test.ts, test/bake/fixtures/deinitialization/test.ts, test/regression/issue/*, test/vendor.json
Adds and updates coverage for standardized fetch errors, nested causes, stacks, errno codes, parser failures, compression failures, redirects, HTTP/2, TLS, cleanup behavior, and upstream skips.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#35988 — Reshapes fetch network failures into TypeError("fetch failed") with the underlying error on .cause.

Suggested reviewers: sosukesuzuki, alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed It implements the linked fetch compatibility goals: TypeError failures, preserved .cause, and Node-style errno codes.
Out of Scope Changes check ✅ Passed The changes stay within fetch error handling, supporting tests, and a small related test skip; no clear unrelated work is present.
Title check ✅ Passed The title clearly summarizes the main change: fetch network failures now reject as TypeError with cause, errno codes, and caller stack.
Description check ✅ Passed The description covers the change and verification details, though it is not formatted with the exact template headings.

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch: reject network errors as TypeError('fetch failed' / 'terminated') with the system error as cause #35988 - Direct predecessor that also wraps fetch network errors as TypeError('fetch failed') with .cause (explicitly superseded by this PR)
  2. fetch: attach a node-style cause to connection-failure errors #34402 - Subset that attaches node-style cause with errno codes to fetch connection-failure errors
  3. fetch: mark body disturbed when a reader fails; reject network errors as TypeError #35855 - Also converts fetch network errors to TypeError and touches the same files (bindings.cpp, Body.rs, FetchTasklet.rs)

🤖 Generated with Claude Code

Comment thread test/js/bun/http/bun-server.test.ts Outdated
Comment thread test/js/bun/http/serve.test.ts Outdated
Comment thread test/js/web/fetch/fetch-error-shape.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: 2

🤖 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-error-shape.test.ts`:
- Around line 131-142: Update the network-error test around
looksLikeNetworkError to avoid hardcoding port 1: reserve an ephemeral localhost
port with Bun.listen(), capture its assigned port, stop the listener, and fetch
that port so the connection is refused. Match the setup and cleanup used by the
preceding ECONNREFUSED test while preserving the existing error-shape
assertions.
- Around line 68-94: Update both spawned-process test cases around Bun.spawn to
drain stdout and stderr concurrently with proc.exited, then assert the combined
stderr and exitCode result before calling JSON.parse on stdout. Preserve the
existing output assertions, and ensure child failures expose stderr diagnostics
instead of producing a JSON parse error.
🪄 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: 2b2ec72e-bd9d-449c-bf54-dfabbd6b7941

📥 Commits

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

📒 Files selected for processing (19)
  • src/http/error.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/lib.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/bake/fixtures/deinitialization/test.ts
  • test/js/bun/http/bun-server.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/chunked-trailing.test.js
  • test/js/web/fetch/client-fetch.test.ts
  • test/js/web/fetch/fetch-error-shape.test.ts
  • test/js/web/fetch/fetch-gzip.test.ts
  • test/js/web/fetch/fetch-redirect.test.ts
  • test/js/web/fetch/fetch-syscall-fault.test.ts
  • test/js/web/fetch/fetch.stream.test.ts
  • test/js/web/fetch/fetch.test.ts

Comment thread test/js/web/fetch/fetch-error-shape.test.ts Outdated
Comment thread test/js/web/fetch/fetch-error-shape.test.ts
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green at c686d67.

Build 82872 final: 194/196 passed, 1 soft-fail, 1 infra timeout. No test failures on any lane that ran.

All 5 new fetch-error-shape tests fail on the released binary and pass on the debug build. 20 test files updated for the new error shape; 1 vendored elysia test skipped until upstream updates its assertion. All review threads addressed; the final summary review confirms no outstanding issues and flags the per-fetch() stack-capture allocation and .code vocabulary change for maintainer sign-off.

Comment thread src/http/error.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread test/bake/fixtures/deinitialization/test.ts Outdated
…uard, undici maxRedirections, bake deinit, cert-mismatch fixture)
Comment thread src/http/error.rs
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
…uestBodyNotReusable comment; update h2-adversarial ConnectionClosed regex
…onsumer's async frames win; update html-rewriter body-fail matchers for the TypeError shape

[skip size check] binary-size baseline is main #79916 (12 commits behind this PR's base, including node:inspector, node:repl, node:quic landings)
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/web/fetch/fetch-error-shape.test.ts
Comment thread src/jsc/bindings/bindings.cpp Outdated
…erializeErrorInfoIfNeeded can invoke prepareStackTrace); drop redundant using in refusedPort helper

[skip size check]
Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/bindings/bindings.cpp Outdated
…instead of the old Bun-specific message

[skip size check] binary-size baseline #79916 is 12 main commits behind this PR's base (node:quic, node:repl, node:inspector landings account for the delta)

@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: 2

🤖 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 `@src/jsc/bindings/bindings.cpp`:
- Around line 2391-2415: Update the stack fallback around
materializeErrorInfoIfNeeded() to root or copy result and stackSrc across calls
that may execute user code, then re-derive destInstance and srcInstance
afterward before accessing them. Do not clear arbitrary exceptions from
materializeErrorInfoIfNeeded(), value(), or related calls; propagate them, only
consuming the pending termination sentinel when it is intentionally replaced.
Preserve the callback-free header fallback when no exception occurs.

In `@test/js/workerd/html-rewriter.test.js`:
- Line 117: Strengthen the connectionError fixture used by the body-stage
assertions to include message "terminated" and cause containing code
"ECONNRESET", while retaining the existing outer name and code checks. Update
the related assertions to compare against this expanded contract so they verify
both the terminated message and nested cause.
🪄 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: fbe61c5d-b01a-490a-94e8-e0b1fb56ee86

📥 Commits

Reviewing files that changed from the base of the PR and between ef09a2b and afe9fd9.

📒 Files selected for processing (4)
  • src/jsc/bindings/bindings.cpp
  • test/js/web/fetch/fetch-error-shape.test.ts
  • test/js/workerd/html-rewriter.test.js
  • test/vendor.json

Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/workerd/html-rewriter.test.js
Comment thread src/jsc/bindings/bindings.cpp Outdated
robobun added 2 commits July 26, 2026 20:36
…uring prepareStackTrace is not swallowed

[skip size check]
…rn (the await addition was a review nit that changes timing in a deinit stress test)

[skip size check]

@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 findings are addressed and I didn't find new issues, but this is a user-facing API change (fetch rejection shape + .code vocabulary) that also adds a per-fetch() Error allocation on the success path and loosens the serve.test.ts heap-count guard to accommodate it — worth a human sign-off on those tradeoffs.

What was reviewed:

  • Bun__createFetchFailedTypeError: exception scope, clearExceptionExceptTermination, GC-safety of locals across materializeErrorInfoIfNeeded, dead ternary — all fixed.
  • ValueError::FetchFailed lifecycle: reset/dupe/to_js all handle the new stack_source Strong; caller_stack_source is deinit'd in clear_data.
  • Grepped for remaining assertions on the old PascalCase codes / message strings; the html-rewriter suite that was missed initially is now updated.
Extended reasoning...

Overview

Reshapes every fetch() network-layer rejection into TypeError('fetch failed'|'terminated') with the underlying system error on .cause, mirrors .code onto the outer error, remaps a subset of Bun's PascalCase codes to Node/undici errno vocabulary (ECONNREFUSED, ETIMEDOUT, HPE_*, UND_ERR_*, Z_DATA_ERROR), and captures the caller's stack at fetch() invocation so the rejection has a useful .stack even for .catch() consumers. Native side: two new C++ entry points in bindings.cpp, a new ValueError::FetchFailed variant threaded through Body.rs, a StrongOptional field on FetchTasklet, and errno_code() on http::Error. Test side: one new dedicated shape test plus updates to ~20 existing test files that asserted the old shape; one vendor test skipped.

Security risks

None identified. The change is error-construction/formatting only; no new input parsing, auth, or trust decisions.

Level of scrutiny

High. This is a deliberate user-facing behavior change on one of Bun's most-used APIs. Three design points deserve maintainer eyes:

  1. Per-call allocation: every fetch() now allocates a JS Error at the call site (Bun__captureCallerStackError) purely so a failed request can transplant its frames. On the success path this is pure overhead, and the existing "should not instanciate error instances in each request" heap-count guard in serve.test.ts had to be relaxed from <= startErrorCount to <= startErrorCount + batchSize (with an added Bun.gc(true)) to absorb it. The comment argues the guard's real purpose — catching a per-request server-side leak of ~1000 — is preserved, which is true, but weakening a leak sentinel to land a feature is exactly the pattern REVIEW.md flags.
  2. .code remap is breaking for callers that check the old Bun-specific strings ("ConnectionRefused", "Timeout", "ZlibError", "InvalidHTTPResponse", "Malformed_HTTP_Response", "InvalidContentLength"). The outer .code mirror only helps callers already using errno names.
  3. Vendor skip: elysia core/stop.test.ts is temporarily skipped rather than the assertion being updated upstream first.

Other factors

The C++ has been through several review rounds here (missing scope → clearExceptionclearExceptionExceptTermination; dead-ternary cleanup; html-rewriter test-suite sweep), and all of those are now applied. CodeRabbit's GC-rooting concern was correctly refuted (JSC conservative stack scan). The new FetchFailed variant is wired into reset(), dupe(), and to_js() and the new StrongOptional is released in FetchTasklet::clear_data. Test coverage for the new shape is solid (fetch-error-shape.test.ts covers ECONNREFUSED, ECONNRESET, DNS, .catch() stack, is-network-error predicate).

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated into #35988, which now also carries the 'terminated' message for body-stage failures and the hermetic DNS test from this PR. The per-call stack capture and the code renames were not carried over; the reasons are in #35988's description, and the missing stack on natively created errors is being handled separately for all such errors rather than for fetch() alone. Closing.

@robobun robobun closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants