From 85c06707e92808f76999139cd954aa5fdb99e68a Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 3 Jul 2026 10:49:39 +0100 Subject: [PATCH 1/3] fix: prevent duplicated UpdateFunction calls --- src/apiv2.spec.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/apiv2.ts | 7 ++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/apiv2.spec.ts b/src/apiv2.spec.ts index b33041fca02..ba958604439 100644 --- a/src/apiv2.spec.ts +++ b/src/apiv2.spec.ts @@ -183,6 +183,47 @@ describe("apiv2", () => { expect(nock.isDone()).to.be.true; }); + it("should not retry a PATCH request after a premature close error", async () => { + nock("https://example.com").patch("/path/to/foo").once().replyWithError({ + message: + "Invalid response body while trying to fetch https://example.com/path/to/foo: Premature close", + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + + const c = new Client({ urlPrefix: "https://example.com" }); + const r = c.request({ + method: "PATCH", + path: "/path/to/foo", + body: { foo: "bar" }, + retries: 1, + retryMinTimeout: 10, + retryMaxTimeout: 15, + }); + await expect(r).to.eventually.be.rejectedWith(FirebaseError); + expect(nock.isDone()).to.be.true; + }); + + it("should still retry a POST request after a premature close error", async () => { + nock("https://example.com").post("/path/to/foo").once().replyWithError({ + message: + "Invalid response body while trying to fetch https://example.com/path/to/foo: Premature close", + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + nock("https://example.com").post("/path/to/foo").once().reply(200, { ok: true }); + + const c = new Client({ urlPrefix: "https://example.com" }); + const r = await c.request({ + method: "POST", + path: "/path/to/foo", + body: { foo: "bar" }, + retries: 1, + retryMinTimeout: 10, + retryMaxTimeout: 15, + }); + expect(r.status).to.equal(200); + expect(nock.isDone()).to.be.true; + }); + it("should not allow resolving on http error when streaming", async () => { const c = new Client({ urlPrefix: "https://example.com" }); const r = c.request({ diff --git a/src/apiv2.ts b/src/apiv2.ts index a478afa264d..47bccf99450 100644 --- a/src/apiv2.ts +++ b/src/apiv2.ts @@ -172,6 +172,10 @@ export function noKeepAliveAgent(parsedURL: URL): http.Agent | https.Agent { return parsedURL.protocol === "https:" ? httpsAgentNoKeepAlive : httpAgentNoKeepAlive; } +function isIdempotentMethod(method: HttpMethod): boolean { + return ["GET", "HEAD", "OPTIONS", "DELETE", "PUT", "POST"].includes(method); +} + function isPrematureCloseError(err: unknown): boolean { for (const candidate of [err, (err as { original?: unknown } | undefined)?.original]) { if (!candidate) { @@ -545,7 +549,8 @@ export class Client { !disabledKeepAlive && bodyReplayable && !proxyURIFromEnv() && - isPrematureCloseError(err) + isPrematureCloseError(err) && + isIdempotentMethod(options.method) ) { disabledKeepAlive = true; fetchOptions.agent = noKeepAliveAgent; From 5edcc23de796712a9867bb943c63b3169505d32e Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 3 Jul 2026 13:30:51 +0100 Subject: [PATCH 2/3] fix: use a flag instead of changing behaviour based on http method --- src/apiv2.spec.ts | 32 ++++++++++++++++++++------------ src/apiv2.ts | 8 +++----- src/gcp/cloudfunctions.ts | 1 + src/gcp/cloudfunctionsv2.ts | 2 +- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/apiv2.spec.ts b/src/apiv2.spec.ts index ba958604439..c5b027abf96 100644 --- a/src/apiv2.spec.ts +++ b/src/apiv2.spec.ts @@ -183,27 +183,35 @@ describe("apiv2", () => { expect(nock.isDone()).to.be.true; }); - it("should not retry a PATCH request after a premature close error", async () => { + it("should not retry when retryOnPrematureClose is false", async () => { nock("https://example.com").patch("/path/to/foo").once().replyWithError({ message: "Invalid response body while trying to fetch https://example.com/path/to/foo: Premature close", code: "ERR_STREAM_PREMATURE_CLOSE", }); + // If a retry fires, this second interceptor would be consumed + const retryScope = nock("https://example.com") + .patch("/path/to/foo") + .once() + .reply(200, { ok: true }); const c = new Client({ urlPrefix: "https://example.com" }); - const r = c.request({ - method: "PATCH", - path: "/path/to/foo", - body: { foo: "bar" }, - retries: 1, - retryMinTimeout: 10, - retryMaxTimeout: 15, - }); - await expect(r).to.eventually.be.rejectedWith(FirebaseError); - expect(nock.isDone()).to.be.true; + await expect( + c.request({ + method: "PATCH", + path: "/path/to/foo", + body: { foo: "bar" }, + retries: 1, + retryMinTimeout: 10, + retryMaxTimeout: 15, + retryOnPrematureClose: false, + }), + ).to.eventually.be.rejectedWith(FirebaseError); + expect(retryScope.isDone()).to.be.false; // proves no retry occurred + nock.cleanAll(); }); - it("should still retry a POST request after a premature close error", async () => { + it("should retry a request by default after a premature close error", async () => { nock("https://example.com").post("/path/to/foo").once().replyWithError({ message: "Invalid response body while trying to fetch https://example.com/path/to/foo: Premature close", diff --git a/src/apiv2.ts b/src/apiv2.ts index 47bccf99450..4ce5eb12bf8 100644 --- a/src/apiv2.ts +++ b/src/apiv2.ts @@ -97,6 +97,8 @@ interface ClientHandlingOptions { retryMinTimeout?: number; /** Maximum timeout between retries. Defaults to 5s. */ retryMaxTimeout?: number; + /** Set to false to disable the premature-close keep-alive retry for this request. Defaults to true. */ + retryOnPrematureClose?: boolean; } export type ClientRequestOptions = RequestOptions & ClientVerbOptions; @@ -172,10 +174,6 @@ export function noKeepAliveAgent(parsedURL: URL): http.Agent | https.Agent { return parsedURL.protocol === "https:" ? httpsAgentNoKeepAlive : httpAgentNoKeepAlive; } -function isIdempotentMethod(method: HttpMethod): boolean { - return ["GET", "HEAD", "OPTIONS", "DELETE", "PUT", "POST"].includes(method); -} - function isPrematureCloseError(err: unknown): boolean { for (const candidate of [err, (err as { original?: unknown } | undefined)?.original]) { if (!candidate) { @@ -550,7 +548,7 @@ export class Client { bodyReplayable && !proxyURIFromEnv() && isPrematureCloseError(err) && - isIdempotentMethod(options.method) + options.retryOnPrematureClose !== false ) { disabledKeepAlive = true; fetchOptions.agent = noKeepAliveAgent; diff --git a/src/gcp/cloudfunctions.ts b/src/gcp/cloudfunctions.ts index ff766ef5189..fce3a5950fa 100644 --- a/src/gcp/cloudfunctions.ts +++ b/src/gcp/cloudfunctions.ts @@ -412,6 +412,7 @@ export async function updateFunction( queryParams: { updateMask: fieldMasks.join(","), }, + retryOnPrematureClose: false, }, ); return { diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index 1955983d731..0c42dcd7ff2 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -415,7 +415,7 @@ export async function updateFunction(cloudFunction: InputCloudFunction): Promise const res = await client.patch( cloudFunction.name, cloudFunction, - { queryParams }, + { queryParams, retryOnPrematureClose: false }, ); return res.body; } catch (err: any) { From e8a65fceb0a49053034f9d630e8a47763df65083 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 1 Oct 2026 15:05:14 +0100 Subject: [PATCH 3/3] fix: skip premature-close retry for function create and delete --- src/gcp/cloudfunctions.ts | 3 ++- src/gcp/cloudfunctionsv2.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/gcp/cloudfunctions.ts b/src/gcp/cloudfunctions.ts index fce3a5950fa..27faa0a7136 100644 --- a/src/gcp/cloudfunctions.ts +++ b/src/gcp/cloudfunctions.ts @@ -244,6 +244,7 @@ export async function createFunction( const res = await client.post, CloudFunction>( endpoint, cloudFunction, + { retryOnPrematureClose: false }, ); return { name: res.body.name, @@ -432,7 +433,7 @@ export async function updateFunction( export async function deleteFunction(name: string): Promise { const endpoint = `/${name}`; try { - const res = await client.delete(endpoint); + const res = await client.delete(endpoint, { retryOnPrematureClose: false }); return { done: false, name: res.body.name, diff --git a/src/gcp/cloudfunctionsv2.ts b/src/gcp/cloudfunctionsv2.ts index d96080ea89d..c4f9f0cfb3e 100644 --- a/src/gcp/cloudfunctionsv2.ts +++ b/src/gcp/cloudfunctionsv2.ts @@ -321,7 +321,7 @@ export async function createFunction(cloudFunction: InputCloudFunction): Promise const res = await client.post( components.join("/"), cloudFunction, - { queryParams: { functionId } }, + { queryParams: { functionId }, retryOnPrematureClose: false }, ); return res.body; } catch (err: any) { @@ -429,7 +429,7 @@ export async function updateFunction(cloudFunction: InputCloudFunction): Promise */ export async function deleteFunction(cloudFunction: string): Promise { try { - const res = await client.delete(cloudFunction); + const res = await client.delete(cloudFunction, { retryOnPrematureClose: false }); return res.body; } catch (err: any) { throw functionsOpLogReject({ name: cloudFunction } as InputCloudFunction, "update", err);