From 6f035c74d72cae9cd2123155a25ef6da3775e3e8 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 3 Aug 2026 20:03:16 +0100 Subject: [PATCH 1/2] fix: tolerate 404 when deleting scheduled function's schedule --- .../functions/release/fabricator.spec.ts | 30 +++++++++++++++++++ src/deploy/functions/release/fabricator.ts | 20 ++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/deploy/functions/release/fabricator.spec.ts b/src/deploy/functions/release/fabricator.spec.ts index cb07a2edd55..2aedb7f5398 100644 --- a/src/deploy/functions/release/fabricator.spec.ts +++ b/src/deploy/functions/release/fabricator.spec.ts @@ -1137,6 +1137,22 @@ describe("Fabricator", () => { "delete topic", ); }); + + it("ignores a 404 when the schedule or topic is already deleted", async () => { + scheduler.deleteJob.rejects(new FirebaseError("Job not found.", { status: 404 })); + pubsub.deleteTopic.rejects(new FirebaseError("Topic not found.", { status: 404 })); + await expect(fab.deleteScheduleV1(ep)).to.eventually.be.fulfilled; + expect(scheduler.deleteJob).to.have.been.called; + expect(pubsub.deleteTopic).to.have.been.called; + }); + + it("still wraps non-404 errors", async () => { + scheduler.deleteJob.rejects(new FirebaseError("Permission denied.", { status: 403 })); + await expect(fab.deleteScheduleV1(ep)).to.eventually.be.rejectedWith( + reporter.DeploymentError, + "delete schedule", + ); + }); }); describe("deleteScheduleV2", () => { @@ -1162,6 +1178,20 @@ describe("Fabricator", () => { "delete schedule", ); }); + + it("ignores a 404 when the schedule is already deleted", async () => { + scheduler.deleteJob.rejects(new FirebaseError("Job not found.", { status: 404 })); + await expect(fab.deleteScheduleV2(ep)).to.eventually.be.fulfilled; + expect(scheduler.deleteJob).to.have.been.called; + }); + + it("still wraps non-404 errors", async () => { + scheduler.deleteJob.rejects(new FirebaseError("Permission denied.", { status: 403 })); + await expect(fab.deleteScheduleV2(ep)).to.eventually.be.rejectedWith( + reporter.DeploymentError, + "delete schedule", + ); + }); }); describe("upsertTaskQueue", () => { diff --git a/src/deploy/functions/release/fabricator.ts b/src/deploy/functions/release/fabricator.ts index 16388baaa95..c7a49700cc2 100644 --- a/src/deploy/functions/release/fabricator.ts +++ b/src/deploy/functions/release/fabricator.ts @@ -6,7 +6,7 @@ import { isServiceAccount404, isTransientError, } from "./executor"; -import { FirebaseError } from "../../../error"; +import { FirebaseError, getErrStatus } from "../../../error"; import { SourceTokenScraper } from "./sourceTokenScraper"; import { Timer } from "./timer"; @@ -81,6 +81,18 @@ const rethrowAs = throw new reporter.DeploymentError(endpoint, op, err); }; +// A 404 while deleting means the resource is already gone — the desired end +// state — so treat it as success rather than failing the deployment. See #4795. +const rethrowAsUnlessNotFound = + (endpoint: backend.Endpoint, op: reporter.OperationType) => + (err: unknown): T | void => { + if (getErrStatus(err) === 404) { + logger.debug(`Ignoring 404 for ${op} on ${endpoint.id}; resource already deleted.`); + return; + } + return rethrowAs(endpoint, op)(err); + }; + /** Fabricators make a customer's backend match a spec by applying a plan. */ export class Fabricator { executor: Executor; @@ -1082,19 +1094,19 @@ export class Fabricator { const jobName = scheduler.jobNameForEndpoint(endpoint, this.appEngineLocation); await this.executor .run(() => scheduler.deleteJob(jobName)) - .catch(rethrowAs(endpoint, "delete schedule")); + .catch(rethrowAsUnlessNotFound(endpoint, "delete schedule")); const topicName = scheduler.topicNameForEndpoint(endpoint); await this.executor .run(() => pubsub.deleteTopic(topicName)) - .catch(rethrowAs(endpoint, "delete topic")); + .catch(rethrowAsUnlessNotFound(endpoint, "delete topic")); } async deleteScheduleV2(endpoint: backend.Endpoint & backend.ScheduleTriggered): Promise { const jobName = scheduler.jobNameForEndpoint(endpoint, endpoint.region); await this.executor .run(() => scheduler.deleteJob(jobName)) - .catch(rethrowAs(endpoint, "delete schedule")); + .catch(rethrowAsUnlessNotFound(endpoint, "delete schedule")); } async disableTaskQueue(endpoint: backend.Endpoint & backend.TaskQueueTriggered): Promise { From 1d53af2c4b1ab9c35a76b81103f20e43478cdc16 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 3 Aug 2026 20:17:07 +0100 Subject: [PATCH 2/2] fix: use parseErrorCode to catch raw GCP 404s on delete --- src/deploy/functions/release/fabricator.spec.ts | 8 ++++++++ src/deploy/functions/release/fabricator.ts | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/deploy/functions/release/fabricator.spec.ts b/src/deploy/functions/release/fabricator.spec.ts index 2aedb7f5398..e93c8e0ee3e 100644 --- a/src/deploy/functions/release/fabricator.spec.ts +++ b/src/deploy/functions/release/fabricator.spec.ts @@ -1146,6 +1146,14 @@ describe("Fabricator", () => { expect(pubsub.deleteTopic).to.have.been.called; }); + it("ignores a raw GCP 404 where the status is on err.code", async () => { + scheduler.deleteJob.rejects(Object.assign(new Error("Job not found."), { code: 404 })); + pubsub.deleteTopic.rejects(Object.assign(new Error("Topic not found."), { code: 404 })); + await expect(fab.deleteScheduleV1(ep)).to.eventually.be.fulfilled; + expect(scheduler.deleteJob).to.have.been.called; + expect(pubsub.deleteTopic).to.have.been.called; + }); + it("still wraps non-404 errors", async () => { scheduler.deleteJob.rejects(new FirebaseError("Permission denied.", { status: 403 })); await expect(fab.deleteScheduleV1(ep)).to.eventually.be.rejectedWith( diff --git a/src/deploy/functions/release/fabricator.ts b/src/deploy/functions/release/fabricator.ts index c7a49700cc2..7afd8571dee 100644 --- a/src/deploy/functions/release/fabricator.ts +++ b/src/deploy/functions/release/fabricator.ts @@ -5,8 +5,9 @@ import { isCloudRunResourceExhausted, isServiceAccount404, isTransientError, + parseErrorCode, } from "./executor"; -import { FirebaseError, getErrStatus } from "../../../error"; +import { FirebaseError } from "../../../error"; import { SourceTokenScraper } from "./sourceTokenScraper"; import { Timer } from "./timer"; @@ -86,7 +87,7 @@ const rethrowAs = const rethrowAsUnlessNotFound = (endpoint: backend.Endpoint, op: reporter.OperationType) => (err: unknown): T | void => { - if (getErrStatus(err) === 404) { + if (parseErrorCode(err) === 404) { logger.debug(`Ignoring 404 for ${op} on ${endpoint.id}; resource already deleted.`); return; }