Last active
December 14, 2025 02:46
-
-
Save isocroft/fbb87a7f865e9f6dbff3c577f9cfb65b to your computer and use it in GitHub Desktop.
Utility to help extract clear, meaningful, human-oriented error messages (as opposed to generic & cryptic error messages) from an axios error object on a web frontend application to aid debugging on the backend
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import axios, { AxiosError, AxiosResponse, AxiosRequestConfig } from "axios"; | |
| const expandOnError = (errorMessage: string, errorCode?: string, errorResponse?: AxiosResponse) => { | |
| if (errorMessage === "Network Error" && Boolean(!errorResponse)) { | |
| /* @HINT: The message returned below simply means that a CORS error occured OR the name resolution failed */ | |
| return errorCode === undefined | |
| ? "We are currently updating our systems... Please try again later" | |
| : "Your ISP seems to have some other network-related issues"; | |
| } | |
| return "The browser restricted access to the server response! Please contact admin"; | |
| }; | |
| const isCORSViolation = (request: XMLHttpRequest, config?: AxiosRequestConfig) => { | |
| const frontendURIHost = window.location.host; | |
| const backendBaseURL = new URL(config?.baseURL || config?.url || "https://x.yz"); | |
| const backendURIHost = backendBaseURL.host; | |
| const isCrossDomainRequest = backendURIHost !== "x.yz" && frontendURIHost !== backendURIHost; | |
| let hasAccessControlOnOrigin = false; | |
| const requestHasValidStatus = Boolean((request.status >= 200 && request.status <= 508)); | |
| const contentType = request.getResponseHeader("Content-Type"); | |
| if (isCrossDomainRequest) { | |
| let allowedOrigin = ""; | |
| try { | |
| if (request.withCredentials) { | |
| allowedOrigin = request.getResponseHeader("Access-Control-Allow-Origin") || ""; | |
| } | |
| /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ | |
| } catch (_) { allowedOrigin = ""; } | |
| if (allowedOrigin.trim() === "*") { | |
| return true; | |
| } else { | |
| hasAccessControlOnOrigin = Boolean(allowedOrigin.trim() === backendBaseURL.origin.trim()); | |
| } | |
| } | |
| return ((isCrossDomainRequest && requestHasValidStatus) && (!hasAccessControlOnOrigin && contentType !== null)); | |
| }; | |
| export const getMessageFromAxiosError = (error: AxiosError) => { | |
| let message = ""; | |
| const isNotCORSViolation = !isCORSViolation(error?.request as XMLHttpRequest, error?.config); | |
| /* @CHECK: https://axios-http.com/docs/handling_errors */ | |
| /* @SEE: https://www.intricatecloud.io/2020/03/how-to-handle-api-errors-in-your-web-app-using-axios/ */ | |
| /* @HINT: These are the varying ranges of error(s) that can occur when making async HTTP requests */ | |
| const isServerResponseEmpty = Boolean(!error?.response) && ((Boolean(error?.request) && isNotCORSViolation) && error?.code !== "ERR_NETWORK"); | |
| const isServerTimedOut = error?.code === "ECONNABORTED" || error?.code === "ETIMEDOUT"; | |
| const isServerUnreachable = error?.code === "ECONNREFUSED"; | |
| const isClientOffline = window !== undefined ? !window.navigator.onLine : false; | |
| const errorMessagesMap = { | |
| unreachable: "Our systems are unreachable! Please try again later", | |
| timeout: "Our systems request(s) timed out! Please try again", | |
| indeterminate: "Something went wrong! Please try again", | |
| offline: "Your internet is unstable! Please check and try again" | |
| }; | |
| switch (true) { | |
| case isClientOffline: | |
| message = errorMessagesMap["offline"]; | |
| break; | |
| case isServerUnreachable: | |
| message = errorMessagesMap["unreachable"]; | |
| break; | |
| case isServerTimedOut: | |
| message = errorMessagesMap["timeout"]; | |
| break; | |
| case isServerResponseEmpty: | |
| message = "Our systems response returned empty! Please try again"; | |
| break; | |
| default: | |
| message = Boolean(error?.response) | |
| ? errorMessagesMap["indeterminate"] | |
| : expandOnError(error.message, error?.code, error?.response); | |
| } | |
| return message; | |
| }; |
Author
NOTE: To use the function above correctly and properly, you have to expose extra response headers to the client (javascript) using Access-Control-Expose-Headers response header on the server-side and set it to contain Access-Control-Allow-Origin.
Access-Control-Expose-Headers: Vary, Set-Cookie, Access-Control-Allow-Origin
Author
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { /* API_BASE_URL, */ QueryKey, JWT_CACHE_STRATEGY } from '../utils/constants';
import type { loginRequest, loginResponse } from '../models/auth';
export const useAuthLogin = () => {
const { tokenStorageMode, tokenStorageKeys } = useAuthConfig();
const { loginMutationCallback } = useAuthStatus();
const { setToStorage } = useBrowserStorage({ storageType: "local" });
const queryClient = useQueryClient();
const { mutate: login, isPending: isLoading, ...rest } = useMutation({
mutationFn: loginMutationCallback,
onSuccess: async (payload) => {
if (payload.status === 200) {
await queryClient.setQueryData([QueryKey.AuthPayload], payload.data);
}
}
});
return { login, isLoading, ...rest };
};
export const useAuthLogout = () => {
const navigate = useNavigate();
const { logoutMutationCallback } = useAuthStatus();
const { mutate: logout, isPending: isLoading, ...rest } = useMutation({
mutationFn: logoutMutationCallback,
onSuccess: async (payload) => {
navigate('/login');
}
});
return { logout, isLoading, ...rest };
};
export const useAuthCheck = () => {
const { checkIfUserAuthValid, getAuthUser } = useAuthStatus();
};
const {
tokenStorageMode,
tokenStorageKeys
} = useAuthConfig();
const {
checkIfUserAuthValid,
getAuthUser,
setAuthUser,
logoutMutationCallback,
loginMutationCallback
} = useAuthStatus();
const {
login,
isLoading
} = useAuthLogin(); -> useAuthStatus() + useAuthConfig();
const {
logout,
isLoading
} = useAuthLogout(); -> useAuthStatus() + useAuthConfig();
Author
import React, { createContext, useContext, useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import type { UseQueryOptions } from "@tanstack/react-query";
import type { AxiosResponse } from 'axios';
import type { TUser } from '../utils/models';
import type { loginRequest, loginResponse } from '../models/auth';
import { axios } from './axiosPoweredClient';
import { useJWTExpiryMonitor } from '../hooks/auth';
import { QueryKey, JWT_CACHE_STRATEGY } from '../utils/constants';
const useGetUser = ({ enabled, onSuccess, onError }: {
enabled: boolean,
onSuccess: UseQueryOptions<TUser>['onSuccess'],
onError: UseQueryOptions<TUser>['onError']
}) => {
return useQuery({
queryKey: [QueryKey.UserProfile],
queryFn: async (): Promise<AxiosResponse<TUser>> => {
const response = await axios.get(import.meta.env.VITE_AUTH_USER_API_ENDPOINT);
if(response.status === 200) {
return response;
} else {
return Promise.reject(new Error(response.data.message));
}
},
enabled,
onSuccess,
onError
});
};
type AuthStatusContextDetails = {
getAuthUser: () => TUser | null,
setAuthUser: (user: TUser | null) => boolean,
loginMuationCallback: (payload: loginRequest) => Promise<loginResponse>,
logoutMutationCallback: () => Promise<void>,
checkIfUserAuthValid: () => boolean
};
type AuthConfigContextDetails = {
tokenStorageMode: typeof JWT_CACHE_STRATEGY[keyof typeof JWT_CACHE_STRATEGY],
tokenStorageKeys: {
TOKEN_KEYNAME: string,
R_TOKEN_KEYNAME: string,
USER_KEYNAME: string,
TOKEN_ISSUE_TIME_KEYNAME: string,
TOKEN_EXPIRE_TIME_KEYNAME: string
}
};
// Create separate contexts for status and config to optimize re-renders
const AuthConfigContext = createContext<AuthConfigContextDetails>({
tokenStorageMode: JWT_CACHE_STRATEGY.STORAGE_MODE,
tokenStorageKeys: {
TOKEN_KEYNAME: 'a_token',
R_TOKEN_KEYNAME: 'r_token',
USER_KEYNAME: 'u_details',
TOKEN_ISSUE_TIME_KEYNAME: 'issd_at',
TOKEN_EXPIRE_TIME_KEYNAME: 'exps_at'
}
});
const AuthStatusContext = createContext<AuthStatusContextDetails>({
getAuthUser: () => null,
setAuthUser: (user = null) => false,
logoutMutationCallback: () => Promise.resolve(undefined),
loginMutationCallback: (payload = { email: '', password: '' }) => Promise.resolve({ data: { access_token: '', refresh_token: '' } }),
checkIfUserAuthValid: () => false
});
/* eslint-disable-next-line react-refresh/only-export-components */
export const useAuthStatus = () => {
const context = useContext(AuthStatusContext);
if (context === undefined) {
throw new Error('`useAuthStatus(...)` must be used within an AuthProvider');
}
return context;
};
/* eslint-disable-next-line react-refresh/only-export-components */
export const useAuthConfig = () => {
const context = useContext(AuthConfigContext);
if (context === undefined) {
throw new Error('`useAuthConfig(...)` must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: React.ReactNode;
mode: AuthConfigContextDetails['tokenStorageMode'];
keys: AuthConfigContextDetails['tokenStorageKeys'];
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children, mode, keys }) => {
const navigate = useNavigate();
const { getFromStorage, setToStorage } = useBrowserStorage({ storageType: "local" });
const hasAuthTokenInStorage = Boolean(getFromStorage<string>(keys.TOKEN_KEYNAME));
const lastAuthUserRefetchTime = useRef<number>(0);
const { refetch, status, isInitialLoading, isPending, error } = useGetUser({
onSuccess (data) {
lastAuthUserRefetchTime.current = hasAuthTokenInStorage ? Date.now() : 0;
setToStorage(storageKeys.USER_KEYNAME, data);
},
onError (error) {
if (import.meta.env.DEV) {
console.error(error.message);
}
},
enabled: hasAuthTokenInStorage,
});
const {
updateQueryCacheData,
getDataFromCache,
} = useReactQueryCache<TUser, Error>(
{ noRenderOnWrite: false, queryKey: [QueryKey.UserProfile] },
getFromStorage<TUser | undefined>(keys.USER_KEYNAME, undefined)
);
const { logoutMutationFn, loginMutationFn } = useJWTExpiryMonitor({
TOKEN_ISSUE_TIME_KEYNAME: keys.TOKEN_ISSUE_TIME_KEYNAME,
TOKEN_EXPIRE_TIME_KEYNAME: keys.TOKEN_EXPIRE_TIME_KEYNAME,
TOKEN_KEYNAME: keys.TOKEN_KEYNAME,
MODE: mode
});
const checkIfAuthValid = () => {
if (!hasAuthTokenInStorage || isInitialLoading || isPending) {
return;
}
const now = Date.now();
const threeMinutes = 180000;
const canRefetch = now - lastAuthUserRefetchTime.current >= threeMinutes;
if (canRefetch) {
return refetch();
}
return Promise.reject(new Error("couldn't refetch"));
};
const stableLoginMutationCallback = useEffectCallback(loginMutationFn, { immutableRef: true });
const stableLogoutMutationCallback = useEffectCallback(logoutMutationFn, { immutableRef: true });
const stableCheckIfUserAuthValidRef = useEffectCallback(checkIfAuthValid, { immutableRef: true });
useEffect(() => {
const eventHandler = () => {
stableCheckIfUserAuthValidRef()
};
window.addEventListener('refetchuserquery', eventHandler, false);
eventHandler();
const intervalId = window.setInterval(() => {
if (mode === JWT_CACHE_STRATEGY.COOKIE_MODE) {
stableCheckIfUserAuthValidRef()
}
}, 60500);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('refetchuserquery', eventHandler, false);
};
}, [mode]);
const authStatusValue = useEffectMemo(() => {
return {
checkIfUserAuthValid () {
window.dispatchEvent(new Event('refetchuserquery'))
},
setAuthUser (data: TUser) {
return Boolean(updateQueryCacheData([QueryKey.UserProfile], () => data));
},
getAuthUser () {
return getDataFromCache([QueryKey.UserProfile]) || null;
},
logoutMutationCallback () {
return stableLogoutMutationCallback();
},
loginMutationCallback (...args) {
return stableLoginMutationCallback(...args);
}
};
}, []);
const authConfigValue = useMemo(() => {
return { tokenStorageMode: mode, tokenStorageKeys: keys };
}, []);
useEffect(() => {
const hasAuthTokenInStorage = Boolean(getFromStorage<string>(keys.TOKEN_KEYNAME, undefined));
if(hasAuthTokenInStorage && status === "success") {
navigate(import.meta.env.VITE_LOGIN_REDIRECT_PATHNAME);
}
}, [status]);
/* @NOTE: `isInitialLoading` which was added in v4.x.x is now currently deprecated in Tanstack React-Query v5.x.x */
/* @CHECK: https://tanstack.com/query/latest/docs/framework/react/reference/useQuery */
/* @HINT: Used `isPending` instead of `isLoading` because it seems to always be `true` even when `useQuery(...)` is disabled */
/* @CHECK: https://github.com/TanStack/query/issues/3975 + https://tanstack.com/query/v4/docs/guides/queries#why-two-different-states */
if (isInitialLoading || isPending) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<AuthConfigContext.Provider value={authConfigValue}>
<AuthStatusContext.Provider value={authStatusValue}>
{children}
</AuthStatusContext.Provider>
</AuthConfigContext.Provider>
);
};
Author
TanStack/query#6327 (reply in thread)
VITE_LOGIN_REDIRECT_PATHNAME=/dashboard
VITE_LOGIN_PATHNAME=/login
VITE_API_BASE_URL=https://slimy-quiet.pipeops.app/api/v1
VITE_API_TIMEOUT_SECONDS=10000
VITE_AUTH_USER_API_ENDPOINT=https://slimy-quiet.pipeops.app/api/v1/api/v1/users/me
VITE_AUTH_LOGIN_API_ENDPOINT=https://slimy-quiet.pipeops.app/api/v1/auth/login
VITE_AUTH_REFRESH_API_ENDPOINT=https://slimy-quiet.pipeops.app/api/v1/auth/refresh-token,
VITE_AUTH_LOGOUT_API_ENDPOINT=https://slimy-quiet.pipeops.app/api/v1/auth/logout
Author
export const useRouteQueryPrefetch = ({ queryOptions, prefetchOnMount = false, cacheAndPersist = false }: { queryOptions: UseQueryOptions, prefetchOnMount: boolean, cacheAndPersist: boolean }) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isPending, startTransition] = useTransition()
const isFirstRender = useIsFirstRender();
useEffect(() => {
if (isFirstRender && prefetchOnMount) {
/* @HINT: Prefetch in background just after component mount */
if (!cacheAndPersist) {
queryClient.prefetchQuery(queryOptions);
} else {
try {
/* @CHECK: https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientensurequerydata */
queryClient.ensureQueryData(queryOptions);
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
} catch (_) { /* eslint-disable no-empty */ }
}
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [prefetchOnMount, cacheAndPersist, isFirstRender]);
const handlePathNavigation = async (urlPathname = '/', queryOptions = { queryKey: [], queryFn: () => null }) => {
/* @HINT: Prefetch during route navigation */
await queryClient.prefetchQuery(queryOptions);
navigate(urlPathname);
};
const onRouteNavigationTriggered = (e: React.MouseEvent<HTMLElement> & { target: HTMLElement }) => {
if (isPending) {
e.stopPropagation();
e.preventDefault();
return;
}
e.persist();
startTransition(async () => {
const { pathname } = new URL(e.target.getAttribute('data-href') || "/", window.location.origin);
/* @ts-ignore */
await handlePathNavigation(pathname, queryOptions);
});
};
return {
isRoutePrefetching: isPending,
onRouteNavigation: onRouteNavigationTriggered
} as const;
};
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.