Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/core/src/utils/googleErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,4 +362,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');
});
});
34 changes: 29 additions & 5 deletions packages/core/src/utils/googleErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +20 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation is not fully robust. For an input with multiple stray commas separated by whitespace, like {"key": 1, , , 2}, it can produce invalid JSON. The issue is that preserving whitespace between commas can lead to an invalid structure.

A more robust and efficient solution is to use a single regular expression to replace any sequence of commas and intermittent whitespace with a single comma. This avoids the loop and correctly sanitizes more complex corruption patterns.

function sanitizeJsonString(jsonStr: string): string {
  // This regex finds a comma followed by one or more groups of
  // (optional whitespace and another comma). It replaces the entire
  // sequence with a single comma. This handles cases like `, ,` or `,,,`
  // in a single pass without a loop.
  return jsonStr.replace(/,(\s*,)+/g, ',');
}


/**
* Based on google/rpc/error_details.proto
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -260,7 +282,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.
}
Expand Down Expand Up @@ -310,7 +332,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 '}'
Expand All @@ -320,7 +342,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
}
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/utils/googleQuotaErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
19 changes: 12 additions & 7 deletions packages/core/src/utils/googleQuotaErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -129,7 +139,7 @@ function classifyValidationRequiredError(

if (
!errorInfo.domain ||
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
!isCloudCodeDomain(errorInfo.domain) ||
errorInfo.reason !== 'VALIDATION_REQUIRED'
) {
return null;
Expand Down Expand Up @@ -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}`,
Expand Down
Loading