From 936f6240dd9821c380f628943dea20f87da365fe Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:08:33 -0700 Subject: [PATCH] fix(core): sanitize SSE-corrupted JSON and domain strings in error classification (#21702) --- packages/core/src/utils/googleErrors.test.ts | 84 +++++++++++++++++++ packages/core/src/utils/googleErrors.ts | 34 ++++++-- .../core/src/utils/googleQuotaErrors.test.ts | 49 +++++++++++ packages/core/src/utils/googleQuotaErrors.ts | 19 +++-- 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/packages/core/src/utils/googleErrors.test.ts b/packages/core/src/utils/googleErrors.test.ts index 6e11d01f31..5a21e72801 100644 --- a/packages/core/src/utils/googleErrors.test.ts +++ b/packages/core/src/utils/googleErrors.test.ts @@ -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'); + }); }); diff --git a/packages/core/src/utils/googleErrors.ts b/packages/core/src/utils/googleErrors.ts index c9acb341bb..4439d55de5 100644 --- a/packages/core/src/utils/googleErrors.ts +++ b/packages/core/src/utils/googleErrors.ts @@ -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 } diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index 67c9ae5d3a..cd09e53511 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -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); + }); }); diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index d0c251e839..fac291f36e 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -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}`,