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..8fbe589430 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,27 @@ 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) { + if (options.fallbackToStaleOnError && channelData) { + logger.warn(new ErrorWithMetadata("Cached request handler using stale cache after fetch failure", { + channel: channel.name, + queryKey: options.queryKey, + }, { cause: error }); + return channelData; + } + + throw error; + } }; if (forceUpdate) { @@ -43,8 +58,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 });