refactor: simplify request handlers and deduplicate config

- Re-enable TanStack Query retry (retry: 3 with exponential backoff),
  delete all server-side retry logic from request handlers
- Strip handler factories to bare minimum: remove isValid, retry, widgetKind
- Remove redundant refetchOnWindowFocus/refetchOnReconnect from docker-table
- Deduplicate queryCacheMaxValueBytes (pass as param, single source of truth)
- Simplify system-resources chart: use dataUpdatedAt instead of ref-based tracking
- Fix leading blank lines in 27 request handler files
This commit is contained in:
Thomas Camlong committed 2026-06-25 11:06:03 +02:00
1 parent a164c951ce
commit 5581b6db94
15 files changed
+36 -114

No files matched your search

@@ -71,7 +71,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
gcTime: queryCacheDefaultGcTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
retry: 3,
},
mutations: {
onError(error) {
@@ -156,8 +156,6 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
+1 -1
View File
@@ -25,7 +25,7 @@ export const queryCacheRouter = createTRPCRouter({
await validateBoardAccessAsync(ctx, input.boardId);
const userId = ctx.session?.user.id ?? "anonymous";
return {
stored: await setQueryCacheAsync(userId, input.boardId, input.value, queryCacheDefaultGcTimeMs),
stored: await setQueryCacheAsync(userId, input.boardId, input.value, queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes),
};
}),
removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
+2 -2
View File
@@ -19,8 +19,8 @@ const redisMock = vi.hoisted(() => {
return {
values,
setQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string, value: string, _ttlMs: number) => {
if (Buffer.byteLength(value, "utf8") > 1024 * 1024) return false;
setQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
values.set(`${_userId}:${_boardId}`, value);
return true;
}),
+3 -4
View File
@@ -129,14 +129,13 @@ export const createGetSetChannel = <TData>(name: string) => {
};
const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
const queryCacheMaxValueBytes = 1024 * 1024;
export const setQueryCacheAsync = async (userId: string, boardId: string, value: string, ttlMs: number) => {
if (Buffer.byteLength(value, "utf8") > queryCacheMaxValueBytes) {
export const setQueryCacheAsync = async (userId: string, boardId: string, value: string, ttlMs: number, maxValueBytes: number) => {
if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
logger.warn("Query cache value exceeded maximum size", {
key: queryCacheKey(userId, boardId),
valueBytes: Buffer.byteLength(value, "utf8"),
maxValueBytes: queryCacheMaxValueBytes,
maxValueBytes,
});
return false;
}
-3
View File
@@ -73,7 +73,6 @@ export const beszelSystemsRequestHandler = createIntegrationRequestHandler<
});
return enriched;
},
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelSystems",
});
@@ -100,7 +99,6 @@ export const beszelAlertsRequestHandler = createIntegrationRequestHandler<
});
return { alerts, history };
},
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelAlerts",
});
@@ -141,6 +139,5 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
});
return { systemStats, containerStats };
},
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelStats",
});
-1
View File
@@ -172,7 +172,6 @@ const mockContainers: {
export const dockerContainersRequestHandler = createWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
if (isDemoMode) {
return mockContainers;
@@ -13,8 +13,6 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
queryKey: string;
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
retry?: { attempts?: number; delayMs?: number };
isValid?: (data: TData) => boolean;
}
export const createIntegrationRequestHandler = <
@@ -23,14 +21,10 @@ export const createIntegrationRequestHandler = <
TInput extends Record<string, unknown>,
>(
options: Options<TData, TKind, TInput>,
) => {
return {
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
createRequestHandler<TData, { options: TInput; integration: IntegrationOfKind<TKind> }>({
queryKey: options.queryKey,
requestAsync: async (input) => options.requestAsync(input.integration, input.options),
retry: options.retry,
isValid: options.isValid,
}).handler({ options: itemOptions, integration }),
};
};
) => ({
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
createRequestHandler<TData, { options: TInput; integration: IntegrationOfKind<TKind> }>({
queryKey: options.queryKey,
requestAsync: async (input) => options.requestAsync(input.integration, input.options),
}).handler({ options: itemOptions, integration }),
});
@@ -1,59 +1,15 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "requestHandler" });
const isAbortedError = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
return error.name === "AbortError" || error.name === "TimeoutError";
};
interface Options<TData, TInput extends Record<string, unknown>> {
queryKey: string;
requestAsync: (input: TInput) => Promise<TData>;
retry?: { attempts?: number; delayMs?: number };
isValid?: (data: TData) => boolean;
}
const defaultRetry = { attempts: 1, delayMs: 0 };
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
return {
handler: (input: TInput) => {
const retryConfig = { ...defaultRetry, ...options.retry };
return {
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
let lastError: unknown;
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
try {
const data = await options.requestAsync(input);
if (options.isValid && !options.isValid(data)) {
logger.warn("Request handler received invalid data", {
queryKey: options.queryKey,
attempt,
});
}
return { data, timestamp: new Date() };
} catch (error) {
lastError = error;
if (isAbortedError(error) || attempt === retryConfig.attempts) {
throw error;
}
logger.warn("Request handler fetch failed, retrying", {
queryKey: options.queryKey,
attempt,
nextDelayMs: retryConfig.delayMs,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
});
await sleep(retryConfig.delayMs);
}
}
throw lastError;
},
};
) => ({
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const data = await options.requestAsync(input);
return { data, timestamp: new Date() };
},
};
};
}),
});
@@ -1,25 +1,16 @@
import type { WidgetKind } from "@homarr/definitions";
import { createRequestHandler } from "./request-handler";
interface Options<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
interface Options<TData, TInput extends Record<string, unknown>> {
queryKey: string;
requestAsync: (input: TInput) => Promise<TData>;
retry?: { attempts?: number; delayMs?: number };
isValid?: (data: TData) => boolean;
widgetKind: TKind;
}
export const createWidgetRequestHandler = <TData, TKind extends WidgetKind, TInput extends Record<string, unknown>>(
options: Options<TData, TKind, TInput>,
) => {
return {
handler: (widgetOptions: TInput) =>
createRequestHandler<TData, TInput>({
queryKey: options.queryKey,
requestAsync: options.requestAsync,
retry: options.retry,
isValid: options.isValid,
}).handler(widgetOptions),
};
};
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (widgetOptions: TInput) =>
createRequestHandler<TData, TInput>({
queryKey: options.queryKey,
requestAsync: options.requestAsync,
}).handler(widgetOptions),
});
@@ -7,7 +7,6 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
queryKey: "minecraftServerStatusApiResult",
widgetKind: "minecraftServerStatus",
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
@@ -11,7 +11,6 @@ const logger = createLogger({ module: "rssFeedsRequestHandler" });
export const rssFeedsRequestHandler = createWidgetRequestHandler({
queryKey: "rssFeedList",
widgetKind: "rssFeed",
async requestAsync(input: { url: string; count: number }) {
const result = (await extract(input.url, {
getExtraEntryFields: (feedEntry) => {
@@ -7,7 +7,6 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const fetchStockPriceHandler = createWidgetRequestHandler({
queryKey: "fetchStockPriceResult",
widgetKind: "stockPrice",
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
const response = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
-1
View File
@@ -7,7 +7,6 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const weatherRequestHandler = createWidgetRequestHandler({
queryKey: "weatherAtLocation",
widgetKind: "weather",
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { Box, Group, Stack } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -33,7 +33,7 @@ const toChartItem = (healthInfo: {
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) {
const { ref, width } = useElementSize();
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const memoryCapacityInBytes =
@@ -41,21 +41,13 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
const [items, setItems] = useState<
{ cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[]
>(() => data.map((item) => toChartItem(item.healthInfo)));
const lastUpdatedAtRef = useRef<number | null>(null);
>([]);
useEffect(() => {
const firstItem = data[0];
if (!firstItem) return;
const updatedAt = firstItem.updatedAt?.getTime?.() ?? null;
if (updatedAt !== null && updatedAt === lastUpdatedAtRef.current) return;
lastUpdatedAtRef.current = updatedAt;
const next = toChartItem(firstItem.healthInfo);
setItems((previousItems) => [...previousItems, next].slice(-MAX_QUEUE_SIZE));
}, [data]);
setItems((prev) => [...prev, toChartItem(firstItem.healthInfo)].slice(-MAX_QUEUE_SIZE));
}, [dataUpdatedAt]);
const showNetwork =
items.length === 0 || (items.every((item) => item.network !== null) && options.visibleCharts.includes("network"));