mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (#21702)
This commit is contained in:
@@ -361,4 +361,88 @@ describe('parseGoogleApiError', () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should parse a gaxios error with SSE-corrupted JSON containing stray commas', () => {
|
||||||
|
// This reproduces the exact corruption pattern observed in production where
|
||||||
|
// SSE serialization injects a stray comma on a newline before "metadata".
|
||||||
|
const corruptedJson = JSON.stringify([
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 429,
|
||||||
|
message:
|
||||||
|
'You have exhausted your capacity on this model. Your quota will reset after 19h14m47s.',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'QUOTA_EXHAUSTED',
|
||||||
|
domain: 'cloudcode-pa.googleapis.com',
|
||||||
|
metadata: {
|
||||||
|
uiMessage: 'true',
|
||||||
|
model: 'gemini-3-flash-preview',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||||
|
retryDelay: '68940s',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).replace(
|
||||||
|
'"domain": "cloudcode-pa.googleapis.com",',
|
||||||
|
'"domain": "cloudcode-pa.googleapis.com",\n , ',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test via message path (fromApiError)
|
||||||
|
const mockError = {
|
||||||
|
message: corruptedJson,
|
||||||
|
code: 429,
|
||||||
|
status: 429,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseGoogleApiError(mockError);
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed?.code).toBe(429);
|
||||||
|
expect(parsed?.message).toContain('You have exhausted your capacity');
|
||||||
|
expect(parsed?.details).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
parsed?.details.some(
|
||||||
|
(d) => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a gaxios error with SSE-corrupted JSON in response.data', () => {
|
||||||
|
const corruptedJson = JSON.stringify([
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 429,
|
||||||
|
message: 'Quota exceeded',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'QUOTA_EXHAUSTED',
|
||||||
|
domain: 'cloudcode-pa.googleapis.com',
|
||||||
|
metadata: { model: 'gemini-3-flash-preview' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).replace(
|
||||||
|
'"domain": "cloudcode-pa.googleapis.com",',
|
||||||
|
'"domain": "cloudcode-pa.googleapis.com",\n, ',
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockError = {
|
||||||
|
response: {
|
||||||
|
status: 429,
|
||||||
|
data: corruptedJson,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseGoogleApiError(mockError);
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed?.code).toBe(429);
|
||||||
|
expect(parsed?.message).toBe('Quota exceeded');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,26 @@
|
|||||||
* This file contains types and functions for parsing structured Google API errors.
|
* This file contains types and functions for parsing structured Google API errors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a JSON string before parsing to handle known SSE stream corruption.
|
||||||
|
* SSE stream parsing can inject stray commas — the observed pattern is a comma
|
||||||
|
* at the end of one line followed by a stray comma on the next line, e.g.:
|
||||||
|
* `"domain": "cloudcode-pa.googleapis.com",\n , "metadata": {`
|
||||||
|
* This collapses duplicate commas (possibly separated by whitespace/newlines)
|
||||||
|
* into a single comma, preserving the whitespace.
|
||||||
|
*/
|
||||||
|
function sanitizeJsonString(jsonStr: string): string {
|
||||||
|
// Match a comma, optional whitespace/newlines, then another comma.
|
||||||
|
// Replace with just a comma + the captured whitespace.
|
||||||
|
// Loop to handle cases like `,,,` which would otherwise become `,,` on a single pass.
|
||||||
|
let prev: string;
|
||||||
|
do {
|
||||||
|
prev = jsonStr;
|
||||||
|
jsonStr = jsonStr.replace(/,(\s*),/g, ',$1');
|
||||||
|
} while (jsonStr !== prev);
|
||||||
|
return jsonStr;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Based on google/rpc/error_details.proto
|
* Based on google/rpc/error_details.proto
|
||||||
*/
|
*/
|
||||||
@@ -138,7 +158,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
|||||||
// If error is a string, try to parse it.
|
// If error is a string, try to parse it.
|
||||||
if (typeof errorObj === 'string') {
|
if (typeof errorObj === 'string') {
|
||||||
try {
|
try {
|
||||||
errorObj = JSON.parse(errorObj);
|
errorObj = JSON.parse(sanitizeJsonString(errorObj));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Not a JSON string, can't parse.
|
// Not a JSON string, can't parse.
|
||||||
return null;
|
return null;
|
||||||
@@ -168,7 +188,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const parsedMessage = JSON.parse(
|
const parsedMessage = JSON.parse(
|
||||||
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
|
sanitizeJsonString(
|
||||||
|
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (parsedMessage.error) {
|
if (parsedMessage.error) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
@@ -261,7 +283,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined {
|
|||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
data = JSON.parse(data);
|
data = JSON.parse(sanitizeJsonString(data));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Not a JSON string, can't parse.
|
// Not a JSON string, can't parse.
|
||||||
}
|
}
|
||||||
@@ -311,7 +333,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
|
|||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
data = JSON.parse(data);
|
data = JSON.parse(sanitizeJsonString(data));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Not a JSON string, can't parse.
|
// Not a JSON string, can't parse.
|
||||||
// Try one more fallback: look for the first '{' and last '}'
|
// Try one more fallback: look for the first '{' and last '}'
|
||||||
@@ -321,7 +343,9 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
|
|||||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
data = JSON.parse(data.substring(firstBrace, lastBrace + 1));
|
data = JSON.parse(
|
||||||
|
sanitizeJsonString(data.substring(firstBrace, lastBrace + 1)),
|
||||||
|
);
|
||||||
} catch (__) {
|
} catch (__) {
|
||||||
// Still failed
|
// Still failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -669,4 +669,53 @@ describe('classifyGoogleError', () => {
|
|||||||
expect(result).toBe(originalError);
|
expect(result).toBe(originalError);
|
||||||
expect(result).not.toBeInstanceOf(ValidationRequiredError);
|
expect(result).not.toBeInstanceOf(ValidationRequiredError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return TerminalQuotaError for Cloud Code QUOTA_EXHAUSTED with SSE-corrupted domain', () => {
|
||||||
|
// SSE serialization can inject a trailing comma into the domain string.
|
||||||
|
// This test verifies that the domain sanitization handles this case.
|
||||||
|
const apiError: GoogleApiError = {
|
||||||
|
code: 429,
|
||||||
|
message:
|
||||||
|
'You have exhausted your capacity on this model. Your quota will reset after 19h14m47s.',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'QUOTA_EXHAUSTED',
|
||||||
|
domain: 'cloudcode-pa.googleapis.com,',
|
||||||
|
metadata: {
|
||||||
|
uiMessage: 'true',
|
||||||
|
model: 'gemini-3-flash-preview',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||||
|
retryDelay: '68940s',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||||
|
const result = classifyGoogleError(new Error());
|
||||||
|
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ValidationRequiredError with SSE-corrupted domain', () => {
|
||||||
|
const apiError: GoogleApiError = {
|
||||||
|
code: 403,
|
||||||
|
message: 'Forbidden.',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'VALIDATION_REQUIRED',
|
||||||
|
domain: 'cloudcode-pa.googleapis.com,',
|
||||||
|
metadata: {
|
||||||
|
validationUrl: 'https://example.com/validate',
|
||||||
|
validationDescription: 'Please validate',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||||
|
const result = classifyGoogleError(new Error());
|
||||||
|
expect(result).toBeInstanceOf(ValidationRequiredError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ const CLOUDCODE_DOMAINS = [
|
|||||||
'autopush-cloudcode-pa.googleapis.com',
|
'autopush-cloudcode-pa.googleapis.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given domain belongs to a Cloud Code API endpoint.
|
||||||
|
* Sanitizes stray characters that SSE stream parsing can inject into the
|
||||||
|
* domain string before comparing.
|
||||||
|
*/
|
||||||
|
function isCloudCodeDomain(domain: string): boolean {
|
||||||
|
const sanitized = domain.replace(/[^a-zA-Z0-9.-]/g, '');
|
||||||
|
return CLOUDCODE_DOMAINS.includes(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a 403 error requires user validation and extracts validation details.
|
* Checks if a 403 error requires user validation and extracts validation details.
|
||||||
*
|
*
|
||||||
@@ -129,7 +139,7 @@ function classifyValidationRequiredError(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!errorInfo.domain ||
|
!errorInfo.domain ||
|
||||||
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
|
!isCloudCodeDomain(errorInfo.domain) ||
|
||||||
errorInfo.reason !== 'VALIDATION_REQUIRED'
|
errorInfo.reason !== 'VALIDATION_REQUIRED'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@@ -313,12 +323,7 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
|
|
||||||
// New Cloud Code API quota handling
|
// New Cloud Code API quota handling
|
||||||
if (errorInfo.domain) {
|
if (errorInfo.domain) {
|
||||||
const validDomains = [
|
if (isCloudCodeDomain(errorInfo.domain)) {
|
||||||
'cloudcode-pa.googleapis.com',
|
|
||||||
'staging-cloudcode-pa.googleapis.com',
|
|
||||||
'autopush-cloudcode-pa.googleapis.com',
|
|
||||||
];
|
|
||||||
if (validDomains.includes(errorInfo.domain)) {
|
|
||||||
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
|
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
|
||||||
return new RetryableQuotaError(
|
return new RetryableQuotaError(
|
||||||
`${googleApiError.message}`,
|
`${googleApiError.message}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user