diff --git a/CHANGELOG.md b/CHANGELOG.md index 748659b90a7..5ee3d69a119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,3 @@ - Added `functions.list_functions` as a MCP tool (#9369) - Added AI Logic to `firebase init` CLI command and `firebase_init` MCP tool. (#9185) +- Improved error messages for Firebase AI Logic provisioning during 'firebase init' (#9377) diff --git a/src/management/provisioning/errorHandler.spec.ts b/src/management/provisioning/errorHandler.spec.ts new file mode 100644 index 00000000000..ecd6784984d --- /dev/null +++ b/src/management/provisioning/errorHandler.spec.ts @@ -0,0 +1,251 @@ +import { expect } from "chai"; +import { FirebaseError } from "../../error"; +import { enhanceProvisioningError } from "./errorHandler"; + +describe("errorHandler", () => { + describe("enhanceProvisioningError", () => { + it("should include ErrorInfo details in error message", () => { + const originalError = new FirebaseError("Permission denied", { + context: { + body: { + error: { + code: 403, + message: "The user has not accepted the terms of service.", + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: + "TOS_REQUIRED: The following ToS's must be accepted: [generative-language-api].", + domain: "firebase.googleapis.com", + }, + ], + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Failed to provision Firebase app"); + + expect(result).to.be.instanceOf(FirebaseError); + expect(result.message).to.include("Failed to provision Firebase app: Permission denied"); + expect(result.message).to.include("Error details:"); + expect(result.message).to.include( + "Reason: TOS_REQUIRED: The following ToS's must be accepted: [generative-language-api].", + ); + expect(result.message).to.include("Domain: firebase.googleapis.com"); + expect(result.exit).to.equal(2); + expect(result.original).to.equal(originalError); + }); + + it("should include HelpLinks in error message", () => { + const originalError = new FirebaseError("Permission denied", { + context: { + body: { + error: { + code: 403, + message: "The user has not accepted the terms of service.", + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.Help", + links: [ + { + description: "Link to accept Generative Language terms of service", + url: "https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com?authuser=0&forceCheckTos=true", + }, + ], + }, + ], + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Failed to provision Firebase app"); + + expect(result.message).to.include("For help resolving this issue:"); + expect(result.message).to.include("Link to accept Generative Language terms of service"); + expect(result.message).to.include( + "https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com?authuser=0&forceCheckTos=true", + ); + }); + + it("should include both ErrorInfo and HelpLinks in error message", () => { + const originalError = new FirebaseError("Permission denied", { + context: { + body: { + error: { + code: 403, + message: "The user has not accepted the terms of service.", + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: + "TOS_REQUIRED: The following ToS's must be accepted: [generative-language-api].", + domain: "firebase.googleapis.com", + }, + { + "@type": "type.googleapis.com/google.rpc.Help", + links: [ + { + description: "Link to accept Generative Language terms of service", + url: "https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com?authuser=0&forceCheckTos=true", + }, + ], + }, + ], + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Failed to provision Firebase app"); + + // Verify ErrorInfo is included + expect(result.message).to.include("Error details:"); + expect(result.message).to.include( + "Reason: TOS_REQUIRED: The following ToS's must be accepted: [generative-language-api].", + ); + expect(result.message).to.include("Domain: firebase.googleapis.com"); + + // Verify HelpLinks are included + expect(result.message).to.include("For help resolving this issue:"); + expect(result.message).to.include("Link to accept Generative Language terms of service"); + }); + + it("should include ErrorInfo with metadata in error message", () => { + const originalError = new FirebaseError("Invalid request", { + context: { + body: { + error: { + code: 400, + message: "Invalid request", + status: "INVALID_ARGUMENT", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "INVALID_FIELD", + domain: "firebase.googleapis.com", + metadata: { + field: "displayName", + constraint: "max_length", + }, + }, + ], + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Operation failed"); + + expect(result.message).to.include("Reason: INVALID_FIELD"); + expect(result.message).to.include("Domain: firebase.googleapis.com"); + expect(result.message).to.include("Additional Info:"); + expect(result.message).to.include("field"); + }); + + it("should include multiple help links in error message", () => { + const originalError = new FirebaseError("Multiple help links", { + context: { + body: { + error: { + code: 403, + message: "Permission denied", + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.Help", + links: [ + { + description: "First help link", + url: "https://example.com/help1", + }, + { + description: "Second help link", + url: "https://example.com/help2", + }, + ], + }, + ], + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Operation failed"); + + expect(result.message).to.include("First help link"); + expect(result.message).to.include("https://example.com/help1"); + expect(result.message).to.include("Second help link"); + expect(result.message).to.include("https://example.com/help2"); + }); + + it("should handle errors without details gracefully", () => { + const originalError = new FirebaseError("Firebase error without details", { + context: { + body: { + error: { + code: 400, + message: "Bad Request", + status: "INVALID_ARGUMENT", + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Operation failed"); + + expect(result).to.be.instanceOf(FirebaseError); + expect(result.message).to.equal("Operation failed: Firebase error without details"); + expect(result.exit).to.equal(2); + }); + + it("should handle non-Error types gracefully", () => { + const result = enhanceProvisioningError("String error", "Operation failed"); + + expect(result).to.be.instanceOf(FirebaseError); + expect(result.message).to.equal("Operation failed: String error"); + expect(result.exit).to.equal(2); + expect(result.original).to.be.instanceOf(Error); + }); + + it("should handle regular Error without context", () => { + const regularError = new Error("Regular error"); + + const result = enhanceProvisioningError(regularError, "Context message"); + + expect(result).to.be.instanceOf(FirebaseError); + expect(result.message).to.equal("Context message: Regular error"); + expect(result.original).to.equal(regularError); + }); + + it("should ignore unknown detail types", () => { + const originalError = new FirebaseError("Unknown detail type", { + context: { + body: { + error: { + code: 500, + message: "Internal error", + status: "INTERNAL", + details: [ + { + "@type": "type.googleapis.com/google.rpc.UnknownType", + someField: "someValue", + }, + ], + }, + }, + }, + }); + + const result = enhanceProvisioningError(originalError, "Operation failed"); + + expect(result.message).to.equal("Operation failed: Unknown detail type"); + expect(result.message).to.not.include("Error details:"); + expect(result.message).to.not.include("For help"); + }); + }); +}); diff --git a/src/management/provisioning/errorHandler.ts b/src/management/provisioning/errorHandler.ts new file mode 100644 index 00000000000..e60da66bc99 --- /dev/null +++ b/src/management/provisioning/errorHandler.ts @@ -0,0 +1,112 @@ +import { FirebaseError, getError } from "../../error"; + +/** + * Google RPC ErrorInfo structure + */ +interface ErrorInfo { + "@type": "type.googleapis.com/google.rpc.ErrorInfo"; + reason: string; + domain: string; + metadata?: Record; +} + +/** + * Google RPC Help structure with links + */ +interface HelpLinks { + "@type": "type.googleapis.com/google.rpc.Help"; + links: Array<{ + description: string; + url: string; + }>; +} + +/** + * Error detail can be ErrorInfo, HelpLinks, or other types + */ +type ErrorDetail = ErrorInfo | HelpLinks | Record; + +/** + * Provisioning API error structure + */ +interface ProvisioningError { + error: { + code: number; + message: string; + status: string; + details?: ErrorDetail[]; + }; +} + +/** + * Type guard for ErrorInfo + */ +function isErrorInfo(detail: ErrorDetail): detail is ErrorInfo { + return detail["@type"] === "type.googleapis.com/google.rpc.ErrorInfo"; +} + +/** + * Type guard for HelpLinks + */ +function isHelpLinks(detail: ErrorDetail): detail is HelpLinks { + return detail["@type"] === "type.googleapis.com/google.rpc.Help"; +} + +/** + * Extracts detailed error information from a provisioning API error response. + * Returns a formatted string with error details and help links. + */ +function extractErrorDetails(err: unknown): string { + if (!(err instanceof Error)) { + return ""; + } + + // Check if this is a FirebaseError with context containing provisioning error + if (err instanceof FirebaseError && err.context) { + const context = err.context as { body?: ProvisioningError }; + const errorBody = context.body?.error; + + if (errorBody?.details && Array.isArray(errorBody.details)) { + const parts: string[] = []; + + for (const detail of errorBody.details) { + if (isErrorInfo(detail)) { + parts.push(`Error details:`); + parts.push(` Reason: ${detail.reason}`); + parts.push(` Domain: ${detail.domain}`); + if (detail.metadata) { + parts.push(` Additional Info: ${JSON.stringify(detail.metadata)}`); + } + } else if (isHelpLinks(detail)) { + parts.push(`\nFor help resolving this issue:`); + for (const link of detail.links) { + parts.push(` - ${link.description}`); + parts.push(` ${link.url}`); + } + } + } + + return parts.length > 0 ? `\n\n${parts.join("\n")}` : ""; + } + } + + return ""; +} + +/** + * Enhances an error with detailed information from provisioning API responses. + * This function extracts error details and includes them in the error message. + */ +export function enhanceProvisioningError(err: unknown, contextMessage: string): FirebaseError { + const originalError = getError(err); + const errorDetails = extractErrorDetails(err); + + const fullMessage = errorDetails + ? `${contextMessage}: ${originalError.message}${errorDetails}` + : `${contextMessage}: ${originalError.message}`; + + return new FirebaseError(fullMessage, { + exit: 2, + original: originalError, + }); +} diff --git a/src/management/provisioning/provision.ts b/src/management/provisioning/provision.ts index 463aebe294a..3379d41eb2a 100644 --- a/src/management/provisioning/provision.ts +++ b/src/management/provisioning/provision.ts @@ -5,6 +5,7 @@ import { logger } from "../../logger"; import { pollOperation } from "../../operation-poller"; import { AppPlatform } from "../apps"; import * as types from "./types"; +import { enhanceProvisioningError } from "./errorHandler"; const apiClient = new Client({ urlPrefix: firebaseApiOrigin(), @@ -133,10 +134,6 @@ export async function provisionFirebaseApp( logger.debug("[provision] Firebase app provisioning completed successfully"); return result; } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - throw new FirebaseError(`Failed to provision Firebase app: ${errorMessage}`, { - exit: 2, - original: err instanceof Error ? err : new Error(String(err)), - }); + throw enhanceProvisioningError(err, "Failed to provision Firebase app"); } }