Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved global error handler; support for registering custom error handlers on component mount #10506

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions public/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -975,13 +975,15 @@
"environment": "Environment",
"error_404": "Error 404",
"error_deleting_shifting": "Error while deleting Shifting record",
"error_failed_to_clone_questionnaire": "Failed to clone questionnaire. Please try again.",
"error_fetching_facility_data": "Error while fetching facility data",
"error_fetching_slots_data": "Error while fetching slots data",
"error_fetching_user_data": "Error while fetching user data",
"error_fetching_user_details": "Error while fetching user details: ",
"error_fetching_users_data": "Failed to load user data. Please try again later.",
"error_generating_discharge_summary": "Error generating discharge summary",
"error_loading_questionnaire_response": "Error loading questionnaire response",
"error_slug_already_in_use": "This slug is already in use. Please choose a different one.",
"error_updating_encounter": "Error to Updating Encounter",
"error_verifying_otp": "Error while verifying OTP, Please request a new OTP",
"error_while_deleting_record": "Error while deleting record",
Expand Down
19 changes: 16 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,34 @@ import AuthUserProvider from "@/Providers/AuthUserProvider";
import HistoryAPIProvider from "@/Providers/HistoryAPIProvider";
import Routers from "@/Routers";
import { handleHttpError } from "@/Utils/request/errorHandler";
import { HTTPError } from "@/Utils/request/types";

import { PubSubProvider } from "./Utils/pubsubContext";

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
retry(failureCount, error) {
// Fail-fast by skipping retries for non-5xx HTTP errors.
if (error instanceof HTTPError && error.status < 500) {
return false;
}

// Retries at most 2 times for HTTP 5xx errors or Network errors.
return failureCount < 2;
},
refetchOnWindowFocus: false,
},
},
queryCache: new QueryCache({
onError: handleHttpError,
onError: (error, query) => {
handleHttpError(error, query.meta);
},
}),
mutationCache: new MutationCache({
onError: handleHttpError,
onError: (error, _vars, _ctx, mutation) => {
handleHttpError(error, mutation.meta);
},
}),
});

Expand Down
2 changes: 1 addition & 1 deletion src/Providers/AuthUserProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default function AuthUserProvider({
}
}, [tokenRefreshQuery.data, tokenRefreshQuery.isError]);

const { mutateAsync: signIn, isPending: isAuthenticating } = useMutation({
const { mutate: signIn, isPending: isAuthenticating } = useMutation({
mutationFn: mutate(routes.login),
onSuccess: (data: JwtTokenObtainPair) => {
setAccessToken(data.access);
Expand Down
220 changes: 134 additions & 86 deletions src/Utils/request/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,122 +1,170 @@
import { MutationMeta, QueryMeta } from "@tanstack/react-query";
import { t } from "i18next";
import { navigate } from "raviger";
import React from "react";
import { toast } from "sonner";

import * as Notifications from "@/Utils/Notifications";
import { HTTPError, StructuredError } from "@/Utils/request/types";
import { HTTPError } from "@/Utils/request/types";

export function handleHttpError(error: Error) {
// Skip handling silent errors and AbortError
if (("silent" in error && error.silent) || error.name === "AbortError") {
type Meta = QueryMeta | MutationMeta | undefined;

export type HttpErrorHandler = (error: HTTPError, meta: Meta) => boolean | void;

const httpErrorHandlers: HttpErrorHandler[] = [];

/**
* Registers a handler that will be called for all HTTP errors.
* The latest registered handler will be called first.
* @param handler - The handler to register.
*/
export function registerHttpErrorHandler(handler: HttpErrorHandler) {
httpErrorHandlers.splice(0, 0, handler);
}

/**
* Unregisters a handler that was previously registered using
* `registerHttpErrorHandler`.
* @param handler - The handler to unregister.
*/
export function unregisterHttpErrorHandler(handler: HttpErrorHandler) {
httpErrorHandlers.splice(httpErrorHandlers.indexOf(handler), 1);
}

/**
* Registers a handler that will be called for all HTTP errors.
*
* When the handler returns `true`, the error is considered handled and no
* other handlers will be called.
*
* The error handler will be unregistered when the component unmounts.
* @param handler - The handler to register.
*/
export function useHttpErrorHandler(handler: HttpErrorHandler) {
const handlerRef = React.useRef(handler);

React.useEffect(() => {
registerHttpErrorHandler(handlerRef.current);
return () => unregisterHttpErrorHandler(handlerRef.current);
}, [handlerRef]);
}

/**
* Handles HTTP errors.
* @param error - The error to handle.
*/
export function handleHttpError(error: Error, meta?: Meta) {
// If the error is an AbortError, skip further handling.
if (error.name === "AbortError") {
return;
}

if (!(error instanceof HTTPError)) {
toast.error(error.message || t("something_went_wrong"));
// If the error is silenced, skip further handling.
//
// Voluntarily kept this check before the HTTPError instance check as errors
// from plugins may not be an instance of HTTPError, but plugins could choose
// to set the `silent` property regardless.
if ("silent" in error && error.silent) {
return;
}

const cause = error.cause;

if (isNotFound(error)) {
toast.error((cause?.detail as string) || t("not_found"));
// If the error is not an HTTPError, show a generic error message and skip
// further handling.
if (!(error instanceof HTTPError)) {
toast.error(error.message || t("something_went_wrong"));
return;
}

if (isSessionExpired(cause)) {
handleSessionExpired();
// Session expired handler is always called before any other handlers.
if (sessionExpiredHandler(error, meta)) {
return;
}

if (isBadRequest(error)) {
const errs = cause?.errors;
if (isPydanticError(errs)) {
handlePydanticErrors(errs);
// Other handlers are called in the order they were registered.
for (const handler of httpErrorHandlers) {
if (handler(error, meta)) {
return;
}
}

if (isStructuredError(cause)) {
handleStructuredErrors(cause);
// Default / fallback handlers are called last.
for (const handler of [detailHandler, notFoundHandler, badRequestHandler]) {
if (handler(error, meta)) {
return;
}

Notifications.BadRequest({ errs });
return;
}

toast.error((cause?.detail as string) || t("something_went_wrong"));
}

function isSessionExpired(error: HTTPError["cause"]) {
return (
// If Authorization header is not valid
error?.code === "token_not_valid" ||
// If Authorization header is not provided
error?.detail === "Authentication credentials were not provided."
);
// If no handler handled the error, show a generic error message.
toast.error(t("something_went_wrong"));
}

function handleSessionExpired() {
if (!location.pathname.startsWith("/session-expired")) {
navigate(`/session-expired?redirect=${window.location.href}`);
/**
* Handles HTTP errors with a `detail` property by showing it as an error.
*/
const detailHandler: HttpErrorHandler = ({ cause }) => {
if (cause && "detail" in cause && typeof cause.detail === "string") {
toast.error(cause.detail);
return true;
}
}
};

function isBadRequest(error: HTTPError) {
return error.status === 400 || error.status === 406;
}
/**
* Handles HTTP 400 Bad Request errors by showing a generic error message.
*/
const badRequestHandler: HttpErrorHandler = ({ status, cause }) => {
if (status !== 400) {
return false;
}

function isNotFound(error: HTTPError) {
return error.status === 404;
}
if (cause && "errors" in cause) {
for (const error of cause.errors) {
if ("type" in error && error.type === "value_error") {
// If error has a ctx property with an error property, show the error.
if (error.ctx && "error" in error.ctx) {
toast.error(error.ctx.error);
continue;
}

if (typeof error.msg === "string") {
toast.error(error.msg);
continue;
}
}
}
}

type PydanticError = {
type: string;
loc?: string[];
msg: string | Record<string, string>;
input?: unknown;
url?: string;
return true;
};

function isStructuredError(err: HTTPError["cause"]): err is StructuredError {
return typeof err === "object" && !Array.isArray(err);
}
/**
* Handles Session Expired / Invalid Token errors by redirecting to the
* session expired page.
*/
const sessionExpiredHandler: HttpErrorHandler = ({ cause }) => {
if (!cause || !("code" in cause) || !("detail" in cause)) {
return;
}

function handleStructuredErrors(cause: StructuredError) {
for (const value of Object.values(cause)) {
if (Array.isArray(value)) {
value.forEach((err) => toast.error(err));
return;
}
if (typeof value === "string") {
toast.error(value);
return;
}
if (
cause.code !== "token_not_valid" &&
cause.detail !== "Authentication credentials were not provided."
) {
return;
}
}

function isPydanticError(errors: unknown): errors is PydanticError[] {
return (
Array.isArray(errors) &&
errors.every(
(error) => typeof error === "object" && error !== null && "type" in error,
)
);
}
// If the user is not already on the session expired page, navigate to it.
if (!location.pathname.startsWith("/session-expired")) {
navigate(`/session-expired?redirect=${window.location.href}`);
}

function handlePydanticErrors(errors: PydanticError[]) {
errors.map(({ type, loc, msg }) => {
const message = typeof msg === "string" ? msg : Object.values(msg)[0];
if (!loc) {
toast.error(message);
return;
}
type = type
.replace("_", " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
toast.error(message, {
description: `${type}: '${loc.join(".")}'`,
duration: 8000,
});
});
}
return true;
};

/**
* Handles HTTP 404 Not Found errors by showing a generic error message.
*/
const notFoundHandler: HttpErrorHandler = ({ status }) => {
if (status === 404) {
toast.error(t("not_found"));
return true;
}
};
17 changes: 13 additions & 4 deletions src/Utils/request/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,23 @@ export interface ApiCallOptions<Route extends ApiRoute<unknown, unknown>> {
headers?: HeadersInit;
}

export type StructuredError = Record<string, string | string[]>;
export type BadRequestValueError = {
type: "value_error";
loc: string[];
msg: string;
input: unknown;
ctx?: { error?: string };
url: string;
};

type HTTPErrorCause = StructuredError | Record<string, unknown> | undefined;
export type BadRequestErrorResponse =
| { code: string; detail: string }
| { errors: (BadRequestValueError | object)[] };

export class HTTPError extends Error {
status: number;
silent: boolean;
cause?: HTTPErrorCause;
cause?: BadRequestErrorResponse | undefined;

constructor({
message,
Expand All @@ -57,7 +66,7 @@ export class HTTPError extends Error {
super(message, { cause });
this.status = status;
this.silent = silent;
this.cause = cause;
this.cause = cause as BadRequestErrorResponse | undefined;
}
}

Expand Down
1 change: 0 additions & 1 deletion src/Utils/request/uploadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const uploadFile = async (
}
Notification.BadRequest({ errs: error.errors });
reject(new Error("Client error"));
reject(new Error("Client error"));
} else {
resolve();
}
Expand Down
Loading
Loading