mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
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:
1 parent
4874c154cb
commit
4e15ec5387
8 files changed
+418
-86
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
Reference in new issue
Block a user