fetch: mark body disturbed when a reader fails; reject network errors as TypeError

Merged
RO
3mo ago
+178-34

When a fetch() response body is being consumed and the underlying download fails mid-body (socket closed before Content-Length is satisfied, decompression error, ...):

import net from "node:net";
const srv = net.createServer(s =>
  s.end("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n" + "x".repeat(1000))
).listen(0, "127.0.0.1");
await once(srv, "listening");
const r = await fetch(`http://127.0.0.1:${srv.address().port}/`);
try { await r.text(); } catch (e) { console.log(e.constructor.name, e instanceof TypeError); }
console.log(r.bodyUsed);
try { await r.text(); } catch (e) { console.log(e.message); }
beforenodeafter
first text()Error (instanceof TypeError === false)TypeError: terminatedTypeError (with .code === "ECONNRESET")
bodyUsed after failed readfalsetruetrue
second text()re-rejects with the socket errorTypeError: Body is unusableTypeError: Body already used

Cause

Value::to_error_instance moved a Locked body straight to Value::Error, dropping the "a read was started" bit (action/promise). body_stream_check has no Value::Error arm, so bodyUsed fell through to false, and every reader re-entered handle_body_error and rejected with the stored error again.

Separately, FetchTasklet::on_reject produced the network error as ValueError::SystemError, which SystemError__toErrorInstance materialises as a plain Error. The fetch spec maps every network error to TypeError; node/undici match that.

Fix

  • Value::to_error_instance records whether a reader had already begun before swapping in Value::Error; after rejecting the pending promise, erroring any readable, and notifying on_receive_value, it leaves the body in Value::Used so subsequent reads hit ERR_BODY_ALREADY_USED.
  • handle_body_error (body already failed before any reader was called) now transitions Error to Used after producing the rejection, since calling a reader is itself the disturbing read.
  • New ValueError::SystemTypeError(SystemError) variant surfaces as a JS TypeError while keeping .code/.path/.syscall/.hostname; FetchTasklet::on_reject returns it for every fetch network error. .code === "ECONNRESET" / "ENOTFOUND" / "ZlibError" etc. are preserved.
  • bindings.cpp gains SystemError__toTypeErrorInstance (shared body with the existing Error path, parameterised on ErrorType).

Updated the handful of tests that asserted name === "Error" for fetch network errors to assert TypeError instead.


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


Related: #20486, #34397 (this change makes fetch network errors instanceof TypeError; it does not add the .cause wrapping those issues also request, so they are not fully closed here).