Skip to content

gaxios + googleapis-common: a request body stream that errors crashes the process (unhandled 'error' event) #9383

Description

@bakissation

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', () => {});

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions