Introduction
In Part 2, I wrote about how I implemented authentication, next, I want to discuss errors, a rather ambiguous topic, especially challenging when the backend and frontend are developed separately. However, I already had my own plan and vision for how to implement this to make it as convenient as possible on both sides. The advantage is that I’m working alone and don’t need to convince anyone else. So, let’s begin.
The main issue is that some developers rely on status codes, while others always return a 200 code with is_success: true/false and include the error text in the body. I don’t particularly like either approach because, over time, it all turns into spaghetti code, especially in large projects. The essence of the idea is to have an enum on the backend that clearly indicates the type of error, so there’s no need to look at the status or message. If desired, everything can be tied to the backend and take a step towards Backend-Driven UI, but I decided to do it a bit differently: only the type is on the backend, while the frontend maps error types to specific classes with messages and other information.
Backend (FastAPI)
Often, the code looks like this:
@auth_router.post(
"/register",
auth_required=False,
status_code=status.HTTP_201_CREATED,
description="API endpoint for user registration",
response_model=UserData,
)
async def register(
data: UserCreateSchema,
auth_controller: AuthController = Depends(get_auth_controller),
settings: AppSettings = Depends(get_settings),
) -> JSONResponse:
logger.info(f"Register customer {data.email}")
try:
result = await auth_controller.register(data)
except CustomException_1 as exc:
...
except CustomException_2 as exc:
...
except CustomException_N as exc:
...
This leads to a long list of exceptions and corresponding status codes and messages. The drawbacks are evident.
FastAPI allows for a well-structured error architecture using exception handlers. My proposal is to define error types and a base class that stores the desired status code to return to the client.
Error Type
class ErrorType(StrEnum):
unauthorized = "unauthorized"
bad_request = "bad_request"
user_exists = "user_exists"
forbidden = "forbidden"
Error Class
class ApplicationError(Exception):
message: str
code: int
error_type: ErrorType
def __init__(self, message: Optional[str]):
self.message = message or self.message
@property
def text(self) -> str:
return self.message
We can then declare an exception handler:
async def application_error_handler(
req: Request, exc: ApplicationError
) -> JSONResponse:
logger.error(f"Application error - msg={exc.text}, type={exc.error_type}")
return JSONResponse(
{"detail": exc.text, "error_type": exc.error_type},
status_code=exc.code,
headers={REQUEST_UID: get_request_id()},
)
And add it during application initialization:
def init_exc_handlers(application: FastAPI) -> None:
application.add_exception_handler(ApplicationError, application_error_handler)
This setup allows us to declare any error types, raise them at various levels of the application (be it RepositoryError, ServiceError), and eliminates boilerplate in handlers. Additionally, we can configure alerting in Slack/Telegram within this handler.
Now it looks like that:
@auth_router.post(
"/register",
auth_required=False,
status_code=status.HTTP_201_CREATED,
description="API endpoint for user registration",
response_model=UserData,
)
async def register(
data: UserCreateSchema,
auth_controller: AuthController = Depends(get_auth_controller),
settings: AppSettings = Depends(get_settings),
) -> JSONResponse:
logger.info(f"Register customer {data.email}")
return await auth_controller.register(data)
Frontend(NextJS)
As an advocate of OOP, I wanted a base error class and a client class to handle errors. Since I decided to keep some logic on the frontend, it’s necessary to process the errors defined on the backend.
We declare error types:
export type ErrorTypes =
| "unauthorized"
| "bad_request"
| "forbidden"
| "not_found"
| "invalid_credentials"
Error Class
export interface ApiError {
message: string;
code: number;
error_type: ErrorTypes;
}
API Client
export class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_URL,
paramsSerializer: { indexes: null },
});
this.client.defaults.headers.post["Content-Type"] = "application/json";
this.client.defaults.withCredentials = true;
this.client.defaults.maxRedirects = 1;
}
private async callApi(method: Method, url: string, requestData?: any, params?: any): Promise<AxiosResponse> {
try {
return await this.client.request({ method, url, data: requestData, params });
} catch (error) {
if (isAxiosError(error)) {
const errorCode = error.response?.status || 500;
const errorMessage = error.response?.data?.detail || "An error occurred";
const error_type = error.response?.data?.error_type || "unknown";
throw {
message: errorMessage,
code: errorCode,
error_type,
} as ApiError;
}
throw {
message: `Unexpected error: ${error}`,
code: 500,
error_type: "unknown",
} as ApiError;
}
}
public async registerUser(data: UserCreateSchema): Promise<UserData> {
const response = await this.callApi("POST", "/register", data);
return response.data;
}
}
Next, we’ll map and implement errors based on their types. I chose to display errors in a modal window, which I find most convenient. Of course, I’m not a UX expert, so this is based solely on my experience. I divided all errors into two types: common ones, where the user can only close the window, and those that allow the user to take some action—renew a subscription, navigate to another page, etc. This approach seems to ease the user’s experience, reducing the time/actions needed to correct an error.
Implementation for common errors:
import Button from "@/src/components/shared/Button";
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/react";
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/24/outline";
import React from "react";
export interface DefaultProps {
message: string;
title: string;
}
interface ModalError {
isOpen: boolean;
onClose: () => void;
}
class CommonError extends React.Component<ModalError> {
protected defaultData: DefaultProps = { message: "", title: "" };
render() {
return (
<Dialog
open={this.props.isOpen}
onClose={this.props.onClose}
className="relative z-50 transition duration-300 ease-out data-[closed]:opacity-0"
transition
>
<DialogBackdrop className="fixed inset-0 bg-black/30" />
<div className="fixed inset-0 flex w-screen items-center justify-center">
<DialogPanel className="w-5/6 md:w-3/6 lg:w-2/6 bg-white flex flex-col space-y-3 rounded-xl p-4 pb-5">
<DialogTitle className={"text-gray-600 "}>
<div className={"flex flex-col items-center"}>
<div className={"w-full flex justify-between items-center"}>
<p className={"font-bold font-display text-base px-2"}>{this.defaultData.title}</p>
<XMarkIcon className={"size-5 hover:opacity-75 hover:cursor-pointer"} onClick={this.props.onClose} />
</div>
</div>
</DialogTitle>
<div className={" flex-row flex text-gray-600 space-x-1 items-center py-2 px-3 bg-gray-100 rounded-xl"}>
<ExclamationCircleIcon className={"size-4"} />
<div className={"font-arimo text-sm "}>{this.defaultData.message}</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
}
Now we can define errors as classes:
export class BadRequestError extends CommonError {
protected defaultData: DefaultProps = {
title: "Bad Request",
message: "The request was invalid. Please check and try again.",
};
}
export class ForbiddenError extends CommonError {
protected defaultData: DefaultProps = {
title: "Access Denied",
message: "You do not have permission to perform this action.",
Implementation for errors requiring action:
export interface DefaultActionProps extends DefaultProps {
href: string;
buttonText: string;
}
class ActionRequiredError extends CommonError {
protected defaultData: DefaultActionProps = {
message: "",
title: "",
href: "",
buttonText: "",
};
render() {
return (
<Dialog
open={this.props.isOpen}
onClose={this.props.onClose}
className="relative z-50 transition duration-300 ease-out data-[closed]:opacity-0"
transition
>
<DialogBackdrop className="fixed inset-0 bg-black/30" />
<div className="fixed inset-0 flex w-screen items-center justify-center">
<DialogPanel className="w-1/4 bg-white flex flex-col space-y-3 rounded-xl p-4 pb-5">
<DialogTitle className={"text-gray-600 "}>
<div className={"flex flex-col items-center"}>
<div className={"w-full flex justify-between items-center"}>
<p className={"font-bold font-display text-base px-2"}>{this.defaultData.title}</p>
<XMarkIcon className={"size-5 hover:opacity-75 hover:cursor-pointer"} onClick={this.props.onClose} />
</div>
</div>
</DialogTitle>
<div className={" flex-row flex text-gray-600 space-x-1 items-center py-2 px-3 rounded-xl"}>
<ExclamationCircleIcon className={"size-4"} />
<div className={"font-arimo text-sm"}>{this.defaultData.message}</div>
</div>
<div className={"w-full py-1"}>
<Button className={"w-full rounded-xl"} href={this.defaultData.href} color={"sky"}>
{this.defaultData.buttonText}
</Button>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
}
export class SubscriptionError extends ActionRequiredError {
protected defaultData: DefaultActionProps = {
title: "Action is Not Allowed",
message: "You don't have an active subscription.",
href: "/prices",
buttonText: "Subscribe",
};
}
I map error types to their corresponding UI classes using a factory:
type ErrorType = typeof CommonError;
class ErrorFactory {
private static errorMapping: Record<ErrorTypes, ErrorType> = {
unauthorized: UnauthorizedError,
bad_request: BadRequestError,
forbidden: ForbiddenError,
not_found: NotFoundError,
...
};
protected static getError(errorType: ErrorTypes): ErrorType {
return this.errorMapping[errorType] || UnknownError;
}
public static getErrorComponent(errorType: ErrorTypes, isOpen: boolean, onClose: () => void): React.JSX.Element {
const Error = this.getError(errorType);
return <Error onClose={onClose} isOpen={isOpen} />;
}
}
export default ErrorFactory;
Here’s how it integrates into a real form:
const AuthForm = () => {
const [error, setError] = useState<ApiError | null>(null);
const [openError, setOpenError] = useState<boolean>(false);
const onSignUp: SubmitHandler<LoginSchema> = async (data) => {
try {
await backendClient.loginUser(data);
} catch (error) {
setError(error as ApiError);
setOpenError(true);
}
};
return (
<form onSubmit={handleSubmit(onSignUp)}>
{error &&
ErrorFactory.getErrorComponent(error.error_type, openError, () => {
setOpenError(false);
})}
...
</form>
);
};
How it looks like:
Final Thoughts
This error-handling architecture strikes a solid balance between structure, flexibility, and UX. While not without trade-offs, it’s proven scalable and productive for a solo developer environment or small teams.
✅ Pros
- Clear Error Typing.
(Enums ensure consistent, predictable error handling across the app) - Frontend Ownership of UX
(Full control over how each error is presented and localized) - Extensible with OOP.
(Easy to add new error types via class inheritance and centralized mapping) - Centralized Backend Handling.
(One exception handler for all application errors, reducing duplication and adding observability hooks (like Slack alerts))
⚠️ Cons
- Manual Sync Between Frontend/Backend. (Error types must be updated in both codebases unless automated).
- Tied to Class-Based Components.
(Modals are built with class components, which may limit future React upgrades or hook integrations)