mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(review): harden quota retry and tooltip edge cases
This commit is contained in:
@@ -21,9 +21,18 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null):
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizeNumericTimestamp = (value: number): number | null => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Support Unix seconds from older token stores while preserving millisecond timestamps.
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof expiredValue === 'number') {
|
||||
return Number.isFinite(expiredValue) ? expiredValue : null;
|
||||
return normalizeNumericTimestamp(expiredValue);
|
||||
}
|
||||
|
||||
const trimmed = expiredValue.trim();
|
||||
@@ -33,7 +42,7 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null):
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numericTimestamp = Number(trimmed);
|
||||
return Number.isFinite(numericTimestamp) ? numericTimestamp : null;
|
||||
return normalizeNumericTimestamp(numericTimestamp);
|
||||
}
|
||||
|
||||
const expiredDate = new Date(trimmed);
|
||||
|
||||
@@ -745,10 +745,9 @@ export async function fetchGeminiCliQuota(
|
||||
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
||||
const shouldRefresh =
|
||||
authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
|
||||
let attemptedRefresh = false;
|
||||
let refreshedBeforeQuotaFetch = false;
|
||||
|
||||
if (shouldRefresh) {
|
||||
attemptedRefresh = true;
|
||||
if (verbose)
|
||||
console.error(
|
||||
authData.isExpired
|
||||
@@ -758,6 +757,7 @@ export async function fetchGeminiCliQuota(
|
||||
const refreshResult = await refreshGeminiToken(accountId);
|
||||
|
||||
if (refreshResult.success) {
|
||||
refreshedBeforeQuotaFetch = true;
|
||||
if (verbose) console.error('[i] Token refreshed successfully');
|
||||
// Re-read auth data after successful refresh
|
||||
const refreshedAuthData = readGeminiCliAuthData(accountId);
|
||||
@@ -784,7 +784,7 @@ export async function fetchGeminiCliQuota(
|
||||
const result = await fetchWithAuthData(authData, accountId, verbose);
|
||||
|
||||
// Retry once with an account-scoped refresh when the quota endpoint rejects auth.
|
||||
if (result.needsReauth && !attemptedRefresh) {
|
||||
if (result.needsReauth && !refreshedBeforeQuotaFetch) {
|
||||
if (verbose) console.error('[i] Got 401, attempting refresh and retry...');
|
||||
const refreshResult = await refreshGeminiToken(accountId);
|
||||
if (refreshResult.success) {
|
||||
|
||||
@@ -89,10 +89,19 @@ describe('Auth Utilities', () => {
|
||||
expect(isTokenExpired(futureTimestamp)).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat Unix-seconds values as seconds, not milliseconds', () => {
|
||||
const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000);
|
||||
expect(isTokenExpired(futureUnixSeconds)).toBe(false);
|
||||
expect(isTokenExpired(String(futureUnixSeconds))).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose normalized expiry timestamps for string and numeric inputs', () => {
|
||||
const futureTimestamp = Date.now() + 60000;
|
||||
expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp);
|
||||
expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp);
|
||||
const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000);
|
||||
expect(getTokenExpiryTimestamp(futureUnixSeconds)).toBe(futureUnixSeconds * 1000);
|
||||
expect(getTokenExpiryTimestamp(String(futureUnixSeconds))).toBe(futureUnixSeconds * 1000);
|
||||
expect(getTokenExpiryTimestamp('not-a-date')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -717,6 +717,111 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
expect(refreshRequest.body).not.toContain('default-refresh-token');
|
||||
expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token');
|
||||
});
|
||||
|
||||
it('retries a 401 quota failure after a transient proactive refresh failure', async () => {
|
||||
writeGeminiToken(
|
||||
{
|
||||
type: 'gemini',
|
||||
email: 'retry@example.com',
|
||||
project_id: 'retry-project',
|
||||
token: {
|
||||
access_token: 'retry-stale-token',
|
||||
refresh_token: 'retry-refresh-token',
|
||||
expiry: Date.now() + 60 * 1000,
|
||||
client_id: 'retry-client-id',
|
||||
client_secret: 'retry-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-retry.json'
|
||||
);
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
method: 'POST',
|
||||
response: { access_token: 'unused-default', expires_in: 1800 },
|
||||
},
|
||||
{
|
||||
url: GEMINI_QUOTA_URL,
|
||||
method: 'POST',
|
||||
status: 200,
|
||||
response: {
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
url: GEMINI_CODE_ASSIST_URL,
|
||||
method: 'POST',
|
||||
status: 503,
|
||||
response: { error: { message: 'supplementary unavailable' } },
|
||||
},
|
||||
]);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let refreshAttempt = 0;
|
||||
let quotaAttempt = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url === GOOGLE_TOKEN_URL) {
|
||||
refreshAttempt += 1;
|
||||
return refreshAttempt === 1
|
||||
? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
: new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === GEMINI_QUOTA_URL) {
|
||||
quotaAttempt += 1;
|
||||
return quotaAttempt === 1
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
: new Response(
|
||||
JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchGeminiCliQuota('retry@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(refreshAttempt).toBe(2);
|
||||
expect(quotaAttempt).toBe(2);
|
||||
|
||||
const storedToken = JSON.parse(
|
||||
fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8')
|
||||
) as { token?: { access_token?: string } };
|
||||
expect(storedToken.token?.access_token).toBe('retry-fresh-token');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct Gemini error helper coverage', () => {
|
||||
|
||||
@@ -249,7 +249,7 @@ export function AccountCard({
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<TooltipContent side="top" className="sm:max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -250,7 +250,7 @@ export function AccountQuotaPanel({
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="sm:max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -306,7 +306,7 @@ export function AccountQuotaPanel({
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="sm:max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -27,6 +27,8 @@ interface QuotaTooltipContentProps {
|
||||
resetTime: string | null;
|
||||
}
|
||||
|
||||
const lowQuotaTextClass = 'text-red-700 dark:text-red-400';
|
||||
|
||||
function formatPlanLabel(planType: string | null | undefined): string | null {
|
||||
if (!planType) return null;
|
||||
const normalized = planType
|
||||
@@ -94,7 +96,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
: 'text-foreground';
|
||||
|
||||
return (
|
||||
<div className="min-w-[16rem] space-y-2 text-xs">
|
||||
<div className="max-w-sm space-y-2 text-xs">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('font-semibold tracking-tight', failureToneClass)}>
|
||||
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
||||
@@ -140,10 +142,10 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
{!isFirst && <div className="my-1 border-t border-border/40" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
<span className={cn('truncate', m.exhausted && lowQuotaTextClass)}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
<span className={cn('font-mono', m.exhausted && lowQuotaTextClass)}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -180,7 +182,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
|
||||
<span className={cn(w.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{getCodexWindowDisplayLabel(w, orderedWindows)}
|
||||
</span>
|
||||
<span className="font-mono">{w.remainingPercent}%</span>
|
||||
@@ -248,7 +250,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<span className={cn(window.remainingPercent < 20 && 'text-red-500')}>
|
||||
<span className={cn(window.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
|
||||
</span>
|
||||
<span className="font-mono">{window.remainingPercent}%</span>
|
||||
@@ -285,7 +287,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
{quota.buckets.map((b) => (
|
||||
<div key={b.id} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
||||
<span className={cn(b.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{b.label}
|
||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||
</span>
|
||||
@@ -327,8 +329,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
return (
|
||||
<div key={label} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className={cn(isLow && 'text-red-500')}>{label}</span>
|
||||
<span className={cn('font-mono', isLow && 'text-red-500')}>
|
||||
<span className={cn(isLow && lowQuotaTextClass)}>{label}</span>
|
||||
<span className={cn('font-mono', isLow && lowQuotaTextClass)}>
|
||||
{snapshot.unlimited
|
||||
? 'Unlimited'
|
||||
: `${formatQuotaPercent(snapshot.percentRemaining)}%`}
|
||||
|
||||
@@ -42,7 +42,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10',
|
||||
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[calc(100vw-2rem)] origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -131,14 +131,21 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getByText('Business'));
|
||||
expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0);
|
||||
const businessPlan = (await screen.findAllByText('Plan: team')).find((node) =>
|
||||
node.closest('[data-slot="tooltip-content"]')
|
||||
);
|
||||
expect(businessPlan).toBeInTheDocument();
|
||||
expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0);
|
||||
const tooltipContent = document.querySelector('[data-slot="tooltip-content"]');
|
||||
const tooltipContent = businessPlan.closest('[data-slot="tooltip-content"]');
|
||||
expect(tooltipContent?.className).toContain('bg-popover');
|
||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||
|
||||
await userEvent.hover(screen.getByText('Personal'));
|
||||
expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0);
|
||||
const personalPlan = (await screen.findAllByText('Plan: plus')).find((node) =>
|
||||
node.closest('[data-slot="tooltip-content"]')
|
||||
);
|
||||
expect(personalPlan).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel';
|
||||
import type { GeminiCliQuotaResult } from '@/lib/api-client';
|
||||
|
||||
function createGeminiFailureQuota(): GeminiCliQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
buckets: [],
|
||||
projectId: 'test-project',
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Request had invalid authentication credentials.',
|
||||
httpStatus: 401,
|
||||
errorCode: 'UNAUTHENTICATED',
|
||||
errorDetail:
|
||||
'{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}',
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
needsReauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccountQuotaPanel failure tooltip', () => {
|
||||
it('renders the shared failure tooltip content with viewport-safe shell classes', async () => {
|
||||
render(
|
||||
<AccountQuotaPanel
|
||||
provider="gemini"
|
||||
quota={createGeminiFailureQuota()}
|
||||
quotaLoading={false}
|
||||
mode="detailed"
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getByText('Reauth'));
|
||||
|
||||
const summary = (
|
||||
await screen.findAllByText('Request had invalid authentication credentials.')
|
||||
).find((node) => node.closest('[data-slot="tooltip-content"]'));
|
||||
expect(summary).toBeInTheDocument();
|
||||
const tooltipContent = summary.closest('[data-slot="tooltip-content"]');
|
||||
const actionHint = screen
|
||||
.getAllByText('Run ccs gemini --auth to reconnect this account.')
|
||||
.find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent);
|
||||
const technicalDetail = screen
|
||||
.getAllByText('HTTP 401 | UNAUTHENTICATED')
|
||||
.find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent);
|
||||
expect(actionHint).toBeInTheDocument();
|
||||
expect(technicalDetail).toBeInTheDocument();
|
||||
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||
expect(tooltipContent?.className).toContain('bg-popover');
|
||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,7 @@ describe('QuotaTooltipContent', () => {
|
||||
needsReauth: true,
|
||||
});
|
||||
|
||||
render(<QuotaTooltipContent quota={quota} resetTime={null} />);
|
||||
const { container } = render(<QuotaTooltipContent quota={quota} resetTime={null} />);
|
||||
|
||||
expect(screen.getByText('Reauth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument();
|
||||
@@ -113,5 +113,6 @@ describe('QuotaTooltipContent', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument();
|
||||
expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument();
|
||||
expect(container.firstChild).not.toHaveClass('min-w-[16rem]');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user