diff --git a/packages/cron-jobs/src/jobs/update-checker.ts b/packages/cron-jobs/src/jobs/update-checker.ts index b985485ab..eea68d07b 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 d9b8dfa90..975448914 100644 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -1,6 +1,7 @@ import dayjs from "dayjs"; import type { Duration } from "dayjs/plugin/duration"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { createChannelWithLatestAndEvents } from "@homarr/redis"; @@ -15,6 +16,7 @@ interface Options> { options: Options, ) => ReturnType>; cacheDuration: Duration; + fallbackToStaleOnError?: boolean; } export const createCachedRequestHandler = >( @@ -26,13 +28,33 @@ 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 +65,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 4c8c1e94b..f615b6481 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 bd09ce89d..8709f20c5 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 });