chore: error handling for fetch

This commit is contained in:
Bassel Kanso 2024-04-08 21:43:37 +03:00
parent 65df420943
commit 7ca9df7f9d
19 changed files with 127 additions and 130 deletions

View File

@ -1,6 +1,5 @@
import * as React from 'react';
import { AxiosError } from 'axios';
import { IntlFormatters, useIntl } from 'react-intl';
import { FetchError } from '../utils/getFetchClient';
@ -121,18 +120,18 @@ export function useAPIErrorHandler(
/**
* @description This method try to normalize the passed error
* and then call formatAPIError to stringify the ResponseObject
* into a string. If it fails it will call formatAxiosError and
* into a string. If it fails it will call formatFetchError and
* return the error message.
*/
const formatError = React.useCallback(
(error: AxiosError<{ error: ApiError }>) => {
(error: FetchError) => {
// Try to normalize the passed error first. This will fail for e.g. network
// errors which are thrown by Axios directly.
try {
const formattedErr = formatAPIError(error, { intlMessagePrefixCallback, formatMessage });
if (!formattedErr) {
return formatAxiosError(error, { intlMessagePrefixCallback, formatMessage });
return formatFetchError(error, { intlMessagePrefixCallback, formatMessage });
}
return formattedErr;
@ -192,7 +191,7 @@ export function useAPIErrorHandler(
error,
},
},
} as AxiosError<{ error: BaseQueryError }>;
} as FetchError;
/**
* There's a chance with SerializedErrors that the message is not set.
@ -202,7 +201,6 @@ export function useAPIErrorHandler(
return 'Unknown error occured.';
}
// @ts-expect-error UnknownApiError is in the same shape as ApiError, but we don't want to expose this to users yet.
return formatError(err);
},
[formatError]
@ -211,8 +209,8 @@ export function useAPIErrorHandler(
};
}
function formatAxiosError(
error: AxiosError<unknown>,
function formatFetchError(
error: FetchError,
{ intlMessagePrefixCallback, formatMessage }: FormatAPIErrorOptions
) {
const { code, message } = error;
@ -238,7 +236,7 @@ type FormatAPIErrorOptions = Partial<Pick<NormalizeErrorOptions, 'intlMessagePre
* will bo concatenated into a single string.
*/
function formatAPIError(
error: AxiosError<{ error: ApiError }>,
error: FetchError,
{ formatMessage, intlMessagePrefixCallback }: FormatAPIErrorOptions
) {
if (!formatMessage) {

View File

@ -15,7 +15,6 @@ import {
VisuallyHidden,
} from '@strapi/design-system';
import { Duplicate, Pencil, Plus, Trash } from '@strapi/icons';
import { AxiosError } from 'axios';
import { produce } from 'immer';
import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
@ -31,6 +30,7 @@ import { useFetchClient } from '../../../../hooks/useFetchClient';
import { useQueryParams } from '../../../../hooks/useQueryParams';
import { useRBAC } from '../../../../hooks/useRBAC';
import { selectAdminPermissions } from '../../../../selectors';
import { isFetchError } from '../../../../utils/getFetchClient';
import { RoleRow, RoleRowProps } from './components/RoleRow';
@ -74,7 +74,7 @@ const ListPage = () => {
type: 'RESET_DATA_TO_DELETE',
});
} catch (error) {
if (error instanceof AxiosError) {
if (isFetchError(error)) {
toggleNotification({
type: 'danger',
message: formatAPIError(error),

View File

@ -5,9 +5,6 @@ import { getFetchClient, isFetchError, type FetchConfig } from '../utils/getFetc
import type { ApiError } from '../hooks/useAPIErrorHandler';
/* -------------------------------------------------------------------------------------------------
* Axios data
* -----------------------------------------------------------------------------------------------*/
export interface QueryArguments {
url: string;
method?: string;
@ -73,16 +70,26 @@ const fetchBaseQuery =
// Handle error of type FetchError
if (isFetchError(err)) {
return {
data: undefined,
error: {
name: 'UnknownError',
message: err.message,
details: err.response,
status: err.response?.status,
stack: err.stack,
} as UnknownApiError,
};
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
'error' in err.response?.data
) {
/**
* This will most likely be ApiError
*/
return { data: undefined, error: err.response?.data.error as any };
} else {
return {
data: undefined,
error: {
name: 'UnknownError',
message: err.message,
details: err.response,
status: err.response?.error.status,
} as UnknownApiError,
};
}
}
const error = err as Error;

View File

@ -1,5 +1,7 @@
import qs from 'qs';
import type { ApiError } from '../hooks/useAPIErrorHandler';
const STORAGE_KEYS = {
TOKEN: 'jwtToken',
USER: 'userInfo',
@ -21,16 +23,23 @@ export type FetchConfig = {
fetchConfig?: FetchParams[1];
};
type ErrorResponse = {
data: any;
error: ApiError & { status: number };
};
export class FetchError extends Error {
public name: string;
public message: string;
public response?: Response;
public response?: ErrorResponse;
public code?: number;
constructor(message: string, response?: Response) {
constructor(message: string, response?: ErrorResponse) {
super(message);
this.name = 'FetchError';
this.message = message;
this.response = response;
this.code = response?.error.status;
// Ensure correct stack trace in error object
if (Error.captureStackTrace) {
@ -104,7 +113,9 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
response: Response
): Promise<FetchResponse<TData>> => {
const result = await response.json();
if (result.error) {
throw new FetchError(result.error.message, result);
}
return {
data: result,
};
@ -129,21 +140,12 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
return `${baseURL}${url}`;
};
const fetchHandler = async (url: string, options?: FetchOptions) => {
const response = await fetch(url, options);
if (!response.ok) {
throw new FetchError(`Failed to fetch data: ${response.statusText}`, response);
}
return response;
};
const fetchClient: FetchClient = {
get: async <TData = unknown, R = FetchResponse<TData>>(
url: string,
options?: FetchConfig
): Promise<R> => {
const response = await fetchHandler(
const response = await fetch(
paramsSerializer(
addBaseUrl(normalizeUrl(url), options?.options?.baseURL),
options?.options?.params
@ -162,7 +164,7 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
data?: TSend,
options?: FetchConfig
): Promise<R> => {
const response = await fetchHandler(
const response = await fetch(
paramsSerializer(
addBaseUrl(normalizeUrl(url), options?.options?.baseURL),
options?.options?.params
@ -182,7 +184,7 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
data?: TSend,
options?: FetchConfig
): Promise<R> => {
const response = await fetchHandler(
const response = await fetch(
paramsSerializer(
addBaseUrl(normalizeUrl(url), options?.options?.baseURL),
options?.options?.params
@ -201,7 +203,7 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
url: string,
options?: FetchConfig
): Promise<R> => {
const response = await fetchHandler(
const response = await fetch(
paramsSerializer(
addBaseUrl(normalizeUrl(url), options?.options?.baseURL),
options?.options?.params

View File

@ -1,8 +1,8 @@
import { getPrefixedId } from './getPrefixedId';
import type { ApiError } from '../hooks/useAPIErrorHandler';
import type { FetchError } from '../utils/getFetchClient';
import type { errors } from '@strapi/utils';
import type { AxiosError } from 'axios';
export interface NormalizeErrorOptions {
name?: string;
@ -53,7 +53,7 @@ const validateErrorIsYupValidationError = (
* (e.g. outside of a React component).
*/
export function normalizeAPIError(
apiError: AxiosError<{ error: ApiError }>,
apiError: FetchError,
intlMessagePrefixCallback?: NormalizeErrorOptions['intlMessagePrefixCallback']
):
| NormalizeErrorReturn

View File

@ -2,7 +2,7 @@ import { server } from '@tests/utils';
import { AxiosError } from 'axios';
import { rest } from 'msw';
import { getFetchClient, instance } from '../getFetchClient';
import { getFetchClient, FetchError } from '../getFetchClient';
describe('fetchClient', () => {
it('should contain a paramsSerializer that can serialize a params object to a string', async () => {
@ -70,7 +70,7 @@ describe('getFetchClient', () => {
try {
await response.get('test-fetch-client');
} catch (err) {
const url = (err as AxiosError).config?.url;
const url = (err as FetchError).config?.url;
expect(url).toBe('/test-fetch-client');
}
});

View File

@ -112,9 +112,9 @@ const fetchBaseQuery =
}
} catch (err) {
/**
* Handle error of type AxiosError
* Handle error of type FetchError
*
* This format mimics what we want from an AxiosError which is what the
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
@ -130,7 +130,7 @@ const fetchBaseQuery =
/**
* This will most likely be ApiError
*/
return { data: undefined, error: err.response?.data.error };
return { data: undefined, error: err.response?.data.error as any };
} else {
return {
data: undefined,
@ -138,7 +138,7 @@ const fetchBaseQuery =
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response,
status: err.response?.status,
status: err.response?.error.status,
} as UnknownApiError,
};
}

View File

@ -25,7 +25,6 @@ import {
import { LinkButton } from '@strapi/design-system/v2';
import { EmptyDocuments, Plus } from '@strapi/icons';
import { unstable_useDocument } from '@strapi/plugin-content-manager/strapi-admin';
import { isAxiosError } from 'axios';
import { Formik, Form } from 'formik';
import { useIntl } from 'react-intl';
import { Link as ReactRouterLink, useParams } from 'react-router-dom';
@ -144,7 +143,7 @@ const AddActionToReleaseModal = ({
}
if ('error' in response) {
if (isAxiosError(response.error)) {
if (isFetchError(response.error)) {
// Handle axios error
toggleNotification({
type: 'danger',

View File

@ -5,6 +5,7 @@ import {
useNotification,
useQueryParams,
useRBAC,
isFetchError,
} from '@strapi/admin/strapi-admin';
import {
Box,
@ -17,7 +18,6 @@ import {
ModalFooter,
} from '@strapi/design-system';
import { UID } from '@strapi/types';
import { isAxiosError } from 'axios';
import { Formik, Form } from 'formik';
import { useIntl } from 'react-intl';
@ -121,7 +121,7 @@ const ReleaseAction: BulkActionComponent = ({ documentIds, model }) => {
}
if ('error' in response) {
if (isAxiosError(response.error)) {
if (isFetchError(response.error)) {
// Handle axios error
toggleNotification({
type: 'warning',

View File

@ -1,10 +1,15 @@
import * as React from 'react';
import { useAPIErrorHandler, useNotification, useAuth, useRBAC } from '@strapi/admin/strapi-admin';
import {
useAPIErrorHandler,
useNotification,
useAuth,
useRBAC,
isFetchError,
} from '@strapi/admin/strapi-admin';
import { Flex, IconButton, Typography, Icon } from '@strapi/design-system';
import { Menu, Link } from '@strapi/design-system/v2';
import { Cross, More, Pencil } from '@strapi/icons';
import { isAxiosError } from 'axios';
import { useIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
@ -85,7 +90,7 @@ const DeleteReleaseActionItem = ({ releaseId, actionId }: DeleteReleaseActionIte
}
if ('error' in response) {
if (isAxiosError(response.error)) {
if (isFetchError(response.error)) {
// Handle axios error
toggleNotification({
type: 'danger',

View File

@ -11,6 +11,7 @@ import {
useNotification,
useQueryParams,
useRBAC,
isFetchError,
} from '@strapi/admin/strapi-admin';
import {
Button,
@ -43,7 +44,6 @@ import { ReleaseActionMenu } from '../components/ReleaseActionMenu';
import { ReleaseActionOptions } from '../components/ReleaseActionOptions';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/baseQuery';
import {
GetReleaseActionsQueryParams,
useGetReleaseActionsQuery,
@ -253,7 +253,7 @@ const ReleaseDetailsLayout = ({
totalPublishedEntries,
totalUnpublishedEntries,
});
} else if (isAxiosError(response.error)) {
} else if (isFetchError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'danger',
@ -556,7 +556,7 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
});
if ('error' in response) {
if (isAxiosError(response.error)) {
if (isFetchError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'danger',
@ -894,7 +894,7 @@ const ReleaseDetailsPage = () => {
}),
});
toggleEditReleaseModal();
} else if (isAxiosError(response.error)) {
} else if (isFetchError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'danger',
@ -916,8 +916,8 @@ const ReleaseDetailsPage = () => {
if ('data' in response) {
navigate('..');
} else if (isAxiosError(response.error)) {
// When the response returns an object with 'error', handle axios error
} else if (isFetchError(response.error)) {
// When the response returns an object with 'error', handle fetch error
toggleNotification({
type: 'danger',
message: formatAPIError(response.error),

View File

@ -8,6 +8,7 @@ import {
useNotification,
useQueryParams,
useRBAC,
isFetchError,
} from '@strapi/admin/strapi-admin';
import { useLicenseLimits } from '@strapi/admin/strapi-admin/ee';
import {
@ -40,7 +41,6 @@ import { GetReleases, type Release } from '../../../shared/contracts/releases';
import { RelativeTime as BaseRelativeTime } from '../components/RelativeTime';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/baseQuery';
import {
useGetReleasesQuery,
GetReleasesQueryParams,
@ -274,7 +274,7 @@ const ReleasesPage = () => {
trackUsage('didCreateRelease');
navigate(response.data.data.id.toString());
} else if (isAxiosError(response.error)) {
} else if (isFetchError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'danger',

View File

@ -1,6 +1,9 @@
import { getFetchClient, type FetchResponse, type FetchConfig } from '@strapi/admin/strapi-admin';
import type { AxiosError } from 'axios';
import {
getFetchClient,
type FetchResponse,
type FetchConfig,
type FetchError,
} from '@strapi/admin/strapi-admin';
export interface QueryArguments<TSend> {
url: string;
@ -39,11 +42,11 @@ const fetchBaseQuery = async <TData = unknown, TSend = unknown>({
const result = await get<TData, FetchResponse<TData>>(url, config);
return { data: result.data };
} catch (error) {
const err = error as AxiosError;
const err = error as FetchError;
/**
* Handle error of type AxiosError
* Handle error of type FetchError
*
* This format mimics what we want from an AxiosError which is what the
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
@ -51,7 +54,7 @@ const fetchBaseQuery = async <TData = unknown, TSend = unknown>({
*/
return {
error: {
status: err.response?.status,
status: err.response?.error.status,
code: err.code,
response: {
data: err.response?.data,
@ -61,24 +64,4 @@ const fetchBaseQuery = async <TData = unknown, TSend = unknown>({
}
};
/* -------------------------------------------------------------------------------------------------
* Axios error
* -----------------------------------------------------------------------------------------------*/
/**
* This asserts the errors from redux-toolkit-query are
* axios errors so we can pass them to our utility functions
* to correctly render error messages.
*/
const isAxiosError = (err: unknown): err is AxiosError<{ error: any }> => {
return (
typeof err === 'object' &&
err !== null &&
'response' in err &&
typeof err.response === 'object' &&
err.response !== null &&
'data' in err.response
);
};
export { isAxiosError, fetchBaseQuery };
export { fetchBaseQuery };

View File

@ -1,10 +1,10 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { axiosBaseQuery, type UnknownApiError } from '../utils/api';
import { fetchBaseQuery, type UnknownApiError } from '../utils/api';
const reviewWorkflowsApi = createApi({
reducerPath: 'reviewWorkflowsApi',
baseQuery: axiosBaseQuery(),
baseQuery: fetchBaseQuery(),
tagTypes: ['ReviewWorkflow', 'ReviewWorkflowStage'],
endpoints: () => ({}),
});

View File

@ -1,11 +1,12 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import { getFetchClient, ApiError, FetchConfig } from '@strapi/admin/strapi-admin';
import { isAxiosError, type AxiosRequestConfig } from 'axios';
import {
getFetchClient,
isFetchError,
type ApiError,
type FetchConfig,
} from '@strapi/admin/strapi-admin';
/* -------------------------------------------------------------------------------------------------
* Axios data
* -----------------------------------------------------------------------------------------------*/
export interface QueryArguments {
url: string;
method?: string;
@ -22,7 +23,7 @@ export interface UnknownApiError {
export type BaseQueryError = ApiError | UnknownApiError;
const axiosBaseQuery =
const fetchBaseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
@ -69,16 +70,16 @@ const axiosBaseQuery =
}
} catch (err) {
/**
* Handle error of type AxiosError
* Handle error of type FetchError
*
* This format mimics what we want from an AxiosError which is what the
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isAxiosError(err)) {
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
@ -95,7 +96,7 @@ const axiosBaseQuery =
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response?.data,
status: err.response?.status,
status: err.response?.error.status,
} as UnknownApiError,
};
}
@ -117,4 +118,4 @@ const isBaseQueryError = (error: BaseQueryError | SerializedError): error is Bas
return error.name !== undefined;
};
export { axiosBaseQuery, isBaseQueryError };
export { fetchBaseQuery, isBaseQueryError };

View File

@ -1,7 +1,7 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { DocumentInfos } from '../types';
import { axiosBaseQuery } from '../utils/baseQuery';
import { baseQuery } from '../utils/baseQuery';
type SettingsInput = {
restrictedAccess: boolean;
@ -10,7 +10,7 @@ type SettingsInput = {
const api = createApi({
reducerPath: 'plugin::documentation',
baseQuery: axiosBaseQuery({
baseQuery: baseQuery({
options: {
baseURL: '/documentation',
},

View File

@ -1,11 +1,12 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import { getFetchClient, ApiError, FetchConfig } from '@strapi/strapi/admin';
import { isAxiosError, type AxiosRequestConfig } from 'axios';
import {
getFetchClient,
isFetchError,
type ApiError,
type FetchConfig,
} from '@strapi/strapi/admin';
/* -------------------------------------------------------------------------------------------------
* Axios data
* -----------------------------------------------------------------------------------------------*/
export interface QueryArguments {
url: string;
method?: string;
@ -22,7 +23,7 @@ export interface UnknownApiError {
export type BaseQueryError = ApiError | UnknownApiError;
const axiosBaseQuery =
const baseQuery =
(config: FetchConfig): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
@ -69,16 +70,16 @@ const axiosBaseQuery =
}
} catch (err) {
/**
* Handle error of type AxiosError
* Handle error of type FetchError
*
* This format mimics what we want from an AxiosError which is what the
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isAxiosError(err)) {
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
@ -95,7 +96,7 @@ const axiosBaseQuery =
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response?.data,
status: err.response?.status,
status: err.response?.error?.status,
} as UnknownApiError,
};
}
@ -117,4 +118,4 @@ const isBaseQueryError = (error: BaseQueryError | SerializedError): error is Bas
return error.name !== undefined;
};
export { axiosBaseQuery, isBaseQueryError };
export { baseQuery, isBaseQueryError };

View File

@ -1,10 +1,10 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { axiosBaseQuery, type UnknownApiError } from '../utils/baseQuery';
import { fetchBaseQuery, type UnknownApiError } from '../utils/baseQuery';
const i18nApi = createApi({
reducerPath: 'i18nApi',
baseQuery: axiosBaseQuery(),
baseQuery: fetchBaseQuery(),
tagTypes: ['Locale'],
endpoints: () => ({}),
});

View File

@ -1,11 +1,12 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import { getFetchClient, ApiError, type FetchConfig } from '@strapi/admin/strapi-admin';
import { isAxiosError } from 'axios';
import {
getFetchClient,
isFetchError,
type ApiError,
type FetchConfig,
} from '@strapi/admin/strapi-admin';
/* -------------------------------------------------------------------------------------------------
* Axios data
* -----------------------------------------------------------------------------------------------*/
export interface QueryArguments {
url: string;
method?: string;
@ -22,7 +23,7 @@ export interface UnknownApiError {
export type BaseQueryError = ApiError | UnknownApiError;
const axiosBaseQuery =
const fetchBaseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
@ -69,16 +70,16 @@ const axiosBaseQuery =
}
} catch (err) {
/**
* Handle error of type AxiosError
* Handle error of type FetchError
*
* This format mimics what we want from an AxiosError which is what the
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isAxiosError(err)) {
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
@ -95,7 +96,7 @@ const axiosBaseQuery =
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response?.data,
status: err.response?.status,
status: err.response?.error.status,
} as UnknownApiError,
};
}
@ -117,4 +118,4 @@ const isBaseQueryError = (error: BaseQueryError | SerializedError): error is Bas
return error.name !== undefined;
};
export { axiosBaseQuery, isBaseQueryError };
export { fetchBaseQuery, isBaseQueryError };