From 28dd9dc8921cdc6c218e9078cc8705b4de5ea70b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 11:59:33 +0200 Subject: [PATCH 1/3] fix: prevent GitHub releases check from blocking app on API failure Fallback to stale Redis cache when the GitHub releases fetch fails, reduce check frequency to daily, and disable Octokit throttling for reliability. --- packages/cron-jobs/src/jobs/update-checker.ts | 8 ++-- .../src/lib/cached-request-handler.ts | 41 +++++++++++++++---- .../src/test/update-checker.spec.ts | 18 ++++++++ .../request-handler/src/update-checker.ts | 6 ++- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/packages/cron-jobs/src/jobs/update-checker.ts b/packages/cron-jobs/src/jobs/update-checker.ts index b985485ab9..eea68d07bc 100644 --- a/packages/cron-jobs/src/jobs/update-checker.ts +++ b/packages/cron-jobs/src/jobs/update-checker.ts @@ -1,13 +1,11 @@ -import { EVERY_HOUR } from "@homarr/cron-jobs-core/expressions"; +import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions"; import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker"; import { createCronJob } from "../lib"; -export const updateCheckerJob = createCronJob("updateChecker", EVERY_HOUR, { +export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, { runOnStart: true, }).withCallback(async () => { const handler = updateCheckerRequestHandler.handler({}); - await handler.getCachedOrUpdatedDataAsync({ - forceUpdate: true, - }); + await handler.getCachedOrUpdatedDataAsync({}); }); diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts index d9b8dfa904..2e00543ed6 100644 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -15,6 +15,7 @@ interface Options> { options: Options, ) => ReturnType>; cacheDuration: Duration; + fallbackToStaleOnError?: boolean; } export const createCachedRequestHandler = >( @@ -26,13 +27,39 @@ export const createCachedRequestHandler = { - const data = await options.requestAsync(input); - await channel.publishAndUpdateLastStateAsync(data); - return { - data, - timestamp: new Date(), - }; + try { + const data = await options.requestAsync(input); + await channel.publishAndUpdateLastStateAsync(data); + return { + data, + timestamp: new Date(), + }; + } catch (error) { + const staleFallbackHandlers: Record { data: TData; timestamp: Date } | null | undefined> = { + enabled: () => channelData, + disabled: () => null, + }; + const staleFallbackKeyMap: Record = { + true: "enabled", + false: "disabled", + }; + const staleFallbackKey = staleFallbackKeyMap[String(Boolean(options.fallbackToStaleOnError))]; + const staleData = staleFallbackHandlers[staleFallbackKey](); + + if (staleData) { + logger.warn("Cached request handler using stale cache after fetch failure", { + channel: channel.name, + queryKey: options.queryKey, + cause: error instanceof Error ? error.message : String(error), + }); + return staleData; + } + + throw error; + } }; if (forceUpdate) { @@ -43,8 +70,6 @@ export const createCachedRequestHandler = options.cacheDuration.asMilliseconds(); diff --git a/packages/request-handler/src/test/update-checker.spec.ts b/packages/request-handler/src/test/update-checker.spec.ts index 4c8c1e94b4..f615b64811 100644 --- a/packages/request-handler/src/test/update-checker.spec.ts +++ b/packages/request-handler/src/test/update-checker.spec.ts @@ -111,6 +111,24 @@ describe("getAvailableUpdatesAsync", () => { // Assert expect(result).toEqual([]); }); + + test("should propagate GitHub API auth errors without blocking callers", async () => { + const currentVersion = "v1.2.3"; + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockRejectedValue(new Error("Bad credentials")); + + await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("Bad credentials"); + }); + + test("should propagate GitHub API quota exhausted errors without blocking callers", async () => { + const currentVersion = "v1.2.3"; + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockRejectedValue( + new Error("Request quota exhausted for request GET /repos/{owner}/{repo}/releases"), + ); + + await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("quota exhausted"); + }); }); const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease); diff --git a/packages/request-handler/src/update-checker.ts b/packages/request-handler/src/update-checker.ts index bd09ce89d4..8709f20c52 100644 --- a/packages/request-handler/src/update-checker.ts +++ b/packages/request-handler/src/update-checker.ts @@ -15,7 +15,8 @@ const logger = createLogger({ module: "updateCheckerRequestHandler" }); export const updateCheckerRequestHandler = createCachedRequestHandler({ queryKey: "homarr-update-checker", - cacheDuration: dayjs.duration(1, "hour"), + cacheDuration: dayjs.duration(1, "day"), + fallbackToStaleOnError: true, async requestAsync(_) { return { availableUpdates: await getAvailableUpdatesAsync(packageJson.version), @@ -58,6 +59,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => { request: { fetch: fetchWithTrustedCertificatesAsync, }, + throttle: { enabled: false }, }); const isCurrentPrerelease = isPrereleaseTag(currentVersion); @@ -100,7 +102,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => { const availableUpdates = semanticReleases .filter((release) => isCurrentPrerelease || !release.isPrerelease) .filter((release) => compareSemVer(release.tagName, currentVersion) > 0) - .sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName)); + .toSorted((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName)); if (availableUpdates.length === 0) { logger.debug("No available updates found", { currentVersion }); From c83511f32bb9a0b00607f460435d0c022ccd1c76 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 20:29:40 +0200 Subject: [PATCH 2/3] refactor(request-handler): simplify stale cache fallback on fetch error Replace lookup-table indirection with a direct flag check when returning cached Redis data after an upstream fetch failure. --- .../src/lib/cached-request-handler.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts index 2e00543ed6..d2fe7fe0d2 100644 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -38,24 +38,13 @@ export const createCachedRequestHandler = { data: TData; timestamp: Date } | null | undefined> = { - enabled: () => channelData, - disabled: () => null, - }; - const staleFallbackKeyMap: Record = { - true: "enabled", - false: "disabled", - }; - const staleFallbackKey = staleFallbackKeyMap[String(Boolean(options.fallbackToStaleOnError))]; - const staleData = staleFallbackHandlers[staleFallbackKey](); - - if (staleData) { + if (options.fallbackToStaleOnError && channelData) { logger.warn("Cached request handler using stale cache after fetch failure", { channel: channel.name, queryKey: options.queryKey, cause: error instanceof Error ? error.message : String(error), }); - return staleData; + return channelData; } throw error; From 20324e4567d1a22d4b055a738084f0025d6202ad Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 29 May 2026 20:58:34 +0200 Subject: [PATCH 3/3] Update packages/request-handler/src/lib/cached-request-handler.ts Co-authored-by: Meier Lukas --- packages/request-handler/src/lib/cached-request-handler.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts index d2fe7fe0d2..8fbe589430 100644 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -39,11 +39,10 @@ export const createCachedRequestHandler =