fix(cliproxy): degrade gracefully on GitHub release 403s

- fall back to stale CLIProxy version caches instead of surfacing 500s

- remove duplicate dashboard update-check fetches and disable retry/focus refetch

- add backend and UI regressions for the degraded-path behavior
This commit is contained in:
Tam Nhu Tran
2026-04-13 22:19:16 -04:00
parent 4874c154cb
commit 4e15ec5387
8 changed files with 418 additions and 86 deletions
+38 -4
View File
@@ -33,6 +33,23 @@ export function getVersionPinPath(backend: CLIProxyBackend = DEFAULT_BACKEND): s
* Read version cache if still valid (backend-specific)
*/
export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): VersionCache | null {
return readVersionCacheInternal(backend, false);
}
/**
* Read version cache even if expired.
* Used as a resilience fallback when GitHub release lookups fail.
*/
export function readStaleVersionCache(
backend: CLIProxyBackend = DEFAULT_BACKEND
): VersionCache | null {
return readVersionCacheInternal(backend, true);
}
function readVersionCacheInternal(
backend: CLIProxyBackend,
allowExpired: boolean
): VersionCache | null {
const cachePath = getVersionCachePath(backend);
if (!fs.existsSync(cachePath)) {
return null;
@@ -42,8 +59,8 @@ export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): Ve
const content = fs.readFileSync(cachePath, 'utf8');
const cache: VersionCache = JSON.parse(content);
// Check if cache is still valid
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
// Check if cache is still valid, unless caller explicitly allows stale fallback.
if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
return cache;
}
@@ -192,6 +209,23 @@ export function getVersionListCachePath(backend: CLIProxyBackend = DEFAULT_BACKE
*/
export function readVersionListCache(
backend: CLIProxyBackend = DEFAULT_BACKEND
): VersionListCache | null {
return readVersionListCacheInternal(backend, false);
}
/**
* Read version list cache even if expired.
* Used as a resilience fallback when GitHub release lookups fail.
*/
export function readStaleVersionListCache(
backend: CLIProxyBackend = DEFAULT_BACKEND
): VersionListCache | null {
return readVersionListCacheInternal(backend, true);
}
function readVersionListCacheInternal(
backend: CLIProxyBackend,
allowExpired: boolean
): VersionListCache | null {
const cachePath = getVersionListCachePath(backend);
if (!fs.existsSync(cachePath)) {
@@ -202,8 +236,8 @@ export function readVersionListCache(
const content = fs.readFileSync(cachePath, 'utf8');
const cache: VersionListCache = JSON.parse(content);
// Check if cache is still valid (1 hour)
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
// Check if cache is still valid, unless caller explicitly allows stale fallback.
if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
return cache;
}
+89 -37
View File
@@ -6,9 +6,11 @@
import { fetchJson } from './downloader';
import {
readVersionCache,
readStaleVersionCache,
writeVersionCache,
readInstalledVersion,
readVersionListCache,
readStaleVersionListCache,
writeVersionListCache,
} from './version-cache';
import { UpdateCheckResult, VersionListResult, getGitHubApiUrls } from './types';
@@ -19,6 +21,18 @@ import {
} from '../platform-detector';
import type { CLIProxyBackend } from '../types';
interface FetchLatestVersionDeps {
fetchJsonFn?: typeof fetchJson;
}
interface CheckForUpdatesDeps {
fetchLatestVersionFn?: (verbose: boolean, backend: CLIProxyBackend) => Promise<string>;
}
interface FetchAllVersionsDeps {
fetchJsonFn?: typeof fetchJson;
}
/**
* Compare semver versions (true if latest > current)
* Handles CLIProxyAPIPlus versioning: strips -0 suffix before comparison
@@ -61,10 +75,11 @@ export function isVersionFaulty(version: string): boolean {
*/
export async function fetchLatestVersion(
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
backend: CLIProxyBackend = DEFAULT_BACKEND,
deps: FetchLatestVersionDeps = {}
): Promise<string> {
const urls = getGitHubApiUrls(backend);
const response = await fetchJson(urls.latestRelease, verbose);
const response = await (deps.fetchJsonFn ?? fetchJson)(urls.latestRelease, verbose);
// Extract version from tag_name (format: "v6.5.27" or "6.5.27")
const tagName = response.tag_name as string;
@@ -84,7 +99,8 @@ export async function checkForUpdates(
binPath: string,
configVersion: string,
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
backend: CLIProxyBackend = DEFAULT_BACKEND,
deps: CheckForUpdatesDeps = {}
): Promise<UpdateCheckResult> {
const currentVersion = readInstalledVersion(binPath, configVersion);
@@ -103,18 +119,38 @@ export async function checkForUpdates(
};
}
// Fetch from GitHub API (backend-specific repo)
const latestVersion = await fetchLatestVersion(verbose, backend);
const now = Date.now();
writeVersionCache(latestVersion, backend);
try {
// Fetch from GitHub API (backend-specific repo)
const latestVersion = await (deps.fetchLatestVersionFn ?? fetchLatestVersion)(verbose, backend);
const now = Date.now();
writeVersionCache(latestVersion, backend);
return {
hasUpdate: isNewerVersion(latestVersion, currentVersion),
currentVersion,
latestVersion,
fromCache: false,
checkedAt: now,
};
return {
hasUpdate: isNewerVersion(latestVersion, currentVersion),
currentVersion,
latestVersion,
fromCache: false,
checkedAt: now,
};
} catch (error) {
const staleCache = readStaleVersionCache(backend);
if (staleCache) {
if (verbose) {
console.error(
`[cliproxy] GitHub latest release lookup failed, using stale cache: ${(error as Error).message}`
);
}
return {
hasUpdate: isNewerVersion(staleCache.latestVersion, currentVersion),
currentVersion,
latestVersion: staleCache.latestVersion,
fromCache: true,
checkedAt: staleCache.checkedAt,
};
}
throw error;
}
}
/**
@@ -125,7 +161,8 @@ export async function checkForUpdates(
*/
export async function fetchAllVersions(
verbose = false,
backend: CLIProxyBackend = DEFAULT_BACKEND
backend: CLIProxyBackend = DEFAULT_BACKEND,
deps: FetchAllVersionsDeps = {}
): Promise<VersionListResult> {
// Try cache first (backend-specific)
const cache = readVersionListCache(backend);
@@ -136,31 +173,46 @@ export async function fetchAllVersions(
return { ...cache, fromCache: true };
}
// Fetch from GitHub API (backend-specific repo)
const urls = getGitHubApiUrls(backend);
const response = await fetchJson(urls.allReleases, verbose);
try {
// Fetch from GitHub API (backend-specific repo)
const urls = getGitHubApiUrls(backend);
const response = await (deps.fetchJsonFn ?? fetchJson)(urls.allReleases, verbose);
// Extract and normalize versions
const releases = response as unknown as Array<{ tag_name: string }>;
const versions = releases
.map((r) => r.tag_name.replace(/^v/, ''))
.filter((v) => /^\d+\.\d+\.\d+(-\d+)?$/.test(v)); // Valid semver only
// Extract and normalize versions
const releases = response as unknown as Array<{ tag_name: string }>;
const versions = releases
.map((r) => r.tag_name.replace(/^v/, ''))
.filter((v) => /^\d+\.\d+\.\d+(-\d+)?$/.test(v)); // Valid semver only
const latest = versions[0] || '';
const latest = versions[0] || '';
// Find latest stable (not newer than max stable AND not in faulty range)
const latestStable =
versions.find((v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION) && !isVersionFaulty(v)) ||
CLIPROXY_MAX_STABLE_VERSION;
// Find latest stable (not newer than max stable AND not in faulty range)
const latestStable =
versions.find(
(v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION) && !isVersionFaulty(v)
) || CLIPROXY_MAX_STABLE_VERSION;
const result: VersionListResult = {
versions,
latestStable,
latest,
fromCache: false,
checkedAt: Date.now(),
};
const result: VersionListResult = {
versions,
latestStable,
latest,
fromCache: false,
checkedAt: Date.now(),
};
writeVersionListCache(result, backend);
return result;
writeVersionListCache(result, backend);
return result;
} catch (error) {
const staleCache = readStaleVersionListCache(backend);
if (staleCache) {
if (verbose) {
console.error(
`[cliproxy] GitHub release list lookup failed, using stale cache: ${(error as Error).message}`
);
}
return { ...staleCache, fromCache: true };
}
throw error;
}
}
+86 -10
View File
@@ -153,6 +153,89 @@ function getConfiguredBackend() {
}
}
function buildUpdateCheckFallback(
backend: ReturnType<typeof getConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION);
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
fromCache: true,
checkedAt: Date.now(),
backend,
backendLabel,
isStable,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
stabilityMessage: isStable
? undefined
: `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`,
};
}
function buildVersionsFallback(
backend: ReturnType<typeof getConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
return {
versions: currentVersion ? [currentVersion] : [],
latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
fromCache: true,
checkedAt: Date.now(),
currentVersion,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
};
}
interface ResolveUpdateCheckDeps {
checkCliproxyUpdateFn?: typeof checkCliproxyUpdate;
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
}
interface ResolveVersionsDeps {
fetchAllVersionsFn?: typeof fetchAllVersions;
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
}
export async function resolveCliproxyUpdateCheckPayload(
backend: ReturnType<typeof getConfiguredBackend>,
deps: ResolveUpdateCheckDeps = {}
) {
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
return checkCliproxyUpdateFn(backend).catch(() =>
buildUpdateCheckFallback(backend, getInstalledVersionFn)
);
}
export async function resolveCliproxyVersionsPayload(
backend: ReturnType<typeof getConfiguredBackend>,
deps: ResolveVersionsDeps = {}
) {
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
const result = await fetchAllVersionsFn(false, backend).catch(() => null);
if (!result) {
return buildVersionsFallback(backend, getInstalledVersionFn);
}
return {
...result,
currentVersion: getInstalledVersionFn(backend),
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
};
}
/**
* Extract status code and model from error log file (lightweight parsing).
* Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop.
@@ -327,7 +410,8 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getConfiguredBackend();
const result = await checkCliproxyUpdate(backend);
const result = await resolveCliproxyUpdateCheckPayload(backend);
res.json(result);
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
@@ -936,15 +1020,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getConfiguredBackend();
const result = await fetchAllVersions(false, backend);
const currentVersion = getInstalledCliproxyVersion(backend);
res.json({
...result,
currentVersion,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
});
res.json(await resolveCliproxyVersionsPayload(backend));
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
res.status(500).json({ error: 'Internal server error' });
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
describe('version-checker stale cache fallback', () => {
let originalCcsHome: string | undefined;
let tempHome = '';
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-version-checker-'));
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('uses a stale latest-version cache when GitHub lookup fails', async () => {
const {
getVersionCachePath,
writeInstalledVersion,
} = await import('../../../src/cliproxy/binary/version-cache');
const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types');
const { checkForUpdates } = await import('../../../src/cliproxy/binary/version-checker');
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
fs.mkdirSync(plusBinDir, { recursive: true });
writeInstalledVersion(plusBinDir, '6.6.80');
fs.writeFileSync(
getVersionCachePath('plus'),
JSON.stringify({
latestVersion: '6.9.23-0',
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
}),
'utf8'
);
const result = await checkForUpdates(plusBinDir, '6.6.80', false, 'plus', {
fetchLatestVersionFn: async () => {
throw new Error('GitHub API error: HTTP 403');
},
});
expect(result.latestVersion).toBe('6.9.23-0');
expect(result.currentVersion).toBe('6.6.80');
expect(result.hasUpdate).toBe(true);
expect(result.fromCache).toBe(true);
});
it('uses a stale release-list cache when GitHub list lookup fails', async () => {
const { getVersionListCachePath } = await import('../../../src/cliproxy/binary/version-cache');
const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types');
const { fetchAllVersions } = await import('../../../src/cliproxy/binary/version-checker');
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
fs.mkdirSync(plusBinDir, { recursive: true });
fs.writeFileSync(
getVersionListCachePath('plus'),
JSON.stringify({
versions: ['6.9.23-0', '6.9.22-0', '6.9.19-0'],
latestStable: '6.9.23-0',
latest: '6.9.23-0',
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
}),
'utf8'
);
const result = await fetchAllVersions(false, 'plus', {
fetchJsonFn: async () => {
throw new Error('GitHub API error: HTTP 403');
},
});
expect(result.versions).toEqual(['6.9.23-0', '6.9.22-0', '6.9.19-0']);
expect(result.latestStable).toBe('6.9.23-0');
expect(result.latest).toBe('6.9.23-0');
expect(result.fromCache).toBe(true);
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'bun:test';
describe('cliproxy-stats-routes version fallback', () => {
it('returns a degraded update-check payload instead of propagating a 500', async () => {
const { resolveCliproxyUpdateCheckPayload } = await import(
'../../../src/web-server/routes/cliproxy-stats-routes'
);
const body = await resolveCliproxyUpdateCheckPayload('plus', {
checkCliproxyUpdateFn: async () => {
throw new Error('GitHub API error: HTTP 403');
},
getInstalledVersionFn: () => '6.6.80',
});
expect(body).toMatchObject({
hasUpdate: false,
currentVersion: '6.6.80',
latestVersion: '6.6.80',
backend: 'plus',
backendLabel: 'CLIProxy Plus',
fromCache: true,
});
});
it('returns a degraded versions payload instead of propagating a 500', async () => {
const { resolveCliproxyVersionsPayload } = await import(
'../../../src/web-server/routes/cliproxy-stats-routes'
);
const body = await resolveCliproxyVersionsPayload('plus', {
fetchAllVersionsFn: async () => {
throw new Error('GitHub API error: HTTP 403');
},
getInstalledVersionFn: () => '6.6.80',
});
expect(body).toMatchObject({
versions: ['6.6.80'],
latestStable: '6.6.80',
latest: '6.6.80',
currentVersion: '6.6.80',
fromCache: true,
});
});
});
+10 -33
View File
@@ -3,22 +3,15 @@
* Fixed header with OAuth login buttons, status indicator, and refresh
*/
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyAuth, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { cn } from '@/lib/utils';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
interface VersionInfo {
currentVersion: string;
isStable: boolean;
stabilityMessage?: string;
backendLabel?: string;
}
interface LoginButtonProps {
provider: string;
displayName: string;
@@ -116,25 +109,9 @@ export function CliproxyHeader({
isRunning = true,
}: CliproxyHeaderProps) {
const { data: authData } = useCliproxyAuth();
const { data: updateCheck } = useCliproxyUpdateCheck();
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
const lastUpdatedText = useRelativeTime(lastUpdated);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
useEffect(() => {
fetch('/api/cliproxy/update-check')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) {
setVersionInfo({
currentVersion: data.currentVersion,
isStable: data.isStable,
stabilityMessage: data.stabilityMessage,
backendLabel: data.backendLabel,
});
}
})
.catch(() => {}); // Silently fail
}, []);
const providers = CLIPROXY_PROVIDERS.map((id) => ({
id,
@@ -155,7 +132,7 @@ export function CliproxyHeader({
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{versionInfo?.backendLabel ?? 'CLIProxy'}
{updateCheck?.backendLabel ?? 'CLIProxy'}
</h1>
<p className="text-sm text-muted-foreground mt-1">CCS-level account management</p>
</div>
@@ -191,18 +168,18 @@ export function CliproxyHeader({
{isRunning ? 'Running' : 'Offline'}
</Badge>
{versionInfo && (
{updateCheck && (
<Badge
variant={versionInfo.isStable ? 'secondary' : 'destructive'}
variant={updateCheck.isStable ? 'secondary' : 'destructive'}
className={cn(
'gap-1.5',
!versionInfo.isStable &&
!updateCheck.isStable &&
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
title={versionInfo.stabilityMessage}
title={updateCheck.stabilityMessage}
>
{!versionInfo.isStable && <AlertTriangle className="w-3 h-3" />}v
{versionInfo.currentVersion}
{!updateCheck.isStable && <AlertTriangle className="w-3 h-3" />}v
{updateCheck.currentVersion}
</Badge>
)}
+4 -2
View File
@@ -447,7 +447,8 @@ export function useCliproxyUpdateCheck() {
queryFn: () => api.cliproxy.updateCheck(),
staleTime: 5 * 60 * 1000, // 5 minutes (reduced from 1 hour for faster backend switch response)
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
refetchOnWindowFocus: true, // Refetch on window focus to catch backend changes
refetchOnWindowFocus: false, // Avoid refetch bursts for non-critical release metadata
retry: false,
});
}
@@ -492,7 +493,8 @@ export function useCliproxyVersions() {
queryKey: ['cliproxy-versions'],
queryFn: () => api.cliproxy.versions(),
staleTime: 5 * 60 * 1000, // 5 minutes (reduced for faster backend switch response)
refetchOnWindowFocus: true, // Refetch on focus to catch backend changes
refetchOnWindowFocus: false, // Avoid repeated release lookups while browsing the dashboard
retry: false,
});
}
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../setup/test-utils';
const hookMocks = vi.hoisted(() => ({
startAuth: vi.fn(),
}));
vi.mock('@/hooks/use-cliproxy', () => ({
useCliproxyAuth: () => ({
data: {
authStatus: [],
},
}),
useCliproxyUpdateCheck: () => ({
data: {
backendLabel: 'CLIProxy Plus',
currentVersion: '6.9.23-0',
isStable: true,
stabilityMessage: undefined,
},
}),
}));
vi.mock('@/hooks/use-cliproxy-auth-flow', () => ({
useCliproxyAuthFlow: () => ({
provider: null,
isAuthenticating: false,
startAuth: hookMocks.startAuth,
}),
}));
import { CliproxyHeader } from '@/components/cliproxy/cliproxy-header';
describe('CliproxyHeader', () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders version data from the shared update query without issuing a direct fetch', () => {
render(
<CliproxyHeader onRefresh={vi.fn()} isRefreshing={false} lastUpdated={new Date()} isRunning />
);
expect(screen.getByText('CLIProxy Plus')).toBeInTheDocument();
expect(screen.getByText('v6.9.23-0')).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
});