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 committed 2026-04-13 22:19:16 -04:00
1 parent 4874c154cb
commit 4e15ec5387
8 files changed
+418 -86

No files matched your search

+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' });