fix(cliproxy): harden gemini quota error fallbacks

This commit is contained in:
Tam Nhu Tran
2026-03-26 17:13:11 -04:00
parent 99f78f156a
commit 31b7d45b58
2 changed files with 103 additions and 12 deletions
+33 -11
View File
@@ -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_BASE = 'https://cloudcode-pa.googleapis.com';
const GEMINI_CLI_API_VERSION = 'v1internal'; const GEMINI_CLI_API_VERSION = 'v1internal';
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
/** /**
* Model groups for quota consolidation. * Model groups for quota consolidation.
@@ -297,7 +298,10 @@ function sanitizeGeminiCliErrorDetail(bodyText: string): string | undefined {
.replace(/\s+/g, ' '); .replace(/\s+/g, ' ');
if (sanitized.length > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) { 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; return sanitized;
@@ -323,7 +327,9 @@ function extractGeminiCliNestedMessage(value: unknown): string | undefined {
record.description, record.description,
record.reason, record.reason,
record.error, 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) { if (directMessage) {
return directMessage; return directMessage;
} }
@@ -341,10 +347,14 @@ function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody {
try { try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>; const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevelMessage = [parsed.message, parsed.error] const topLevelMessage = [parsed.message, parsed.error].find(
.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0); (candidate): candidate is string =>
const topLevelCode = [parsed.code, parsed.status] typeof candidate === 'string' && candidate.trim().length > 0
.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') { if (parsed.error && typeof parsed.error === 'object') {
const error = parsed.error as Record<string, unknown>; const error = parsed.error as Record<string, unknown>;
@@ -356,7 +366,12 @@ function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody {
) || undefined, ) || undefined,
errorDetail: sanitizedDetail, errorDetail: sanitizedDetail,
message: message:
[error.message, error.error, extractGeminiCliNestedMessage(error.details), topLevelMessage].find( [
error.message,
error.error,
extractGeminiCliNestedMessage(error.details),
topLevelMessage,
].find(
(candidate): candidate is string => (candidate): candidate is string =>
typeof candidate === 'string' && candidate.trim().length > 0 typeof candidate === 'string' && candidate.trim().length > 0
) || undefined, ) || undefined,
@@ -368,13 +383,14 @@ function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody {
errorDetail: sanitizedDetail, errorDetail: sanitizedDetail,
message: message:
[topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find( [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, ) || undefined,
}; };
} catch { } catch {
return { return {
errorDetail: sanitizedDetail, errorDetail: sanitizedDetail,
message: trimmed, message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed,
}; };
} }
} }
@@ -577,7 +593,12 @@ async function fetchWithAuthData(
if (!response.ok) { if (!response.ok) {
const bodyText = await response.text(); 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; const data = (await response.json()) as GeminiCliQuotaResponse;
@@ -606,7 +627,8 @@ async function fetchWithAuthData(
return buildGeminiCliFailureResult(accountId, authData.projectId, { return buildGeminiCliFailureResult(accountId, authData.projectId, {
error: errorMsg, 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.', actionHint: 'Retry later. This looks temporary.',
retryable: true, retryable: true,
httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined, httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined,
@@ -33,7 +33,10 @@ describe('Gemini CLI Quota Fetcher', () => {
return tokenPath; return tokenPath;
} }
function writeActiveGeminiAccount(accountId: string, overrides: Record<string, unknown> = {}): string { function writeActiveGeminiAccount(
accountId: string,
overrides: Record<string, unknown> = {}
): string {
return writeGeminiToken({ return writeGeminiToken({
type: 'gemini', type: 'gemini',
email: accountId, email: accountId,
@@ -381,6 +384,72 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(result.actionHint).toContain('Retry'); expect(result.actionHint).toContain('Retry');
expect(result.error).toBe('Too many quota requests'); 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: '<!doctype html><html><body>bad gateway</body></html>',
},
]);
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', () => { describe('refreshGeminiToken', () => {