Skip to content

Commit aa1b02a

Browse files
gsquared94gemini-cli-robot
authored andcommitted
fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (#21702)
1 parent 94ccdc7 commit aa1b02a

4 files changed

Lines changed: 174 additions & 12 deletions

File tree

packages/core/src/utils/googleErrors.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,4 +362,88 @@ describe('parseGoogleApiError', () => {
362362
),
363363
).toBe(true);
364364
});
365+
366+
it('should parse a gaxios error with SSE-corrupted JSON containing stray commas', () => {
367+
// This reproduces the exact corruption pattern observed in production where
368+
// SSE serialization injects a stray comma on a newline before "metadata".
369+
const corruptedJson = JSON.stringify([
370+
{
371+
error: {
372+
code: 429,
373+
message:
374+
'You have exhausted your capacity on this model. Your quota will reset after 19h14m47s.',
375+
details: [
376+
{
377+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
378+
reason: 'QUOTA_EXHAUSTED',
379+
domain: 'cloudcode-pa.googleapis.com',
380+
metadata: {
381+
uiMessage: 'true',
382+
model: 'gemini-3-flash-preview',
383+
},
384+
},
385+
{
386+
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
387+
retryDelay: '68940s',
388+
},
389+
],
390+
},
391+
},
392+
]).replace(
393+
'"domain": "cloudcode-pa.googleapis.com",',
394+
'"domain": "cloudcode-pa.googleapis.com",\n , ',
395+
);
396+
397+
// Test via message path (fromApiError)
398+
const mockError = {
399+
message: corruptedJson,
400+
code: 429,
401+
status: 429,
402+
};
403+
404+
const parsed = parseGoogleApiError(mockError);
405+
expect(parsed).not.toBeNull();
406+
expect(parsed?.code).toBe(429);
407+
expect(parsed?.message).toContain('You have exhausted your capacity');
408+
expect(parsed?.details).toHaveLength(2);
409+
expect(
410+
parsed?.details.some(
411+
(d) => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
412+
),
413+
).toBe(true);
414+
});
415+
416+
it('should parse a gaxios error with SSE-corrupted JSON in response.data', () => {
417+
const corruptedJson = JSON.stringify([
418+
{
419+
error: {
420+
code: 429,
421+
message: 'Quota exceeded',
422+
details: [
423+
{
424+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
425+
reason: 'QUOTA_EXHAUSTED',
426+
domain: 'cloudcode-pa.googleapis.com',
427+
metadata: { model: 'gemini-3-flash-preview' },
428+
},
429+
],
430+
},
431+
},
432+
]).replace(
433+
'"domain": "cloudcode-pa.googleapis.com",',
434+
'"domain": "cloudcode-pa.googleapis.com",\n, ',
435+
);
436+
437+
const mockError = {
438+
response: {
439+
status: 429,
440+
data: corruptedJson,
441+
},
442+
};
443+
444+
const parsed = parseGoogleApiError(mockError);
445+
expect(parsed).not.toBeNull();
446+
expect(parsed?.code).toBe(429);
447+
expect(parsed?.message).toBe('Quota exceeded');
448+
});
365449
});

packages/core/src/utils/googleErrors.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@
99
* This file contains types and functions for parsing structured Google API errors.
1010
*/
1111

12+
/**
13+
* Sanitize a JSON string before parsing to handle known SSE stream corruption.
14+
* SSE stream parsing can inject stray commas — the observed pattern is a comma
15+
* at the end of one line followed by a stray comma on the next line, e.g.:
16+
* `"domain": "cloudcode-pa.googleapis.com",\n , "metadata": {`
17+
* This collapses duplicate commas (possibly separated by whitespace/newlines)
18+
* into a single comma, preserving the whitespace.
19+
*/
20+
function sanitizeJsonString(jsonStr: string): string {
21+
// Match a comma, optional whitespace/newlines, then another comma.
22+
// Replace with just a comma + the captured whitespace.
23+
// Loop to handle cases like `,,,` which would otherwise become `,,` on a single pass.
24+
let prev: string;
25+
do {
26+
prev = jsonStr;
27+
jsonStr = jsonStr.replace(/,(\s*),/g, ',$1');
28+
} while (jsonStr !== prev);
29+
return jsonStr;
30+
}
31+
1232
/**
1333
* Based on google/rpc/error_details.proto
1434
*/
@@ -138,7 +158,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
138158
// If error is a string, try to parse it.
139159
if (typeof errorObj === 'string') {
140160
try {
141-
errorObj = JSON.parse(errorObj);
161+
errorObj = JSON.parse(sanitizeJsonString(errorObj));
142162
} catch (_) {
143163
// Not a JSON string, can't parse.
144164
return null;
@@ -168,7 +188,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
168188
try {
169189
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
170190
const parsedMessage = JSON.parse(
171-
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
191+
sanitizeJsonString(
192+
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
193+
),
172194
);
173195
if (parsedMessage.error) {
174196
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -260,7 +282,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined {
260282
if (typeof data === 'string') {
261283
try {
262284
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
263-
data = JSON.parse(data);
285+
data = JSON.parse(sanitizeJsonString(data));
264286
} catch (_) {
265287
// Not a JSON string, can't parse.
266288
}
@@ -310,7 +332,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
310332
if (typeof data === 'string') {
311333
try {
312334
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
313-
data = JSON.parse(data);
335+
data = JSON.parse(sanitizeJsonString(data));
314336
} catch (_) {
315337
// Not a JSON string, can't parse.
316338
// Try one more fallback: look for the first '{' and last '}'
@@ -320,7 +342,9 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
320342
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
321343
try {
322344
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
323-
data = JSON.parse(data.substring(firstBrace, lastBrace + 1));
345+
data = JSON.parse(
346+
sanitizeJsonString(data.substring(firstBrace, lastBrace + 1)),
347+
);
324348
} catch (__) {
325349
// Still failed
326350
}

packages/core/src/utils/googleQuotaErrors.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,4 +669,53 @@ describe('classifyGoogleError', () => {
669669
expect(result).toBe(originalError);
670670
expect(result).not.toBeInstanceOf(ValidationRequiredError);
671671
});
672+
673+
it('should return TerminalQuotaError for Cloud Code QUOTA_EXHAUSTED with SSE-corrupted domain', () => {
674+
// SSE serialization can inject a trailing comma into the domain string.
675+
// This test verifies that the domain sanitization handles this case.
676+
const apiError: GoogleApiError = {
677+
code: 429,
678+
message:
679+
'You have exhausted your capacity on this model. Your quota will reset after 19h14m47s.',
680+
details: [
681+
{
682+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
683+
reason: 'QUOTA_EXHAUSTED',
684+
domain: 'cloudcode-pa.googleapis.com,',
685+
metadata: {
686+
uiMessage: 'true',
687+
model: 'gemini-3-flash-preview',
688+
},
689+
},
690+
{
691+
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
692+
retryDelay: '68940s',
693+
},
694+
],
695+
};
696+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
697+
const result = classifyGoogleError(new Error());
698+
expect(result).toBeInstanceOf(TerminalQuotaError);
699+
});
700+
701+
it('should return ValidationRequiredError with SSE-corrupted domain', () => {
702+
const apiError: GoogleApiError = {
703+
code: 403,
704+
message: 'Forbidden.',
705+
details: [
706+
{
707+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
708+
reason: 'VALIDATION_REQUIRED',
709+
domain: 'cloudcode-pa.googleapis.com,',
710+
metadata: {
711+
validationUrl: 'https://example.com/validate',
712+
validationDescription: 'Please validate',
713+
},
714+
},
715+
],
716+
};
717+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
718+
const result = classifyGoogleError(new Error());
719+
expect(result).toBeInstanceOf(ValidationRequiredError);
720+
});
672721
});

packages/core/src/utils/googleQuotaErrors.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ const CLOUDCODE_DOMAINS = [
109109
'autopush-cloudcode-pa.googleapis.com',
110110
];
111111

112+
/**
113+
* Checks if the given domain belongs to a Cloud Code API endpoint.
114+
* Sanitizes stray characters that SSE stream parsing can inject into the
115+
* domain string before comparing.
116+
*/
117+
function isCloudCodeDomain(domain: string): boolean {
118+
const sanitized = domain.replace(/[^a-zA-Z0-9.-]/g, '');
119+
return CLOUDCODE_DOMAINS.includes(sanitized);
120+
}
121+
112122
/**
113123
* Checks if a 403 error requires user validation and extracts validation details.
114124
*
@@ -129,7 +139,7 @@ function classifyValidationRequiredError(
129139

130140
if (
131141
!errorInfo.domain ||
132-
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
142+
!isCloudCodeDomain(errorInfo.domain) ||
133143
errorInfo.reason !== 'VALIDATION_REQUIRED'
134144
) {
135145
return null;
@@ -313,12 +323,7 @@ export function classifyGoogleError(error: unknown): unknown {
313323

314324
// New Cloud Code API quota handling
315325
if (errorInfo.domain) {
316-
const validDomains = [
317-
'cloudcode-pa.googleapis.com',
318-
'staging-cloudcode-pa.googleapis.com',
319-
'autopush-cloudcode-pa.googleapis.com',
320-
];
321-
if (validDomains.includes(errorInfo.domain)) {
326+
if (isCloudCodeDomain(errorInfo.domain)) {
322327
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
323328
return new RetryableQuotaError(
324329
`${googleApiError.message}`,

0 commit comments

Comments
 (0)