Affects core/packages/gaxios (7.3.1) and core/packages/nodejs-googleapis-common (9.1.0), Node 22.14. Filed here since both packages moved into this monorepo (the standalone gaxios repo is archived).
A Node Readable passed as a request body that emits 'error' before it is consumed fires the event with no listener attached, and Node terminates the process. The everyday trigger is fs.createReadStream(path) on an unopenable path (ENOENT/EACCES): it returns synchronously and reports the failure as an async event. In a long-lived server, one bad path kills every concurrent request — we hit this in production via a Drive media upload.
There are two independent unhandled windows:
A. gaxios: stream data before dispatch
import { request } from 'gaxios';
import fs from 'node:fs';
try {
await request({ url: 'https://example.com/', method: 'POST', data: fs.createReadStream('/nonexistent/file.bin') });
} catch (e) {
console.log('rejected cleanly:', e.code); // never reached
}
// process exits 1: Unhandled 'error' event ... ENOENT
Expected: the promise rejects with the ENOENT. Observed: process crash. Notably node-fetch handles the same stream correctly when called directly (fetch(url, { method: 'POST', body: fs.createReadStream('/nonexistent') }) rejects cleanly and the process survives), so the missing listener is in gaxios's own window before the fetch backend takes ownership.
B. googleapis-common: the multipart upload pipe chain
apirequest.ts multipartUpload() wires the caller's media body as:
part.body.pipe(pStream).pipe(rStream);
.pipe() does not propagate 'error' events, so (1) an error on part.body is unhandled → process crash, and (2) even if it were handled, rStream (what the request actually sends) never learns about it, so the request could not fail cleanly either. Mechanism in isolation:
import fs from 'node:fs';
import { PassThrough } from 'node:stream';
const src = fs.createReadStream('/nonexistent/file.bin');
const p = new PassThrough(), r = new PassThrough();
src.pipe(p).pipe(r); r.resume();
// process exits 1: Unhandled 'error' event ... ENOENT
This is the path any googleapis/@googleapis/* media upload takes (e.g. drive.files.create with media.body a file stream), which is how it surfaced for us.
Suggested fix
- gaxios: attach an
'error' listener when accepting a stream data and reject the in-flight request promise with the error.
- googleapis-common: in
multipartUpload(), forward errors down the chain, e.g. part.body.on('error', err => rStream.destroy(err)) (and the same for pStream), so the request rejects instead of the process dying.
Workaround for users
Open the fd first so the open-failure class throws inside try/catch, and keep a listener for late errors:
const handle = await fs.promises.open(path, 'r');
const stream = handle.createReadStream();
stream.on('error', () => {});
Affects
core/packages/gaxios(7.3.1) andcore/packages/nodejs-googleapis-common(9.1.0), Node 22.14. Filed here since both packages moved into this monorepo (the standalone gaxios repo is archived).A Node
Readablepassed as a request body that emits'error'before it is consumed fires the event with no listener attached, and Node terminates the process. The everyday trigger isfs.createReadStream(path)on an unopenable path (ENOENT/EACCES): it returns synchronously and reports the failure as an async event. In a long-lived server, one bad path kills every concurrent request — we hit this in production via a Drive media upload.There are two independent unhandled windows:
A. gaxios: stream
databefore dispatchExpected: the promise rejects with the ENOENT. Observed: process crash. Notably node-fetch handles the same stream correctly when called directly (
fetch(url, { method: 'POST', body: fs.createReadStream('/nonexistent') })rejects cleanly and the process survives), so the missing listener is in gaxios's own window before the fetch backend takes ownership.B. googleapis-common: the multipart upload pipe chain
apirequest.tsmultipartUpload()wires the caller's media body as:.pipe()does not propagate'error'events, so (1) an error onpart.bodyis unhandled → process crash, and (2) even if it were handled,rStream(what the request actually sends) never learns about it, so the request could not fail cleanly either. Mechanism in isolation:This is the path any
googleapis/@googleapis/*media upload takes (e.g.drive.files.createwithmedia.bodya file stream), which is how it surfaced for us.Suggested fix
'error'listener when accepting a streamdataand reject the in-flight request promise with the error.multipartUpload(), forward errors down the chain, e.g.part.body.on('error', err => rStream.destroy(err))(and the same forpStream), so the request rejects instead of the process dying.Workaround for users
Open the fd first so the open-failure class throws inside try/catch, and keep a listener for late errors: