From 31b7d45b58a6929f168a25f724c56ccbd1dcc9fa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:16:03 -0400 Subject: [PATCH] fix(cliproxy): harden gemini quota error fallbacks --- src/cliproxy/quota-fetcher-gemini-cli.ts | 44 +++++++++--- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 71 ++++++++++++++++++- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 55d93497..8bc983d4 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -17,6 +17,7 @@ import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com'; const GEMINI_CLI_API_VERSION = 'v1internal'; const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; +const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; /** * Model groups for quota consolidation. @@ -297,7 +298,10 @@ function sanitizeGeminiCliErrorDetail(bodyText: string): string | undefined { .replace(/\s+/g, ' '); if (sanitized.length > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) { - sanitized = `${sanitized.slice(0, GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - 14)}...[truncated]`; + sanitized = `${sanitized.slice( + 0, + GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX.length + )}${GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX}`; } return sanitized; @@ -323,7 +327,9 @@ function extractGeminiCliNestedMessage(value: unknown): string | undefined { record.description, record.reason, record.error, - ].find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0); + ].find( + (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 + ); if (directMessage) { return directMessage; } @@ -341,10 +347,14 @@ function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody { try { const parsed = JSON.parse(trimmed) as Record; - const topLevelMessage = [parsed.message, parsed.error] - .find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0); - const topLevelCode = [parsed.code, parsed.status] - .find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0); + const topLevelMessage = [parsed.message, parsed.error].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ); + const topLevelCode = [parsed.code, parsed.status].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ); if (parsed.error && typeof parsed.error === 'object') { const error = parsed.error as Record; @@ -356,7 +366,12 @@ function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody { ) || undefined, errorDetail: sanitizedDetail, message: - [error.message, error.error, extractGeminiCliNestedMessage(error.details), topLevelMessage].find( + [ + error.message, + error.error, + extractGeminiCliNestedMessage(error.details), + topLevelMessage, + ].find( (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 ) || undefined, @@ -368,13 +383,14 @@ function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody { errorDetail: sanitizedDetail, message: [topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find( - (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 ) || undefined, }; } catch { return { errorDetail: sanitizedDetail, - message: trimmed, + message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed, }; } } @@ -577,7 +593,12 @@ async function fetchWithAuthData( if (!response.ok) { const bodyText = await response.text(); - return buildGeminiCliHttpFailureResult(accountId, authData.projectId, response.status, bodyText); + return buildGeminiCliHttpFailureResult( + accountId, + authData.projectId, + response.status, + bodyText + ); } const data = (await response.json()) as GeminiCliQuotaResponse; @@ -606,7 +627,8 @@ async function fetchWithAuthData( return buildGeminiCliFailureResult(accountId, authData.projectId, { error: errorMsg, - errorCode: err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error', + errorCode: + err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error', actionHint: 'Retry later. This looks temporary.', retryable: true, httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined, diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index ad3e34c9..05dfdcab 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -33,7 +33,10 @@ describe('Gemini CLI Quota Fetcher', () => { return tokenPath; } - function writeActiveGeminiAccount(accountId: string, overrides: Record = {}): string { + function writeActiveGeminiAccount( + accountId: string, + overrides: Record = {} + ): string { return writeGeminiToken({ type: 'gemini', email: accountId, @@ -381,6 +384,72 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.actionHint).toContain('Retry'); expect(result.error).toBe('Too many quota requests'); }); + + it('preserves non-JSON upstream error text when Gemini returns a plain-text failure', async () => { + writeActiveGeminiAccount('plaintext@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 418, + headers: { 'Content-Type': 'text/plain' }, + response: 'Internal Server Error', + }, + ]); + + const result = await fetchGeminiCliQuota('plaintext@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(418); + expect(result.errorCode).toBe('quota_request_failed'); + expect(result.retryable).toBe(false); + expect(result.error).toBe('Internal Server Error'); + expect(result.errorDetail).toBe('Internal Server Error'); + }); + + it('marks 5xx Gemini quota responses as retryable provider outages', async () => { + writeActiveGeminiAccount('outage@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 503, + headers: { 'Content-Type': 'text/plain' }, + response: 'Service temporarily unavailable', + }, + ]); + + const result = await fetchGeminiCliQuota('outage@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(503); + expect(result.errorCode).toBe('provider_unavailable'); + expect(result.retryable).toBe(true); + expect(result.actionHint).toContain('temporary Google upstream problem'); + expect(result.error).toBe('Service temporarily unavailable'); + }); + + it('omits raw HTML upstream bodies from Gemini quota error detail', async () => { + writeActiveGeminiAccount('html@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 502, + headers: { 'Content-Type': 'text/html' }, + response: 'bad gateway', + }, + ]); + + const result = await fetchGeminiCliQuota('html@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); + expect(result.errorDetail).toBe('[HTML error response omitted]'); + }); }); describe('refreshGeminiToken', () => {