fetch: mark body disturbed when a reader fails; reject network errors as TypeError
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); }
| before | node | after | |
|---|---|---|---|
first text() | Error (instanceof TypeError === false) | TypeError: terminated | TypeError (with .code === "ECONNRESET") |
bodyUsed after failed read | false | true | true |
second text() | re-rejects with the socket error | TypeError: Body is unusable | TypeError: 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_instancerecords whether a reader had already begun before swapping inValue::Error; after rejecting the pending promise, erroring any readable, and notifyingon_receive_value, it leaves the body inValue::Usedso subsequent reads hitERR_BODY_ALREADY_USED.handle_body_error(body already failed before any reader was called) now transitionsErrortoUsedafter producing the rejection, since calling a reader is itself the disturbing read.- New
ValueError::SystemTypeError(SystemError)variant surfaces as a JSTypeErrorwhile keeping.code/.path/.syscall/.hostname;FetchTasklet::on_rejectreturns it for every fetch network error..code === "ECONNRESET"/"ENOTFOUND"/"ZlibError"etc. are preserved. bindings.cppgainsSystemError__toTypeErrorInstance(shared body with the existingErrorpath, parameterised onErrorType).
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).