FastAPI returns errors in different shapes depending on the error type: 422 Unprocessable Entity has a detail array of validation issues, 4xx errors have a detail string, 5xx errors may have no body at all, and network failures have no response. Without a normalisation layer, every component that handles errors needs its own code to parse these different shapes. A centralised normaliseApiError utility converts all of these into a consistent string message that can be displayed directly in the UI.
API Error Normalisation Utility
// src/utils/apiErrors.js
/**
* Convert any RTK Query or Axios error into a user-friendly string.
*
* FastAPI error shapes:
* 422: { detail: [{ loc: ["body", "title"], msg: "field required", type: "missing" }] }
* 4xx: { detail: "Not found" }
* 4xx: { detail: "Invalid credentials" }
* 5xx: may have no body
* Network: { status: "FETCH_ERROR", error: "Failed to fetch" }
*/
export function normaliseApiError(error, fallback = "Something went wrong") {
if (!error) return fallback;
// RTK Query network/fetch failure
if (error.status === "FETCH_ERROR") {
return "Network error — check your connection and try again";
}
// RTK Query timeout
if (error.status === "TIMEOUT_ERROR") {
return "Request timed out — please try again";
}
// RTK Query parsing error
if (error.status === "PARSING_ERROR") {
return "Unexpected server response";
}
const data = error.data ?? error.response?.data;
if (!data) return fallback;
// FastAPI 422: detail is an array of validation issues
if (Array.isArray(data.detail)) {
return data.detail
.map((issue) => {
const field = issue.loc?.[issue.loc.length - 1] ?? "field";
return `${field}: ${issue.msg}`;
})
.join("; ");
}
// FastAPI 4xx/5xx: detail is a string
if (typeof data.detail === "string") {
return data.detail;
}
// Generic message field
if (typeof data.message === "string") {
return data.message;
}
return fallback;
}
/**
* Extract per-field errors from a FastAPI 422 response.
* Returns { fieldName: "error message" } for use with form error state.
*/
export function extractFieldErrors(error) {
const data = error?.data ?? error?.response?.data;
if (!Array.isArray(data?.detail)) return {};
const fieldErrors = {};
for (const issue of data.detail) {
// loc example: ["body", "title"] — take the last segment as field name
const field = issue.loc?.[issue.loc.length - 1];
if (field && field !== "__root__") {
fieldErrors[field] = issue.msg;
}
}
return fieldErrors;
}
// Usage in a component:
// const { error } = useCreatePostMutation();
// const message = normaliseApiError(error, "Failed to create post");
//
// For form field errors:
// const fieldErrors = extractFieldErrors(error);
// setErrors(fieldErrors);
Note: RTK Query wraps HTTP errors differently from Axios. With RTK Query and
fetchBaseQuery, an HTTP error gives you { status: 422, data: { detail: [...] } } — the response body is in error.data. With Axios, it is in error.response.data. The normaliseApiError utility handles both by checking error.data ?? error.response?.data, making it usable regardless of whether you use RTK Query or a raw Axios call.Tip: Wire
normaliseApiError to the toast notification system so error handling in components is a single line: toast.error(normaliseApiError(error)). Create a higher-level utility: function showApiError(error, toast) { toast.error(normaliseApiError(error)); }. Components that perform mutations call showApiError(error, toast) in the catch block without any error-parsing logic of their own.Warning: Do not expose raw server error messages to users in production. FastAPI’s internal error messages may contain stack traces, SQL queries, file paths, or other sensitive technical details on unhandled 500 errors. Filter 5xx errors before displaying:
if (error.status >= 500) return "Server error — please try again later". Reserve the detailed error message for 4xx client errors where the user can take action based on the information.Error Handling in Mutation Components
import { useCreatePostMutation } from "@/store/apiSlice";
import { useToast } from "@/context/ToastContext";
import { normaliseApiError, extractFieldErrors } from "@/utils/apiErrors";
function PostEditorPage() {
const toast = useToast();
const [createPost, { isLoading }] = useCreatePostMutation();
const [fieldErrors, setFieldErrors] = useState({});
async function handleSubmit(formValues) {
try {
const post = await createPost(formValues).unwrap();
toast.success("Post published!");
navigate(`/posts/${post.id}`);
} catch (error) {
// Try to show field-level errors (422)
const fields = extractFieldErrors(error);
if (Object.keys(fields).length > 0) {
setFieldErrors(fields);
} else {
// Fall back to a toast for non-field errors
toast.error(normaliseApiError(error, "Failed to save post"));
}
}
}
// ...
}
Global 401 Handling in RTK Query
// Handle token expiry globally — no need to check 401 in every component
import { fetchBaseQuery } from "@reduxjs/toolkit/query";
import { Mutex } from "async-mutex";
const mutex = new Mutex(); // prevent multiple simultaneous refresh attempts
const baseQuery = fetchBaseQuery({ baseUrl: config.apiBaseUrl, prepareHeaders });
export const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
// Wait if another refresh is in progress
if (!mutex.isLocked()) {
const release = await mutex.acquire();
try {
const refreshToken = localStorage.getItem("refresh_token");
const refreshResult = await baseQuery(
{ url: "/auth/refresh", method: "POST", body: { refresh_token: refreshToken } },
api, extraOptions
);
if (refreshResult.data) {
const { access_token, refresh_token } = refreshResult.data;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
result = await baseQuery(args, api, extraOptions); // retry
} else {
// Refresh failed — log out
localStorage.removeItem("access_token");
window.location.href = "/login";
}
} finally {
release();
}
} else {
await mutex.waitForUnlock();
result = await baseQuery(args, api, extraOptions);
}
}
return result;
};
Common Mistakes
Mistake 1 — Accessing .data directly on RTK Query result (throws if error)
❌ Wrong — unwrap() is safer:
const result = await createPost(data);
const post = result.data; // undefined if error! result.error has the error
✅ Correct — unwrap() throws on error, returns data on success:
const post = await createPost(data).unwrap(); // ✓ throws on error → goes to catch
Quick Reference
| Utility | Returns | Use For |
|---|---|---|
normaliseApiError(error) |
string message | Toast notifications |
extractFieldErrors(error) |
object: field → message | Inline form errors on 422 |
mutation.unwrap() |
data or throws | Clean try/catch in handlers |