fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (#21702)

This commit is contained in:
Gaurav
2026-03-09 07:08:33 -07:00
committed by GitHub
parent f8dd6f4f4c
commit 936f6240dd
4 changed files with 174 additions and 12 deletions

View File

@@ -361,4 +361,88 @@ describe('parseGoogleApiError', () => {
),
).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');
});
});

View File

@@ -9,6 +9,26 @@
* 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
*/
@@ -138,7 +158,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
// If error is a string, try to parse it.
if (typeof errorObj === 'string') {
try {
errorObj = JSON.parse(errorObj);
errorObj = JSON.parse(sanitizeJsonString(errorObj));
} catch (_) {
// Not a JSON string, can't parse.
return null;
@@ -168,7 +188,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsedMessage = JSON.parse(
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
sanitizeJsonString(
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
),
);
if (parsedMessage.error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -261,7 +283,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined {
if (typeof data === 'string') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data = JSON.parse(data);
data = JSON.parse(sanitizeJsonString(data));
} catch (_) {
// Not a JSON string, can't parse.
}
@@ -311,7 +333,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
if (typeof data === 'string') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data = JSON.parse(data);
data = JSON.parse(sanitizeJsonString(data));
} catch (_) {
// Not a JSON string, can't parse.
// 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) {
try {
// 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 (__) {
// Still failed
}

View File

@@ -669,4 +669,53 @@ describe('classifyGoogleError', () => {
expect(result).toBe(originalError);
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);
});
});

View File

@@ -109,6 +109,16 @@ const CLOUDCODE_DOMAINS = [
'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.
*
@@ -129,7 +139,7 @@ function classifyValidationRequiredError(
if (
!errorInfo.domain ||
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
!isCloudCodeDomain(errorInfo.domain) ||
errorInfo.reason !== 'VALIDATION_REQUIRED'
) {
return null;
@@ -313,12 +323,7 @@ export function classifyGoogleError(error: unknown): unknown {
// New Cloud Code API quota handling
if (errorInfo.domain) {
const validDomains = [
'cloudcode-pa.googleapis.com',
'staging-cloudcode-pa.googleapis.com',
'autopush-cloudcode-pa.googleapis.com',
];
if (validDomains.includes(errorInfo.domain)) {
if (isCloudCodeDomain(errorInfo.domain)) {
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
return new RetryableQuotaError(
`${googleApiError.message}`,