fix(patch): cherry-pick ea48bd9 to release/v0.31.0-preview.1-pr-20577 [CONFLICTS] (#20592)

Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
This commit is contained in:
gemini-cli-robot
2026-02-27 14:51:52 -05:00
committed by GitHub
parent f310915535
commit 3e3da76cca
17 changed files with 672 additions and 19 deletions

View File

@@ -11,6 +11,7 @@ import {
toFriendlyError,
BadRequestError,
ForbiddenError,
AccountSuspendedError,
getErrorMessage,
getErrorType,
FatalAuthenticationError,
@@ -127,9 +128,86 @@ describe('toFriendlyError', () => {
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
expect((result as ForbiddenError).message).toBe('Forbidden');
});
it('should return AccountSuspendedError for 403 with TOS_VIOLATION reason in details', () => {
const error = {
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled in this account for violation of Terms of Service.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'TOS_VIOLATION',
domain: 'example.googleapis.com',
metadata: {
uiMessage: 'true',
appeal_url_link_text: 'Appeal Here',
appeal_url: 'https://example.com/appeal',
},
},
],
},
},
},
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(AccountSuspendedError);
expect(result).toBeInstanceOf(ForbiddenError);
const suspended = result as AccountSuspendedError;
expect(suspended.message).toBe(
'This service has been disabled in this account for violation of Terms of Service.',
);
expect(suspended.appealUrl).toBe('https://example.com/appeal');
expect(suspended.appealLinkText).toBe('Appeal Here');
});
it('should return ForbiddenError for 403 with violation message but no TOS_VIOLATION detail', () => {
const error = {
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled in this account for violation of Terms of Service.',
},
},
},
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
});
it('should return ForbiddenError for 403 with non-TOS_VIOLATION detail', () => {
const error = {
response: {
data: {
error: {
code: 403,
message: 'Forbidden',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'ACCESS_DENIED',
domain: 'googleapis.com',
metadata: {},
},
],
},
},
},
};
const result = toFriendlyError(error);
expect(result).toBeInstanceOf(ForbiddenError);
expect(result).not.toBeInstanceOf(AccountSuspendedError);
});
it('should parse stringified JSON data', () => {
const error = {
response: {
@@ -236,6 +314,9 @@ describe('getErrorType', () => {
'FatalCancellationError',
);
expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError');
expect(getErrorType(new AccountSuspendedError('test'))).toBe(
'AccountSuspendedError',
);
expect(getErrorType(new UnauthorizedError('test'))).toBe(
'UnauthorizedError',
);

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { parseGoogleApiError, type ErrorInfo } from './googleErrors.js';
interface GaxiosError {
response?: {
data?: unknown;
@@ -98,6 +100,17 @@ export class CanceledError extends Error {
}
export class ForbiddenError extends Error {}
export class AccountSuspendedError extends ForbiddenError {
readonly appealUrl?: string;
readonly appealLinkText?: string;
constructor(message: string, metadata?: Record<string, string>) {
super(message);
this.name = 'AccountSuspendedError';
this.appealUrl = metadata?.['appeal_url'];
this.appealLinkText = metadata?.['appeal_url_link_text'];
}
}
export class UnauthorizedError extends Error {}
export class BadRequestError extends Error {}
@@ -148,6 +161,24 @@ function isResponseData(data: unknown): data is ResponseData {
}
export function toFriendlyError(error: unknown): unknown {
// First, try structured parsing for TOS_VIOLATION detection.
const googleApiError = parseGoogleApiError(error);
if (googleApiError && googleApiError.code === 403) {
const tosDetail = googleApiError.details.find(
(d): d is ErrorInfo =>
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo' &&
'reason' in d &&
d.reason === 'TOS_VIOLATION',
);
if (tosDetail) {
return new AccountSuspendedError(
googleApiError.message,
tosDetail.metadata,
);
}
}
// Fall back to basic Gaxios error parsing for other HTTP errors.
if (isGaxiosError(error)) {
const data = parseResponseData(error);
if (data && data.error && data.error.message && data.error.code) {
@@ -157,9 +188,6 @@ export function toFriendlyError(error: unknown): unknown {
case 401:
return new UnauthorizedError(data.error.message);
case 403:
// It's import to pass the message here since it might
// explain the cause like "the cloud project you're
// using doesn't have code assist enabled".
return new ForbiddenError(data.error.message);
default:
}
@@ -168,6 +196,13 @@ export function toFriendlyError(error: unknown): unknown {
return error;
}
export function isAccountSuspendedError(
error: unknown,
): AccountSuspendedError | null {
const friendly = toFriendlyError(error);
return friendly instanceof AccountSuspendedError ? friendly : null;
}
function parseResponseData(error: GaxiosError): ResponseData | undefined {
let data = error.response?.data;
// Inexplicably, Gaxios sometimes doesn't JSONify the response data.