Skip to content

fetch: reject a FormData body whose Bun.file() cannot be read with a TypeError - #37514

Open
robobun wants to merge 1 commit into
mainfrom
farm/d51dd0b5/fetch-formdata-file-type-error
Open

fetch: reject a FormData body whose Bun.file() cannot be read with a TypeError#37514
robobun wants to merge 1 commit into
mainfrom
farm/d51dd0b5/fetch-formdata-file-type-error

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

fetch() with a FormData body that holds a Bun.file() which cannot be read rejects with a plain Error. Since #35855 the network errors fetch() produces are TypeErrors carrying the system error fields; this rejection was left out:

const body = new FormData();
body.append("f", Bun.file("/definitely/missing/upload"));
try {
  await fetch("http://127.0.0.1:1/", { method: "POST", body });
} catch (e) {
  console.log(e.constructor.name, e instanceof TypeError, e.code, e.syscall);
}
// before: Error false ENOENT open
// after:  TypeError true ENOENT open

The same applies to a directory entry (EISDIR from the read) and to the object form fetch({ url, body }).

Cause: Blob::from_dom_form_data (src/runtime/webcore/Blob.rs) serializes the multipart body eagerly. When the read of a file entry failed, FormDataContext::on_entry threw the bun_sys::Error on the global right there with to_js(), i.e. SystemError::to_error_instance, a plain Error. fetch_impl then saw the pending exception and reject_on_exception rejected with whatever had been thrown. The serializer does not know whether it is serving fetch() or a Request/Response constructor, and those two callers want different things.

Fix: from_dom_form_data returns Result<Blob, bun_sys::Error> instead of throwing (FormDataContext records the first read failure in place of the failed flag; the joiner cleanup on failure is unchanged). Its two callers pick the error class:

  • Body::Value::from_js (new Request(), new Response(), HTMLRewriter.transform) throws it as the same plain system error as before. It now does so by returning Err rather than Ok(empty blob) with the exception left pending, which is what its callers already handle.
  • HTTPRequestBody::from_js (src/runtime/webcore/fetch/FetchTasklet.rs, the body extraction both fetch(url, { body }) and fetch({ url, body }) go through) handles FormData before delegating to Value::from_js and materializes the failure with SystemError::to_type_error_instance, the binding fetch: mark body disturbed when a reader fails; reject network errors as TypeError #35855 added. The two builders share one body in bindings.cpp, so code, errno, syscall, path and the message are identical; only the prototype differs.

Why this split

The fetch spec turns a request body that fails to be read into a network error, and fetch() rejects every network error with a TypeError; Node rejects the equivalent (a body over a missing file) with TypeError: fetch failed (cause ENOENT). Bun's shape for this since #35855 is the system error fields directly on the TypeError, which is what this produces, so err instanceof TypeError now identifies a fetch() failure whether the body or the connection failed.

The constructors are deliberately unchanged. The spec gives them no reason to throw at all (the eager read is a Bun choice), and everywhere else in Bun a file-backed body that cannot be read surfaces as the plain system error (Bun.file(p).text() and new Response(Bun.file(p)).text() both reject with Error { code: "ENOENT" }); Node likewise hands back the underlying error when a body is consumed outside fetch(). Changing their class would widen the change without a spec or Node argument behind it. test/js/web/html/FormData.test.ts ("doesnt crash when file is missing") and FormData-file-error-leak keep exercising that path and pass unchanged.

#37501 does the same for a bare body: Bun.file() in fetch.rs and explicitly leaves this FormData route out; the two are independent (both add tests at the end of fetch-file-upload.test.ts, so whichever lands second needs a trivial rebase). #35792 (streaming FormData uploads) keeps unreadable files on this buffered path, so this still applies there.

How did you verify your code works?

New describe block in test/js/bun/http/fetch-file-upload.test.ts:

  • fetch(url, { body }) with a string entry, a readable file and a missing file: already rejected when fetch() returns (Bun.peek.status), instanceof TypeError, { code: "ENOENT", syscall: "open", path, errno }, message still names the file
  • fetch({ url, body }): same
  • a directory entry: TypeError with { code: "EISDIR", syscall: "read" }, the failure coming out of the read rather than the open (skipped on Windows)
  • new Response(body) and new Request(url, { body }) still throw synchronously, still a plain Error (not a TypeError) with the same fields

The first three fail on the current release (Expected constructor: TypeError, received the plain Error with the same fields) and pass with this change; the fourth pins the unchanged side. Also run with the debug build: the rest of this file, test/js/web/html/FormData*.test.ts (including the file-error leak test), test/js/web/fetch/{body,body-mixin-errors,body-clone,content-length} tests, test/js/bun/http/form-data-set-append.test.js, the HTMLRewriter suite, and cargo clippy -p bun_runtime.

…TypeError

Blob::from_dom_form_data threw the read failure as a plain Error while
serializing the multipart body, so fetch(url, { body: formData }) rejected
with that Error even though every other fetch() network error has been a
TypeError since #35855. It now returns the bun_sys::Error to its caller:
Request/Response construction keeps throwing it as the plain system error,
and HTTPRequestBody::from_js (fetch) materializes it as a TypeError that
still carries code, errno, syscall and path.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 4 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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

How do review 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 refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c5269020-4b7b-41ca-82f1-27333702aca1

📥 Commits

Reviewing files that changed from the base of the PR and between da3851e and 0e4fcc2.

📒 Files selected for processing (4)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/bun/http/fetch-file-upload.test.ts

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the 1.4.0 release and on main (da3851e57a): fetch(url, { method: "POST", body: formData }) where the FormData holds Bun.file() of a missing path rejects with a plain Error { code: "ENOENT", syscall: "open" } instead of a TypeError; same for a directory entry (EISDIR) and for fetch({ url, body }).

Fix is in this PR. The three new fetch() tests in test/js/bun/http/fetch-file-upload.test.ts fail on the release build (Expected constructor: TypeError) and pass with the change; the fourth test pins that new Request() / new Response() still throw the plain system error.

Related: #37501 covers the bare body: Bun.file() case in fetch.rs; this PR covers the FormData route it leaves out.

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

LGTM — narrow error-class fix that brings the FormData-body fetch() rejection in line with #35855's TypeError convention.

What was reviewed:

  • from_dom_form_data now returns Result instead of throwing in-place; both callers (Body.rs, FetchTasklet.rs) are updated, no others exist.
  • Value::from_js callers (Request ctor, Response ctor via body::extract, HTMLRewriter) already handle Err — the switch from Ok(empty) + pending exception to Err is behavior-preserving there.
  • HTTPRequestBody::from_js success path returns AnyBlob::Blob, identical to what the old BodyValue::from_jsuse_as_any_blob() fallthrough produced.
  • SysErrorJsc::throw = to_system_error().to_error_instance() + throw_value, matching the old on_entry behavior byte-for-byte for the constructor path.
Extended reasoning...

Overview

Changes Blob::from_dom_form_data to return Result<Blob, bun_sys::Error> rather than throwing on the global and returning an empty blob. FormDataContext swaps its failed: bool + global_this fields for a single read_error: Option<bun_sys::Error>; the joiner cleanup on failure is unchanged. The two callers then choose the error class: Body::Value::from_js (Request/Response constructors, HTMLRewriter) throws it via SysErrorJsc::throw — the same plain Error as before — while HTTPRequestBody::from_js (both fetch() call forms) intercepts FormData ahead of BodyValue::from_js and throws it via SystemError::to_type_error_instance.

Security risks

None. This changes only which JS error prototype a rejection is constructed with; no new input parsing, no allocation-size arithmetic, no auth/crypto surface.

Level of scrutiny

Low-to-medium. The refactor from throw-in-helper to return-error-let-caller-decide is mechanical; the one design choice (TypeError for fetch, plain Error for constructors) is well-argued against the fetch spec, Node behavior, and Bun's own #35855 precedent, and the sibling PR #37501 makes the same split for the bare Bun.file() body. I traced every caller of from_dom_form_data (exactly two) and every caller of BodyValue::from_js / body::extract to confirm the Ok(empty)+pending-exceptionErr change is behavior-preserving — Request.rs handles both via bail!, Response/HTMLRewriter via ?.

Other factors

Four new tests cover the variant matrix (both fetch overloads, ENOENT from open vs EISDIR from read, and a negative-contract test pinning constructor behavior). The success path in HTTPRequestBody::from_js short-circuits to AnyBlob::Blob(blob), which is exactly what the old Value::Blobuse_as_any_blob() fallthrough produced. The removed global_this field on FormDataContext had no other reader. No prior human review comments to address.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:59 AM PT - Aug 11th, 2026

@robobun, your commit 0e4fcc24cd76883bc2c7a2de3b32cae2f5fb1309 passed in Build #92283! 🎉


🧪   To try this PR locally:

bunx bun-pr 37514

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

bun-37514 --bun

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.

1 participant