mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 10:16:34 +00:00
fix(cliproxy): harden gemini quota error fallbacks
This commit is contained in:
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user