@@ -5,6 +5,8 @@ import { ProxyAgent } from "proxy-agent";
55import * as retry from "retry" ;
66import AbortController from "abort-controller" ;
77import fetch , { HeadersInit , Response , RequestInit , Headers } from "node-fetch" ;
8+ import * as http from "http" ;
9+ import * as https from "https" ;
810import util from "util" ;
911
1012import * as auth from "./auth" ;
@@ -157,6 +159,39 @@ function proxyURIFromEnv(): string | undefined {
157159 ) ;
158160}
159161
162+ // Some networks (and recent Node.js security releases) interact badly with
163+ // node-fetch's keep-alive handling: a reused/closed keep-alive socket surfaces
164+ // as an "Invalid response body ...: Premature close" error while the response
165+ // body is being read, even though the response is otherwise valid. Retrying the
166+ // request once without keep-alive (Connection: close) reliably works around it.
167+ // See https://github.com/firebase/firebase-tools/issues/10692 and
168+ // https://github.com/node-fetch/node-fetch/issues/1767.
169+ const httpAgentNoKeepAlive = new http . Agent ( { keepAlive : false } ) ;
170+ const httpsAgentNoKeepAlive = new https . Agent ( { keepAlive : false } ) ;
171+ export function noKeepAliveAgent ( parsedURL : URL ) : http . Agent | https . Agent {
172+ return parsedURL . protocol === "https:" ? httpsAgentNoKeepAlive : httpAgentNoKeepAlive ;
173+ }
174+
175+ function isPrematureCloseError ( err : unknown ) : boolean {
176+ for ( const candidate of [ err , ( err as { original ?: unknown } | undefined ) ?. original ] ) {
177+ if ( ! candidate ) {
178+ continue ;
179+ }
180+ const e = candidate as { code ?: string ; errno ?: string ; message ?: string } ;
181+ const code = e . code || e . errno ;
182+ const message = typeof e . message === "string" ? e . message : "" ;
183+ if (
184+ code === "ERR_STREAM_PREMATURE_CLOSE" ||
185+ code === "ECONNRESET" ||
186+ / p r e m a t u r e c l o s e / i. test ( message ) ||
187+ / s o c k e t h a n g u p / i. test ( message )
188+ ) {
189+ return true ;
190+ }
191+ }
192+ return false ;
193+ }
194+
160195export type ClientOptions = {
161196 urlPrefix : string ;
162197 apiVersion ?: string ;
@@ -408,8 +443,18 @@ export class Client {
408443 fetchOptions . signal = signal ;
409444 }
410445
411- if ( typeof options . body === "string" || isStream ( options . body ) ) {
446+ // A request can only be safely retried if its body can be sent again.
447+ // FormData is a single-use stream, so serialize it to a Buffer up front (the
448+ // CLI only sends small string forms this way, e.g. the OAuth token request).
449+ // Raw streams cannot be replayed and therefore disable the keep-alive retry.
450+ let bodyReplayable = true ;
451+ if ( typeof options . body === "string" || Buffer . isBuffer ( options . body ) ) {
452+ fetchOptions . body = options . body ;
453+ } else if ( options . body instanceof FormData ) {
454+ fetchOptions . body = options . body . getBuffer ( ) ;
455+ } else if ( isStream ( options . body ) ) {
412456 fetchOptions . body = options . body ;
457+ bodyReplayable = false ;
413458 } else if ( options . body !== undefined ) {
414459 fetchOptions . body = JSON . stringify ( options . body ) ;
415460 }
@@ -431,6 +476,10 @@ export class Client {
431476 }
432477 const operation = retry . operation ( operationOptions ) ;
433478
479+ // Tracks whether we've already downgraded this request to a non-keep-alive
480+ // connection in response to a "Premature close" error (retried at most once).
481+ let disabledKeepAlive = false ;
482+
434483 return await new Promise < ClientResponse < ResT > > ( ( resolve , reject ) => {
435484 // eslint-disable-next-line @typescript-eslint/no-misused-promises
436485 operation . attempt ( async ( currentAttempt ) : Promise < void > => {
@@ -490,6 +539,27 @@ export class Client {
490539 } ) ;
491540 }
492541 } catch ( err : unknown ) {
542+ // Work around a node-fetch/Node.js keep-alive bug that surfaces as a
543+ // "Premature close" error: retry the request once without keep-alive.
544+ if (
545+ ! disabledKeepAlive &&
546+ bodyReplayable &&
547+ ! proxyURIFromEnv ( ) &&
548+ isPrematureCloseError ( err )
549+ ) {
550+ disabledKeepAlive = true ;
551+ fetchOptions . agent = noKeepAliveAgent ;
552+ // Use a fresh Headers instance so we don't mutate the shared options.
553+ const closeHeaders = new Headers ( fetchOptions . headers ) ;
554+ closeHeaders . set ( "Connection" , "close" ) ;
555+ fetchOptions . headers = closeHeaders ;
556+ logger . debug (
557+ `*** [apiv2] retrying ${ fetchURL } without keep-alive after a premature close error` ,
558+ ) ;
559+ if ( operation . retry ( err instanceof Error ? err : new Error ( `${ err } ` ) ) ) {
560+ return ;
561+ }
562+ }
493563 return err instanceof FirebaseError ? reject ( err ) : reject ( new FirebaseError ( `${ err } ` ) ) ;
494564 }
495565
0 commit comments