Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions packages/cron-jobs/src/jobs/update-checker.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
41 changes: 33 additions & 8 deletions packages/request-handler/src/lib/cached-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
options: Options<TData, TInput>,
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
cacheDuration: Duration;
fallbackToStaleOnError?: boolean;
}

export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
Expand All @@ -26,13 +27,39 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,

return {
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
const channelData = await channel.getAsync();

const requestNewDataAsync = async () => {
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<string, () => { data: TData; timestamp: Date } | null | undefined> = {
enabled: () => channelData,
disabled: () => null,
};
const staleFallbackKeyMap: Record<string, string> = {
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) {
Expand All @@ -43,8 +70,6 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
return await requestNewDataAsync();
}

const channelData = await channel.getAsync();

const shouldRequestNewData =
!channelData ||
dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds();
Expand Down
18 changes: 18 additions & 0 deletions packages/request-handler/src/test/update-checker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/request-handler/src/update-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -58,6 +59,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => {
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
throttle: { enabled: false },
});

const isCurrentPrerelease = isPrereleaseTag(currentVersion);
Expand Down Expand Up @@ -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 });
Expand Down