From dc76bcc433d58d879f8850ac777d2cd239dad611 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:30:13 -0400 Subject: [PATCH] Add error messaging for 429 errors (#1316) --- packages/cli/src/nonInteractiveCli.test.ts | 3 +- packages/cli/src/nonInteractiveCli.ts | 4 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 9 +- .../cli/src/ui/utils/errorParsing.test.ts | 55 ++++++++++-- packages/cli/src/ui/utils/errorParsing.ts | 88 ++++++++++++------- packages/core/src/core/turn.test.ts | 4 +- packages/core/src/core/turn.ts | 21 ++++- 7 files changed, 139 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 959cf03f..2bee1f24 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -215,8 +215,7 @@ describe('runNonInteractive', () => { await runNonInteractive(mockConfig, 'Initial fail'); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error processing input:', - apiError, + '[API Error: API connection failed]', ); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 01ec62c8..02bbfd52 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -19,6 +19,8 @@ import { GenerateContentResponse, } from '@google/genai'; +import { parseAndFormatApiError } from './ui/utils/errorParsing.js'; + function getResponseText(response: GenerateContentResponse): string | null { if (response.candidates && response.candidates.length > 0) { const candidate = response.candidates[0]; @@ -126,7 +128,7 @@ export async function runNonInteractive( } } } catch (error) { - console.error('Error processing input:', error); + console.error(parseAndFormatApiError(error)); process.exit(1); } finally { if (isTelemetrySdkInitialized()) { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 921fbdb1..e045fdeb 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -396,7 +396,10 @@ export const useGeminiStream = ( setPendingHistoryItem(null); } addItem( - { type: MessageType.ERROR, text: `[API Error: ${eventValue.message}]` }, + { + type: MessageType.ERROR, + text: parseAndFormatApiError(eventValue.error), + }, userMessageTimestamp, ); }, @@ -530,6 +533,10 @@ export const useGeminiStream = ( setPendingHistoryItem(null); } } catch (error: unknown) { + console.log( + 'GEMINI_DEBUG: Caught error in useGeminiStream.ts:', + JSON.stringify(error), + ); if (isAuthError(error)) { onAuthError(); } else if (!isNodeError(error) || error.name !== 'AbortError') { diff --git a/packages/cli/src/ui/utils/errorParsing.test.ts b/packages/cli/src/ui/utils/errorParsing.test.ts index afee5793..0dbd75c8 100644 --- a/packages/cli/src/ui/utils/errorParsing.test.ts +++ b/packages/cli/src/ui/utils/errorParsing.test.ts @@ -6,29 +6,46 @@ import { describe, it, expect } from 'vitest'; import { parseAndFormatApiError } from './errorParsing.js'; +import { StructuredError } from '@gemini-cli/core'; describe('parseAndFormatApiError', () => { + const rateLimitMessage = + 'Please wait and try again later. To increase your limits, upgrade to a plan with higher limits, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey'; + it('should format a valid API error JSON', () => { const errorMessage = 'got status: 400 Bad Request. {"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}'; const expected = - 'API Error: API key not valid. Please pass a valid API key. (Status: INVALID_ARGUMENT)'; + '[API Error: API key not valid. Please pass a valid API key. (Status: INVALID_ARGUMENT)]'; + expect(parseAndFormatApiError(errorMessage)).toBe(expected); + }); + + it('should format a 429 API error JSON with the custom message', () => { + const errorMessage = + 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}'; + const expected = `[API Error: Rate limit exceeded (Status: RESOURCE_EXHAUSTED)]\n${rateLimitMessage}`; expect(parseAndFormatApiError(errorMessage)).toBe(expected); }); it('should return the original message if it is not a JSON error', () => { const errorMessage = 'This is a plain old error message'; - expect(parseAndFormatApiError(errorMessage)).toBe(errorMessage); + expect(parseAndFormatApiError(errorMessage)).toBe( + `[API Error: ${errorMessage}]`, + ); }); it('should return the original message for malformed JSON', () => { const errorMessage = '[Stream Error: {"error": "malformed}'; - expect(parseAndFormatApiError(errorMessage)).toBe(errorMessage); + expect(parseAndFormatApiError(errorMessage)).toBe( + `[API Error: ${errorMessage}]`, + ); }); it('should handle JSON that does not match the ApiError structure', () => { const errorMessage = '[Stream Error: {"not_an_error": "some other json"}]'; - expect(parseAndFormatApiError(errorMessage)).toBe(errorMessage); + expect(parseAndFormatApiError(errorMessage)).toBe( + `[API Error: ${errorMessage}]`, + ); }); it('should format a nested API error', () => { @@ -49,8 +66,32 @@ describe('parseAndFormatApiError', () => { }, }); - expect(parseAndFormatApiError(errorMessage)).toBe( - "API Error: Gemini 2.5 Pro Preview doesn't have a free quota tier. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. (Status: Too Many Requests)", - ); + const expected = `[API Error: Gemini 2.5 Pro Preview doesn't have a free quota tier. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. (Status: Too Many Requests)]\n${rateLimitMessage}`; + + expect(parseAndFormatApiError(errorMessage)).toBe(expected); + }); + + it('should format a StructuredError', () => { + const error: StructuredError = { + message: 'A structured error occurred', + status: 500, + }; + const expected = '[API Error: A structured error occurred]'; + expect(parseAndFormatApiError(error)).toBe(expected); + }); + + it('should format a 429 StructuredError with the custom message', () => { + const error: StructuredError = { + message: 'Rate limit exceeded', + status: 429, + }; + const expected = `[API Error: Rate limit exceeded]\n${rateLimitMessage}`; + expect(parseAndFormatApiError(error)).toBe(expected); + }); + + it('should handle an unknown error type', () => { + const error = 12345; + const expected = '[API Error: An unknown error occurred.]'; + expect(parseAndFormatApiError(error)).toBe(expected); }); }); diff --git a/packages/cli/src/ui/utils/errorParsing.ts b/packages/cli/src/ui/utils/errorParsing.ts index aec337b6..1aca15ae 100644 --- a/packages/cli/src/ui/utils/errorParsing.ts +++ b/packages/cli/src/ui/utils/errorParsing.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { StructuredError } from '@gemini-cli/core'; + +const RATE_LIMIT_ERROR_MESSAGE = + '\nPlease wait and try again later. To increase your limits, upgrade to a plan with higher limits, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey'; + export interface ApiError { error: { code: number; @@ -23,34 +28,57 @@ function isApiError(error: unknown): error is ApiError { ); } -export function parseAndFormatApiError(errorMessage: string): string { - // The error message might be prefixed with some text, like "[Stream Error: ...]". - // We want to find the start of the JSON object. - const jsonStart = errorMessage.indexOf('{'); - if (jsonStart === -1) { - return errorMessage; // Not a JSON error, return as is. - } - - const jsonString = errorMessage.substring(jsonStart); - - try { - const error = JSON.parse(jsonString) as unknown; - if (isApiError(error)) { - let finalMessage = error.error.message; - try { - // See if the message is a stringified JSON with another error - const nestedError = JSON.parse(finalMessage) as unknown; - if (isApiError(nestedError)) { - finalMessage = nestedError.error.message; - } - } catch (_e) { - // It's not a nested JSON error, so we just use the message as is. - } - return `API Error: ${finalMessage} (Status: ${error.error.status})`; - } - } catch (_e) { - // Not a valid JSON, fall through and return the original message. - } - - return errorMessage; +function isStructuredError(error: unknown): error is StructuredError { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as StructuredError).message === 'string' + ); +} + +export function parseAndFormatApiError(error: unknown): string { + if (isStructuredError(error)) { + let text = `[API Error: ${error.message}]`; + if (error.status === 429) { + text += RATE_LIMIT_ERROR_MESSAGE; + } + return text; + } + + // The error message might be a string containing a JSON object. + if (typeof error === 'string') { + const jsonStart = error.indexOf('{'); + if (jsonStart === -1) { + return `[API Error: ${error}]`; // Not a JSON error, return as is. + } + + const jsonString = error.substring(jsonStart); + + try { + const parsedError = JSON.parse(jsonString) as unknown; + if (isApiError(parsedError)) { + let finalMessage = parsedError.error.message; + try { + // See if the message is a stringified JSON with another error + const nestedError = JSON.parse(finalMessage) as unknown; + if (isApiError(nestedError)) { + finalMessage = nestedError.error.message; + } + } catch (_e) { + // It's not a nested JSON error, so we just use the message as is. + } + let text = `[API Error: ${finalMessage} (Status: ${parsedError.error.status})]`; + if (parsedError.error.code === 429) { + text += RATE_LIMIT_ERROR_MESSAGE; + } + return text; + } + } catch (_e) { + // Not a valid JSON, fall through and return the original message. + } + return `[API Error: ${error}]`; + } + + return '[API Error: An unknown error occurred.]'; } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index a525cbff..602a0b74 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -233,7 +233,9 @@ describe('Turn', () => { expect(events.length).toBe(1); const errorEvent = events[0] as ServerGeminiErrorEvent; expect(errorEvent.type).toBe(GeminiEventType.Error); - expect(errorEvent.value).toEqual({ message: 'API Error' }); + expect(errorEvent.value).toEqual({ + error: { message: 'API Error', status: undefined }, + }); expect(turn.getDebugResponses().length).toBe(0); expect(reportError).toHaveBeenCalledWith( error, diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index cdb4a89f..85fffd93 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -49,8 +49,13 @@ export enum GeminiEventType { Thought = 'thought', } -export interface GeminiErrorEventValue { +export interface StructuredError { message: string; + status?: number; +} + +export interface GeminiErrorEventValue { + error: StructuredError; } export interface ToolCallRequestInfo { @@ -236,8 +241,18 @@ export class Turn { contextForReport, 'Turn.run-sendMessageStream', ); - const errorMessage = getErrorMessage(error); - yield { type: GeminiEventType.Error, value: { message: errorMessage } }; + const status = + typeof error === 'object' && + error !== null && + 'status' in error && + typeof (error as { status: unknown }).status === 'number' + ? (error as { status: number }).status + : undefined; + const structuredError: StructuredError = { + message: getErrorMessage(error), + status, + }; + yield { type: GeminiEventType.Error, value: { error: structuredError } }; return; } }