Error Handling — Consistent API Error UX Across the App

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

🧠 Test Yourself

FastAPI returns a 422 with { "detail": [{ "loc": ["body", "slug"], "msg": "value is not a valid string" }] }. What does normaliseApiError(error) return?