Refactor API fetching code, handle all possible errors correctly

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-03-07 21:00:43 +01:00
parent 92195e3fb6
commit 5fea050fed
10 changed files with 85 additions and 90 deletions

View file

@ -16,79 +16,79 @@ export type ErrorAPIResponse = {
export type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse;
export const useAPIQuery = <T>({
key,
path,
params,
enabled,
}: {
const createQueryFn =
<T>({ path, params }: { path: string; params?: Record<string, string> }) =>
async ({ signal }: { signal: AbortSignal }) => {
const queryString = params
? `?${new URLSearchParams(params).toString()}`
: "";
try {
const res = await fetch(`/${API_PATH}/${path}${queryString}`, {
cache: "no-store",
credentials: "same-origin",
signal,
});
if (
!res.ok &&
!res.headers.get("content-type")?.startsWith("application/json")
) {
// For example, Prometheus may send a 503 Service Unavailable response
// with a "text/plain" content type when it's starting up. But the API
// may also respond with a JSON error message and the same error code.
throw new Error(res.statusText);
}
const apiRes = (await res.json()) as APIResponse<T>;
if (apiRes.status === "error") {
throw new Error(
apiRes.error !== undefined
? apiRes.error
: 'missing "error" field in response JSON'
);
}
return apiRes as SuccessAPIResponse<T>;
} catch (error) {
if (!(error instanceof Error)) {
throw new Error("Unknown error");
}
switch (error.name) {
case "TypeError":
throw new Error("Network error or unable to reach the server");
case "SyntaxError":
throw new Error("Invalid JSON response");
default:
throw error;
}
}
};
type QueryOptions = {
key?: string;
path: string;
params?: Record<string, string>;
enabled?: boolean;
}) =>
useQuery<APIResponse<T>>({
queryKey: [key || path],
};
export const useAPIQuery = <T>({ key, path, params, enabled }: QueryOptions) =>
useQuery<SuccessAPIResponse<T>>({
queryKey: key ? [key] : [path, params],
retry: false,
refetchOnWindowFocus: false,
gcTime: 0,
enabled,
queryFn: async ({ signal }) => {
const queryString = params
? `?${new URLSearchParams(params).toString()}`
: "";
return (
fetch(`/${API_PATH}/${path}${queryString}`, {
cache: "no-store",
credentials: "same-origin",
signal,
})
// TODO: think about how to check API errors here, if this code remains in use.
.then((res) => {
if (!res.ok) {
throw new Error(res.statusText);
}
return res;
})
.then((res) => res.json() as Promise<APIResponse<T>>)
);
},
queryFn: createQueryFn({ path, params }),
});
export const useSuspenseAPIQuery = <T>(
path: string,
params?: Record<string, string>
) =>
export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) =>
useSuspenseQuery<SuccessAPIResponse<T>>({
queryKey: [path],
queryKey: key ? [key] : [path, params],
retry: false,
refetchOnWindowFocus: false,
gcTime: 0,
queryFn: ({ signal }) => {
const queryString = params
? `?${new URLSearchParams(params).toString()}`
: "";
return (
fetch(`/${API_PATH}/${path}${queryString}`, {
cache: "no-store",
credentials: "same-origin",
signal,
})
// Introduce 3 seconds delay to simulate slow network.
// .then(
// (res) =>
// new Promise<typeof res>((resolve) =>
// setTimeout(() => resolve(res), 2000)
// )
// )
// TODO: think about how to check API errors here, if this code remains in use.
.then((res) => {
if (!res.ok) {
throw new Error(res.statusText);
}
return res;
})
.then((res) => res.json() as Promise<SuccessAPIResponse<T>>)
);
},
queryFn: createQueryFn({ path, params }),
});

View file

@ -0,0 +1,3 @@
export default interface ConfigResult {
yaml: string;
}

View file

@ -17,8 +17,11 @@ import { formatRelative, now } from "../lib/formatTime";
import { Fragment, useState } from "react";
export default function AlertsPage() {
const { data } = useSuspenseAPIQuery<AlertingRulesMap>(`/rules`, {
type: "alert",
const { data } = useSuspenseAPIQuery<AlertingRulesMap>({
path: `/rules`,
params: {
type: "alert",
},
});
const [showAnnotations, setShowAnnotations] = useState(false);

View file

@ -1,17 +1,14 @@
import { CodeHighlight } from "@mantine/code-highlight";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSuspenseAPIQuery } from "../api/api";
import ConfigResult from "../api/responseTypes/config";
export default function ConfigPage() {
const {
data: {
data: { yaml },
},
} = useSuspenseQuery<{ data: { yaml: string } }>({
queryKey: ["config"],
queryFn: () => {
return fetch("/api/v1/status/config").then((res) => res.json());
},
});
} = useSuspenseAPIQuery<ConfigResult>({ path: `/status/config` });
return (
<CodeHighlight
code={yaml}

View file

@ -83,7 +83,9 @@ function sortData(
}
export default function FlagsPage() {
const { data } = useSuspenseAPIQuery<Record<string, string>>(`/status/flags`);
const { data } = useSuspenseAPIQuery<Record<string, string>>({
path: `/status/flags`,
});
const flags = Object.entries(data.data).map(([flag, value]) => ({
flag,

View file

@ -28,7 +28,7 @@ const healthBadgeClass = (state: string) => {
};
export default function RulesPage() {
const { data } = useSuspenseAPIQuery<RulesMap>(`/rules`);
const { data } = useSuspenseAPIQuery<RulesMap>({ path: `/rules` });
return (
<>

View file

@ -28,10 +28,12 @@ const statusConfig: Record<
};
export default function StatusPage() {
const { data: buildinfo } =
useSuspenseAPIQuery<Record<string, string>>(`/status/buildinfo`);
const { data: runtimeinfo } =
useSuspenseAPIQuery<Record<string, string>>(`/status/runtimeinfo`);
const { data: buildinfo } = useSuspenseAPIQuery<Record<string, string>>({
path: `/status/buildinfo`,
});
const { data: runtimeinfo } = useSuspenseAPIQuery<Record<string, string>>({
path: `/status/runtimeinfo`,
});
return (
<Stack gap="lg" maw={1000} mx="auto" mt="lg">

View file

@ -13,7 +13,7 @@ export default function TSDBStatusPage() {
seriesCountByLabelValuePair,
},
},
} = useSuspenseAPIQuery<TSDBMap>(`/status/tsdb`);
} = useSuspenseAPIQuery<TSDBMap>({ path: `/status/tsdb` });
const unixToTime = (unix: number): string => {
try {

View file

@ -81,11 +81,6 @@ const DataTable: FC<TableProps> = ({ expr, evalTime, retriggerIdx }) => {
return <Alert variant="transparent">No data queried yet</Alert>;
}
if (data.status !== "success") {
// TODO: Remove this case and handle it in useAPIQuery instead!
return null;
}
const { result, resultType } = data.data;
if (result.length === 0) {

View file

@ -148,10 +148,6 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
}
if (formatResult) {
if (formatResult.status !== "success") {
// TODO: Remove this case and handle it in useAPIQuery instead!
return;
}
setExpr(formatResult.data);
notifications.show({
color: "green",
@ -222,10 +218,7 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
}
onClick={() => formatQuery()}
disabled={
isFormatting ||
expr === "" ||
(formatResult?.status === "success" &&
expr === formatResult.data)
isFormatting || expr === "" || expr === formatResult?.data
}
>
Format expression