Enhance error handling for UI sendRequest service (#14971)

This commit is contained in:
Sarah Gillespie
2023-11-09 09:33:31 -06:00
committed by GitHub
parent b2ab4553cc
commit e57ea7ea61
10 changed files with 278 additions and 51 deletions
+184 -3
View File
@@ -1,4 +1,5 @@
import PropTypes from "prop-types";
import { AxiosError, isAxiosError } from "axios";
export default PropTypes.shape({
http_status: PropTypes.number,
@@ -11,14 +12,194 @@ export interface IOldApiError {
base: string;
}
export interface IError {
/**
* IFleetApiError is the shape of a Fleet API error. It represents an element of the `errors`
* array in a Fleet API response for failed requests (see `IFleetApiResponseWithErrors`).
*/
export interface IFleetApiError {
name: string;
reason: string;
}
// Response returned by API when there is an error
/**
* IApiError is the shape of a Fleet API response for failed requests.
*
* TODO: Rename to IFleetApiResponseWithErrors
*/
export interface IApiError {
message: string;
errors: IError[];
errors: IFleetApiError[];
uuid?: string;
}
const isFleetApiError = (err: unknown): err is IFleetApiError => {
if (!err || typeof err !== "object" || !("name" in err && "reason" in err)) {
return false;
}
const e = err as Record<"name" | "reason", unknown>;
if (typeof e.name !== "string" || typeof e.reason !== "string") {
return false;
}
return true;
};
interface IRecordWithErrors extends Record<string | number | symbol, unknown> {
errors: unknown[];
}
const isRecordWithErrors = (r: unknown): r is IRecordWithErrors => {
if (!r || typeof r !== "object" || !("errors" in r)) {
return false;
}
const { errors } = r as { errors: unknown };
if (!Array.isArray(errors)) {
return false;
}
return true;
};
interface IRecordWithDataErrors
extends Record<string | number | symbol, unknown> {
data: IRecordWithErrors;
}
const isRecordWithDataErrors = (r: unknown): r is IRecordWithDataErrors => {
if (!r || typeof r !== "object" || !("data" in r)) {
return false;
}
const { data } = r as { data: unknown };
if (!isRecordWithErrors(data)) {
return false;
}
const { errors } = data;
if (!Array.isArray(errors)) {
return false;
}
return true;
};
interface IRecordWithResponseDataErrors
extends Record<string | number | symbol, unknown> {
response: IRecordWithDataErrors;
}
const isRecordWithResponseDataErrors = (
r: unknown
): r is IRecordWithResponseDataErrors => {
if (!r || typeof r !== "object" || !("response" in r)) {
return false;
}
const { response } = r as { response: unknown };
if (!isRecordWithDataErrors(response)) {
return false;
}
return true;
};
interface IFilterFleetErrorBase {
nameEquals?: string;
reasonIncludes?: string;
}
interface IFilterFleetErrorName extends IFilterFleetErrorBase {
nameEquals: string;
reasonIncludes?: never;
}
interface IFilterFleetErrorReason extends IFilterFleetErrorBase {
nameEquals?: never;
reasonIncludes: string;
}
// FilterFleetError is the shape of a filter that can be applied to to filter Fleet
// server errors. It is the union of FilterFleetErrorName and FilterFleetErrorReason,
// which ensures that only one of `nameEquals` or `reasonIncludes` can be specified.
type IFilterFleetError = IFilterFleetErrorName | IFilterFleetErrorReason;
const filterFleetErrorNameEquals = (errs: unknown[], value: string) => {
if (!value || !errs?.length) {
return undefined;
}
return errs?.find((e) => isFleetApiError(e) && e.name === value) as
| IFleetApiError
| undefined;
};
const filterFleetErrorReasonIncludes = (errs: unknown[], value: string) => {
if (!value || !errs?.length) {
return undefined;
}
return errs?.find((e) => isFleetApiError(e) && e.reason?.includes(value)) as
| IFleetApiError
| undefined;
};
const getReasonFromErrors = (errors: unknown[], filter?: IFilterFleetError) => {
if (!errors.length) {
return "";
}
let fleetError: IFleetApiError | undefined;
if (filter?.nameEquals) {
fleetError = filterFleetErrorNameEquals(errors, filter.nameEquals);
} else if (filter?.reasonIncludes) {
fleetError = filterFleetErrorReasonIncludes(errors, filter.reasonIncludes);
} else {
fleetError = isFleetApiError(errors[0]) ? errors[0] : undefined;
}
return fleetError?.reason || "";
};
const getReasonFromRecordWithDataErrors = (
r: IRecordWithDataErrors,
filter?: IFilterFleetError
): string => {
return getReasonFromErrors(r.data.errors, filter);
};
const getReasonFromAxiosError = (
ae: AxiosError,
filter?: IFilterFleetError
): string => {
return isRecordWithDataErrors(ae.response)
? getReasonFromRecordWithDataErrors(ae.response, filter)
: "";
};
/**
* getErrorReason attempts to parse a unknown payload as an `AxiosError` or
* other `Record`-like object with the general shape as follows:
* `{ response: { data: { errors: unknown[] } } }`
*
* It attempts to extract a `reason` from a Fleet API error (i.e. an object
* with `name` and `reason` properties) in the `errors` array, if present.
* Other in values in the payload are generally ignored.
*
* If `filter` is specified, it attempts to find an error that satisfies the filter
* and returns the `reason`, if found. Otherwise, it returns the `reason`
* of the first error, if any.
*
* By default, an empty string is returned as the reason if no error is found.
*/
export const getErrorReason = (
payload: unknown | undefined,
filter?: IFilterFleetError
): string => {
if (isAxiosError(payload)) {
return getReasonFromAxiosError(payload, filter);
}
if (isRecordWithResponseDataErrors(payload)) {
return getReasonFromRecordWithDataErrors(payload.response, filter);
}
if (isRecordWithDataErrors(payload)) {
return getReasonFromRecordWithDataErrors(payload, filter);
}
if (isRecordWithErrors(payload)) {
return getReasonFromErrors(payload.errors, filter);
}
return "";
};
@@ -1,8 +1,10 @@
import React, { useContext } from "react";
import { InjectedRouter } from "react-router";
import { isAxiosError } from "axios";
import PATHS from "router/paths";
import configAPI from "services/entities/config";
import { getErrorReason } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
@@ -30,15 +32,23 @@ const useSetWindowsMdm = ({
const turnOnWindowsMdm = async () => {
try {
const updatedConfig = await configAPI.update({
mdm: {
const updatedConfig = await configAPI.updateMDMConfig(
{
windows_enabled_and_configured: enable,
},
});
true
);
setConfig(updatedConfig);
renderFlash("success", successMessage);
} catch {
renderFlash("error", errorMessage);
} catch (e) {
let msg = errorMessage;
if (enable && isAxiosError(e) && e.response?.status === 422) {
msg =
getErrorReason(e, {
nameEquals: "mdm.windows_enabled_and_configured",
}) || msg;
}
renderFlash("error", msg);
} finally {
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
}
@@ -8,7 +8,7 @@ import scriptsAPI, {
IHostScript,
IHostScriptsResponse,
} from "services/entities/scripts";
import { IApiError, IError } from "interfaces/errors";
import { IApiError } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import Card from "components/Card";
@@ -43,7 +43,7 @@ const Scripts = ({
isLoading: isLoadingScriptData,
isError: isErrorScriptData,
refetch: refetchScriptsData,
} = useQuery<IHostScriptsResponse, IError>(
} = useQuery<IHostScriptsResponse, IApiError>(
["scripts", hostId, page],
() => scriptsAPI.getHostScripts(hostId as number, page),
{
@@ -13,7 +13,7 @@ import { ITarget, ITargetsAPIResponse } from "interfaces/target";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { getError } from "services";
import { getErrorReason } from "interfaces/errors";
import packsAPI from "services/entities/packs";
import queriesAPI from "services/entities/queries";
import scheduledQueriesAPI from "services/entities/scheduled_queries";
@@ -150,9 +150,12 @@ const EditPacksPage = ({
router.push(PATHS.MANAGE_PACKS);
renderFlash("success", `Successfully updated this pack.`);
})
.catch((response) => {
const error = getError(response);
if (error.includes("Duplicate entry")) {
.catch((e) => {
if (
getErrorReason(e, {
reasonIncludes: "Duplicate entry",
})
) {
renderFlash(
"error",
"Unable to update pack. Pack names must be unique."
@@ -3,7 +3,7 @@ import { useQuery } from "react-query";
import { InjectedRouter } from "react-router/lib/Router";
import { IPack, IStoredPacksResponse } from "interfaces/pack";
import { IError } from "interfaces/errors";
import { IFleetApiError } from "interfaces/errors";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import packsAPI from "services/entities/packs";
@@ -29,7 +29,7 @@ const renderTable = (
onDisablePackClick: (selectedTablePackIds: number[]) => void,
onCreatePackClick: React.MouseEventHandler<HTMLButtonElement>,
packs: IPack[] | undefined,
packsError: IError | null,
packsError: IFleetApiError | null,
isLoadingPacks: boolean
): JSX.Element => {
if (packsError) {
@@ -65,7 +65,7 @@ const ManagePacksPage = ({ router }: IManagePacksPageProps): JSX.Element => {
error: packsError,
isFetching: isLoadingPacks,
refetch: refetchPacks,
} = useQuery<IStoredPacksResponse, IError, IPack[]>(
} = useQuery<IStoredPacksResponse, IFleetApiError, IPack[]>(
"packs",
() => packsAPI.loadAll(),
{
@@ -9,7 +9,7 @@ import { IQuery } from "interfaces/query";
import { ITargetsAPIResponse } from "interfaces/target";
import { IEditPackFormData } from "interfaces/pack";
import { getError } from "services";
import { getErrorReason } from "interfaces/errors";
import packsAPI from "services/entities/packs";
import PackForm from "components/forms/packs/PackForm";
@@ -54,10 +54,12 @@ const PackComposerPage = ({ router }: IPackComposerPageProps): JSX.Element => {
"success",
"Pack successfully created. Add queries to your pack."
);
} catch (response) {
const error = getError(response);
if (error.includes("Duplicate entry")) {
} catch (e) {
if (
getErrorReason(e, {
reasonIncludes: "Duplicate entry",
})
) {
renderFlash(
"error",
"Unable to create pack. Pack names must be unique."
+39 -3
View File
@@ -2,7 +2,7 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { IConfig } from "interfaces/config";
import { IConfig, IMdmConfig } from "interfaces/config";
import axios, { AxiosError } from "axios";
export default {
@@ -35,10 +35,46 @@ export default {
return sendRequest("GET", GLOBAL_ENROLL_SECRETS);
},
update: (formData: any) => {
/**
* update is used to update the app config.
*
* If the request fails and `skipParseError` is `true`, the caller is
* responsible for verifying that the value of the rejected promise is an AxiosError
* and futher parsing of the the error mesage.
*/
update: (formData: any, skipParseError?: boolean) => {
const { CONFIG } = endpoints;
return sendRequest("PATCH", CONFIG, formData);
return sendRequest(
"PATCH",
CONFIG,
formData,
undefined,
undefined,
skipParseError
);
},
/**
* updateMDMConfig is a special case of update that is used to update the MDM
* config.
*
* If the request fails and `skipParseError` is `true`, the caller is
* responsible for verifying that the value of the rejected promise is an AxiosError
* and futher parsing of the the error mesage.
*/
updateMDMConfig: (mdm: Partial<IMdmConfig>, skipParseError?: boolean) => {
const { CONFIG } = endpoints;
return sendRequest(
"PATCH",
CONFIG,
{ mdm },
undefined,
undefined,
skipParseError
);
},
// This API call is made to a specific endpoint that is different than our
+6 -4
View File
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest, { getError } from "services";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { getErrorReason } from "interfaces/errors";
import { ISelectedTargetsForApi } from "interfaces/target";
import { AxiosResponse } from "axios";
import {
ICreateQueryRequestBody,
IModifyQueryRequestBody,
@@ -70,8 +70,10 @@ export default {
total: 0,
},
});
} catch (response) {
throw new Error(getError(response as AxiosResponse));
} catch (e) {
throw new Error(
getErrorReason(e) || `run query: parse server error ${e}`
);
}
},
update: (id: number, updateParams: IModifyQueryRequestBody) => {
+13 -20
View File
@@ -1,18 +1,14 @@
import axios, {
AxiosError,
AxiosResponse,
ResponseType as AxiosResponseType,
} from "axios";
import axios, { isAxiosError, ResponseType as AxiosResponseType } from "axios";
import URL_PREFIX from "router/url_prefix";
import { authToken } from "utilities/local";
import URL_PREFIX from "router/url_prefix";
const sendRequest = async (
export const sendRequest = async (
method: "GET" | "POST" | "PATCH" | "DELETE" | "HEAD",
path: string,
data?: unknown,
responseType: AxiosResponseType = "json",
timeout?: number
timeout?: number,
skipParseError?: boolean
) => {
const { origin } = global.window.location;
@@ -33,20 +29,17 @@ const sendRequest = async (
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
if (skipParseError) {
return Promise.reject(error);
}
let reason: unknown | undefined;
if (isAxiosError(error)) {
reason = error.response || error.message || error.code;
}
return Promise.reject(
axiosError.response ||
axiosError.message ||
axiosError.code ||
"unknown axios error"
reason || `send request: parse server error: ${error}`
);
}
};
// return the first error
export const getError = (response: unknown): string => {
const r = response as AxiosResponse;
return r.data?.errors?.[0]?.reason || ""; // TODO: check if any callers rely on empty return value
};
export default sendRequest;
@@ -1,7 +1,7 @@
import { get, join } from "lodash";
import { IError, IOldApiError } from "interfaces/errors";
import { IFleetApiError } from "interfaces/errors";
const formatServerErrors = (errors: IError[]) => {
const formatServerErrors = (errors: IFleetApiError[]) => {
if (!errors || !errors.length) {
return {};
}