From d65625e59978a90d5dcfd3bc01d3eec073a8153a Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:43:37 +0200 Subject: [PATCH] feat(update): enhance GitHub update handling with rate limit support and caching Added support for handling GitHub API rate limit errors in update checks and changes retrieval. Introduced a caching mechanism for GitHub API responses to improve performance. Updated environment configuration to include an optional read-only GitHub PAT for better rate limit management. --- web-nodejs/.env.example | 2 + web-nodejs/routes/settings.routes.js | 14 ++++++- web-nodejs/services/updateService.js | 62 +++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/web-nodejs/.env.example b/web-nodejs/.env.example index 8e5b0023..40a41ecf 100644 --- a/web-nodejs/.env.example +++ b/web-nodejs/.env.example @@ -85,3 +85,5 @@ BILLING_REQUIRE_WORK_REPORT=1 # GitHub update source (stable = main branch, development = dev branch) UPDATE_GITHUB_BRANCH=main +# Optional: read-only GitHub PAT for update checks (60 req/h unauthenticated → 5,000/h with token) +# UPDATE_GITHUB_TOKEN= diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js index a9c6b351..1f420249 100644 --- a/web-nodejs/routes/settings.routes.js +++ b/web-nodejs/routes/settings.routes.js @@ -1041,7 +1041,12 @@ router.get('/api/settings/updates/check', requireAuth, requirePermission('server res.json({ success: true, data: result }); } catch (err) { console.error('Update check error:', err); - res.status(500).json({ success: false, error: 'Failed to check for updates: ' + err.message }); + const rateLimited = updateService.isGithubRateLimitError(err); + res.status(rateLimited ? 503 : 500).json({ + success: false, + error: 'Failed to check for updates: ' + err.message, + code: rateLimited ? 'GITHUB_RATE_LIMIT' : undefined, + }); } }); @@ -1080,7 +1085,12 @@ router.get('/api/settings/updates/changes', requireAuth, requirePermission('serv res.json({ success: true, data: result }); } catch (err) { console.error('Get changes error:', err); - res.status(500).json({ success: false, error: 'Failed to get changed files: ' + err.message }); + const rateLimited = updateService.isGithubRateLimitError(err); + res.status(rateLimited ? 503 : 500).json({ + success: false, + error: 'Failed to get changed files: ' + err.message, + code: rateLimited ? 'GITHUB_RATE_LIMIT' : undefined, + }); } }); diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js index a43ac44a..36eca1a4 100644 --- a/web-nodejs/services/updateService.js +++ b/web-nodejs/services/updateService.js @@ -120,7 +120,11 @@ function setUpdateChannel(channelId) { } // Optional GitHub personal-access token (60 req/h without, 5 000 with) -const GITHUB_TOKEN = process.env.UPDATE_GITHUB_TOKEN || ''; +const GITHUB_TOKEN = process.env.UPDATE_GITHUB_TOKEN || process.env.GITHUB_TOKEN || ''; + +/** @type {Map} */ +const GH_GET_CACHE = new Map(); +const GH_GET_CACHE_TTL_MS = Number(process.env.UPDATE_GITHUB_CACHE_MS) || 120_000; // ---------- component definitions ---------- const COMPONENTS = { @@ -264,10 +268,47 @@ async function ghListRepoBlobPaths(ref) { // ======================== HTTP Helpers =================================== +function githubApiError(statusCode, body, headers = {}) { + const snippet = String(body || '').slice(0, 200); + const rateLimited = (statusCode === 403 || statusCode === 429) + && (/rate limit/i.test(body) || headers['x-ratelimit-remaining'] === '0'); + if (rateLimited) { + const resetRaw = headers['x-ratelimit-reset']; + const resetAt = resetRaw ? new Date(Number(resetRaw) * 1000).toISOString() : null; + const hint = GITHUB_TOKEN + ? 'GitHub API rate limit exceeded for the configured token.' + : 'GitHub API rate limit exceeded for unauthenticated requests (60/hour). Set UPDATE_GITHUB_TOKEN in the console .env — a read-only Personal Access Token raises the limit to 5,000/hour.'; + const err = new Error(resetAt ? `${hint} Resets at ${resetAt}.` : hint); + err.code = 'GITHUB_RATE_LIMIT'; + err.statusCode = statusCode; + return err; + } + const err = new Error(`GitHub API ${statusCode}: ${snippet}`); + err.statusCode = statusCode; + return err; +} + +function isGithubRateLimitError(err) { + return !!(err && (err.code === 'GITHUB_RATE_LIMIT' || /rate limit exceeded/i.test(err.message || ''))); +} + +function ghGetCacheKey(urlPath) { + const url = urlPath.startsWith('https://') ? new URL(urlPath) : new URL(urlPath, GITHUB_API); + return url.pathname + url.search; +} + /** * HTTPS GET → parsed JSON. Follows one redirect. */ -function ghGet(urlPath) { +function ghGet(urlPath, { bypassCache = false } = {}) { + const cacheKey = ghGetCacheKey(urlPath); + if (!bypassCache && GH_GET_CACHE_TTL_MS > 0) { + const cached = GH_GET_CACHE.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return Promise.resolve(cached.data); + } + } + return new Promise((resolve, reject) => { const url = urlPath.startsWith('https://') ? new URL(urlPath) : new URL(urlPath, GITHUB_API); const headers = { 'User-Agent': USER_AGENT, 'Accept': 'application/vnd.github+json' }; @@ -275,17 +316,24 @@ function ghGet(urlPath) { const req = https.get({ hostname: url.hostname, path: url.pathname + url.search, headers }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { - return ghGet(res.headers.location).then(resolve, reject); + return ghGet(res.headers.location, { bypassCache }).then(resolve, reject); } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const body = Buffer.concat(chunks).toString(); if (res.statusCode >= 400) { - return reject(new Error(`GitHub API ${res.statusCode}: ${body.slice(0, 200)}`)); + return reject(githubApiError(res.statusCode, body, res.headers)); + } + try { + const data = JSON.parse(body); + if (GH_GET_CACHE_TTL_MS > 0) { + GH_GET_CACHE.set(cacheKey, { expires: Date.now() + GH_GET_CACHE_TTL_MS, data }); + } + resolve(data); + } catch (_e) { + reject(new Error('Invalid JSON from GitHub API')); } - try { resolve(JSON.parse(body)); } - catch (_e) { reject(new Error('Invalid JSON from GitHub API')); } }); }); req.on('error', reject); @@ -2962,6 +3010,8 @@ module.exports = { getImageEmbeddedSHA, bootstrapDockerImageDeployment, getDockerUpdateInstructions, + isGithubRateLimitError, + githubApiError, COMPONENTS, NON_CRITICAL_UPDATE_FAILURES, isNonCriticalUpdateFailure,