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 type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse;
export const useAPIQuery = <T>({ const createQueryFn =
key, <T>({ path, params }: { path: string; params?: Record<string, string> }) =>
path, async ({ signal }: { signal: AbortSignal }) => {
params, const queryString = params
enabled, ? `?${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; key?: string;
path: string; path: string;
params?: Record<string, string>; params?: Record<string, string>;
enabled?: boolean; 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, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
gcTime: 0, gcTime: 0,
enabled, enabled,
queryFn: async ({ signal }) => { queryFn: createQueryFn({ path, params }),
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>>)
);
},
}); });
export const useSuspenseAPIQuery = <T>( export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) =>
path: string,
params?: Record<string, string>
) =>
useSuspenseQuery<SuccessAPIResponse<T>>({ useSuspenseQuery<SuccessAPIResponse<T>>({
queryKey: [path], queryKey: key ? [key] : [path, params],
retry: false, retry: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
gcTime: 0, gcTime: 0,
queryFn: ({ signal }) => { queryFn: createQueryFn({ path, params }),
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>>)
);
},
}); });

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"; import { Fragment, useState } from "react";
export default function AlertsPage() { export default function AlertsPage() {
const { data } = useSuspenseAPIQuery<AlertingRulesMap>(`/rules`, { const { data } = useSuspenseAPIQuery<AlertingRulesMap>({
path: `/rules`,
params: {
type: "alert", type: "alert",
},
}); });
const [showAnnotations, setShowAnnotations] = useState(false); const [showAnnotations, setShowAnnotations] = useState(false);

View file

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

View file

@ -83,7 +83,9 @@ function sortData(
} }
export default function FlagsPage() { 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]) => ({ const flags = Object.entries(data.data).map(([flag, value]) => ({
flag, flag,

View file

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

View file

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

View file

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

View file

@ -81,11 +81,6 @@ const DataTable: FC<TableProps> = ({ expr, evalTime, retriggerIdx }) => {
return <Alert variant="transparent">No data queried yet</Alert>; 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; const { result, resultType } = data.data;
if (result.length === 0) { if (result.length === 0) {

View file

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