From b6b050714dcecfc9bb741a3cdc84c4d21c8efda2 Mon Sep 17 00:00:00 2001 From: HelloWNW Date: Thu, 6 Aug 2026 12:36:01 +0000 Subject: [PATCH 1/2] fix: prefer native fetch over node-fetch in Node.js node-fetch can intermittently reject requests with `Premature close`, notably POSTs to the OAuth2 token endpoint. Native fetch is unaffected and has been stable since Node 18, this package's minimum supported version. node-fetch remains a fallback when no global fetch exists. Two differences between the implementations are normalized so the default path behaves as before: - responseType: 'stream' converts the ReadableStream back to a stream.Readable. A caller-provided fetchImplementation is untouched. - A non-Error AbortSignal reason is propagated instead of being replaced with a generic message. --- core/packages/gaxios/src/gaxios.ts | 25 +++++++++++++++++++++++-- core/packages/gaxios/test/test.getch.ts | 5 +---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/core/packages/gaxios/src/gaxios.ts b/core/packages/gaxios/src/gaxios.ts index c2854de06661..894a131b3279 100644 --- a/core/packages/gaxios/src/gaxios.ts +++ b/core/packages/gaxios/src/gaxios.ts @@ -147,6 +147,8 @@ export class Gaxios implements FetchCompliance { private async _defaultAdapter( config: GaxiosOptionsPrepared, ): Promise> { + const usingInternalFetch = + !config.fetchImplementation && !this.defaults.fetchImplementation; const fetchImpl = config.fetchImplementation || this.defaults.fetchImplementation || @@ -158,7 +160,7 @@ export class Gaxios implements FetchCompliance { delete preparedOpts.data; const res = (await fetchImpl(config.url, preparedOpts as {})) as Response; - const data = await this.getResponseData(config, res); + const data = await this.getResponseData(config, res, usingInternalFetch); if (!Object.getOwnPropertyDescriptor(res, 'data')?.configurable) { // Work-around for `node-fetch` v3 as accessing `data` would otherwise throw @@ -227,6 +229,10 @@ export class Gaxios implements FetchCompliance { err = e; } else if (e instanceof Error) { err = new GaxiosError(e.message, opts, undefined, e); + } else if (typeof e === 'string') { + // Native `fetch` rejects with the `AbortSignal`'s reason as-is, which + // is not necessarily an `Error`. + err = new GaxiosError(e, opts, undefined, e); } else { err = new GaxiosError('Unexpected Gaxios Error', opts, undefined, e); } @@ -257,6 +263,7 @@ export class Gaxios implements FetchCompliance { private async getResponseData( opts: GaxiosOptionsPrepared, res: Response, + usingInternalFetch = false, ): Promise> { if (res.status === HTTP_STATUS_NO_CONTENT) { return ''; @@ -277,6 +284,14 @@ export class Gaxios implements FetchCompliance { switch (opts.responseType) { case 'stream': + if (usingInternalFetch && res.body && !(res.body instanceof Readable)) { + // Native `fetch` resolves a `ReadableStream`, so convert it to retain + // the `stream.Readable` contract. A caller-provided + // `fetchImplementation` keeps its own body type. + return Readable.fromWeb( + res.body as unknown as import('stream/web').ReadableStream, + ); + } return res.body; case 'json': { const data = await res.text(); @@ -671,10 +686,16 @@ export class Gaxios implements FetchCompliance { static async #getFetch() { const hasWindow = typeof window !== 'undefined' && !!window; + const hasGlobalFetch = typeof globalThis.fetch === 'function'; + // Prefer native `fetch`, available since Node 18 - this package's minimum + // supported version. `node-fetch` can intermittently fail requests with + // `Premature close` errors and remains only as a fallback. this.#fetch ||= hasWindow ? window.fetch - : (await import('node-fetch')).default; + : hasGlobalFetch + ? globalThis.fetch + : (await import('node-fetch')).default; return this.#fetch; } diff --git a/core/packages/gaxios/test/test.getch.ts b/core/packages/gaxios/test/test.getch.ts index 62072b7897c8..bcd60b59e7e6 100644 --- a/core/packages/gaxios/test/test.getch.ts +++ b/core/packages/gaxios/test/test.getch.ts @@ -863,10 +863,7 @@ describe('🥁 configuration options', () => { await assert.rejects( () => gaxios.request({url, timeout, signal}), - // `node-fetch` always rejects with the generic 'abort' error: - /abort/, - // native `fetch` matches the error properly: - // new RegExp(message) + new RegExp(message), ); }); }); From e8ec31e550f824ce8c69e19971342541ede06c6d Mon Sep 17 00:00:00 2001 From: HelloWNW Date: Thu, 6 Aug 2026 13:38:01 +0000 Subject: [PATCH 2/2] fix: guard stream conversion for browser environments Readable.fromWeb is unavailable in browser bundles, where stream is stubbed or polyfilled by stream-browserify, and Readable itself may be undefined. Detect a web stream by its getReader method and confirm fromWeb exists before converting, so browsers keep resolving a ReadableStream as they did previously. --- core/packages/gaxios/src/gaxios.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/core/packages/gaxios/src/gaxios.ts b/core/packages/gaxios/src/gaxios.ts index 894a131b3279..6e9c7d004411 100644 --- a/core/packages/gaxios/src/gaxios.ts +++ b/core/packages/gaxios/src/gaxios.ts @@ -283,16 +283,29 @@ export class Gaxios implements FetchCompliance { } switch (opts.responseType) { - case 'stream': - if (usingInternalFetch && res.body && !(res.body instanceof Readable)) { - // Native `fetch` resolves a `ReadableStream`, so convert it to retain - // the `stream.Readable` contract. A caller-provided - // `fetchImplementation` keeps its own body type. + case 'stream': { + // Native `fetch` resolves a `ReadableStream`, so convert it to retain + // the `stream.Readable` contract. Browser bundles stub or polyfill + // `stream` without `fromWeb` and have always resolved a + // `ReadableStream`, so the conversion is skipped there. A + // caller-provided `fetchImplementation` keeps its own body type. + const body = res.body as + | (ReadableStream & {getReader?: unknown}) + | null; + const isWebStream = typeof body?.getReader === 'function'; + + if ( + usingInternalFetch && + isWebStream && + typeof Readable?.fromWeb === 'function' + ) { return Readable.fromWeb( - res.body as unknown as import('stream/web').ReadableStream, + body as unknown as import('stream/web').ReadableStream, ); } + return res.body; + } case 'json': { const data = await res.text(); try {