Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
251 changes: 251 additions & 0 deletions src/management/provisioning/errorHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
112 changes: 112 additions & 0 deletions src/management/provisioning/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

/**
* 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<string, unknown>;

/**
* 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,
});
}
7 changes: 2 additions & 5 deletions src/management/provisioning/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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");
}
}
Loading