diff --git a/src/deploy/functions/release/fabricator.spec.ts b/src/deploy/functions/release/fabricator.spec.ts index 6dd5d1cb02d..383762a71cc 100644 --- a/src/deploy/functions/release/fabricator.spec.ts +++ b/src/deploy/functions/release/fabricator.spec.ts @@ -1137,6 +1137,30 @@ 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("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( + reporter.DeploymentError, + "delete schedule", + ); + }); }); describe("deleteScheduleV2", () => { @@ -1162,6 +1186,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..7afd8571dee 100644 --- a/src/deploy/functions/release/fabricator.ts +++ b/src/deploy/functions/release/fabricator.ts @@ -5,6 +5,7 @@ import { isCloudRunResourceExhausted, isServiceAccount404, isTransientError, + parseErrorCode, } from "./executor"; import { FirebaseError } from "../../../error"; @@ -81,6 +82,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 (parseErrorCode(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 +1095,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 {