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;
}
}