Skip to content

Commit b665535

Browse files
gsquared94kunal-10-cloud
authored andcommitted
fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (google-gemini#21702)
1 parent b68a5bf commit b665535

File tree

4 files changed

+174
-12
lines changed

4 files changed

+174
-12
lines changed

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

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

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
@@ -261,7 +283,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined {
261283
if (typeof data === 'string') {
262284
try {
263285
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
264-
data = JSON.parse(data);
286+
data = JSON.parse(sanitizeJsonString(data));
265287
} catch (_) {
266288
// Not a JSON string, can't parse.
267289
}
@@ -311,7 +333,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
311333
if (typeof data === 'string') {
312334
try {
313335
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
314-
data = JSON.parse(data);
336+
data = JSON.parse(sanitizeJsonString(data));
315337
} catch (_) {
316338
// Not a JSON string, can't parse.
317339
// Try one more fallback: look for the first '{' and last '}'
@@ -321,7 +343,9 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
321343
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
322344
try {
323345
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
324-
data = JSON.parse(data.substring(firstBrace, lastBrace + 1));
346+
data = JSON.parse(
347+
sanitizeJsonString(data.substring(firstBrace, lastBrace + 1)),
348+
);
325349
} catch (__) {
326350
// Still failed
327351
}

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)