CDLUC3 / dmsp_frontend_prototype

Repo to test out new NextJS framework
MIT License
0 stars 0 forks source link

[Spike] - Research error handling strategy or architecture #92

Open jupiter007 opened 3 weeks ago

jupiter007 commented 3 weeks ago

1) Define ways in which errors from graphql will be caught and processed 2) Determine how to handle different types of errors (higher level errors, page- or field-level errors 3) Establish conventions for error messages and user feedback

We can establish what we need the backend server to return. For example, other than the status code, what do we need in the returned error object. For field-level errors, should the backend return an array of objects with each object containing the field name and error message?

jupiter007 commented 3 weeks ago

Forms

For any forms, there should be both frontend and backend validation of fields.

Apollo Server can return an errors array in the response that looks like:

    const errorResponse = [
      {"error": "username error message", "path": ["username"]},
      {"error": "password error message", "path": ["password"]}
    ];

These errors can then be displayed below the form fields accordingly.

Here is an example of frontend code where field validations are done, and field errors in the response from the backend are also handled:


"use client"

import React, { useState } from "react";
import { useAddContributorRoleMutation } from "@/generated/graphql";

type FieldError = {
    error: string;
    path: string;
};

type ContributorRoleFormState = {
    url: string;
    label: string;
    displayOrder: number;
    description: string;
};

type FieldErrors = {
    [key in keyof ContributorRoleFormState]?: string;
};

/**
 * This is a test page to demo and test the use of graphql hooks on the client side.
 * Client-side graphql requests uses the apollo-wrapper.tsx file
 * @returns 
 */
const AddContributorRoleForm: React.FC = () => {
    // Form state
    const [formState, setFormState] = useState<ContributorRoleFormState>({
        url: '',
        label: '',
        displayOrder: 0,
        description: '',
    });
    const [errors, setErrors] = useState({
        url: '',
        label: '',
        displayOrder: '',
        description: '',
    })

    // State for field-level errors
    const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});

    // Use the generated mutation hook
    const [addContributorRoleMutation, { data, loading, error }] = useAddContributorRoleMutation();

    // Handle input change
    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
        const { name, value } = e.target;
        setFormState({
            ...formState,
            [name]: name === 'displayOrder' ? parseInt(value) : value,
        });
    };

    const validateForm = () => {
        const newErrors = { url: '', label: '', displayOrder: '', description: '' };
        if (!formState.url) newErrors.url = 'Url is required';
        if (!formState.label) newErrors.label = 'Label is required';
        if (!formState.displayOrder) newErrors.displayOrder = 'DisplayOrder is required';
        if (!formState.description) newErrors.description = 'Description is required';
        return newErrors;
    }

    const handleSubmit = async (e: FormEvent) => {
        e.preventDefault();

        const validationErrors = validateForm();
        setFieldErrors(validationErrors);

        if (!fieldErrors) {
            try {
                const response = await addContributorRoleMutation({
                    variables: formState,
                });

                if (response?.data.addContributorRole?.success) {
                    console.log('Contributor role added successfully:', response.data.addContributorRole.contributorRole);
                    setFieldErrors({})

                } else {
                    console.error('Failed to add contributor role:', response.data.addContributorRole.message);
                    const errors = response?.data?.addContributorRoles?.errors || [];
                    const errorMap: FieldErrors = {};
                    errors.forEach(err => {
                        errorMap[err.path as keyof ContributorRoleFormState] = err.error;
                    });
                    setFieldErrors(errorMap);
                }
            } catch (err) {
                console.error('Error executing mutation', err);
            }
        }

    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <input
                    type="text"
                    name="url"
                    placeholder="URL"
                    value={formState.url}
                    onChange={handleChange}
                />
                {fieldErrors.url && <div style={{ color: 'red' }}>{fieldErrors.url}</div>}
            </div>

            <div>
                <input
                    type="text"
                    name="label"
                    placeholder="Label"
                    value={formState.label}
                    onChange={handleChange}
                />
                {fieldErrors.label && <div style={{ color: 'red' }}>{fieldErrors.label}</div>}
            </div>
            <div>
                <input
                    type="number"
                    name="displayOrder"
                    placeholder="Display Order"
                    value={formState.displayOrder}
                    onChange={handleChange}
                />
                {fieldErrors.displayOrder && <div style={{ color: 'red' }}>{fieldErrors.displayOrder}</div>}
            </div>
            <div>

                <input
                    type="text"
                    placeholder="Description"
                    name="description"
                    value={formState.description}
                    onChange={handleChange}
                />
                {fieldErrors.description && <div style={{ color: 'red' }}>{fieldErrors.description}</div>}
            </div>
            <button type="submit" disabled={loading}>
                {loading ? 'Submitting...' : 'Add Contributor Role'}
            </button>
            {error && <p>Error: {error.message}</p>}

        </form>
    );
}

export default AddContributorRoleForm;

GraphQL

When the client component function making the GraphQL request catches an error, we should use a shared errorHandler function to check for graphQLErrors and networkError and handle accordingly. GraphQL errors will be caught in the catch part of the try-catch.

I'm picturing the error handler will look something like this:

'use client'

import { ApolloError } from '@apollo/client';
import { useRouter } from 'next/navigation';
import logECS from '@/utils/clientLogger';

export const handleError = (error: any, setFlashMessage: (message: { type: 'success' | 'error' | 'info', message: string }) => void) => {
    let message = 'An unexpected error occurred';
    const router = useRouter();
    // Handle Apollo Client GraphQL errors
    if (error instanceof ApolloError) {
        if (error.graphQLErrors.length > 0) {
            error.graphQLErrors.forEach(graphQLError => {
                switch (graphQLError.extensions?.code) {
                    case 'UNAUTHORIZED':
                        setFlashMessage({ type: 'error', message: 'You are not authenticated. Please log  in.' })
                        logECS('error', `[GraphQL Error]: UNAUTHORIZED - ${graphQLError.message}`, {
                            error: graphQLError
                        });
                        router.push('/login');
                        break;
                    case 'FORBIDDEN':
                        setFlashMessage({ type: 'error', message: 'You do not have permission to access this resource.' })
                        logECS('error', `[GraphQL Error]: FORBIDDEN - ${graphQLError.message}`, {
                            error: graphQLError
                        });
                        router.push('/login');
                        break;
                    case 'BAD_USER_INPUT':
                        message = 'There was a problem with your input.'
                        break;
                    default:
                        message = graphQLError.message;
                }
            });
        } else if (error.networkError) {
            message = 'Network error occurred.'
        }
    } else if (error.response) {
        // Handle HTTP status codes
        switch (error.response.status) {
            case 404:
                setFlashMessage({ type: 'error', message: 'Something went wrong.' })
                logECS('error', `404 Error - ${error.message}`, {
                    error: error
                });
                router.push('/404-error');
                break;
            case 500:
                setFlashMessage({ type: 'error', message: 'Something went wrong.' })
                logECS('error', `500 Error - ${error.message}`, {
                    error: error
                });
                router.push('/500-error');
                break;
            default:
                message = `Error: ${error.response.statusText}`;
                logECS('error', message, {
                    error: error
                });
        }
    } else if (error.message) {
        // Handle other types of errors (e.g., client-side errors)
        setFlashMessage({ type: 'error', message: 'Something went wrong.' })
        router.push('/500-error');
        return;
        //message = error.message;
    }

    return message;

}

Also, the Apollo Client instance will include an errorLink which will allow us to handle specific types of graphQLErrors:

import { ApolloClient, InMemoryCache, createHttpLink, from } from "@apollo/client";
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { useRouter } from 'next/router';
import { createAuthLink } from '@/utils/authLink';
import logECS from '@/utils/clientLogger';

const httpLink = createHttpLink({
    uri: `${process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT}/graphql`
});

const authLink = createAuthLink();

const retryLink = new RetryLink({
    attempts: {
        max: 3, // Maximum number of retry attempts
        retryIf: (error, _operation) => {
            // Retry on network errors
            return !!error.networkError;
        }
    },
    delay: {
        initial: 1000, // Initial delay in milliseconds
        max: 5000, // Maximum delay in milliseconds
        jitter: true // Add random jitter to the delay to help spread out retry attempts and avoid potential overloading of backend system
    }
});

interface CustomError extends Error {
    customInfo?: { errorMessage: string }
}

const router = useRouter();

const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
    if (graphQLErrors) {
        graphQLErrors.forEach(({ message, extensions }) => {
            //Check for specific error codes
            switch (extensions?.code) {
                case 'UNAUTHORIZED':
                    logECS('error', `[GraphQL Error]:  - ${message}`, {
                        errorCode: 'UNAUTHORIZED'
                    });
                    router.push('/login');
                    break;
                case 'FORBIDDEN':
                    logECS('error', `[GraphQL Error]: FORBIDDEN - ${message}`, {
                        errorCode: 'FORBIDDEN'
                    });
                    // Rediret to dashboard
                    router.push('/');
                    break;
                case 'INTERNAL_SERVER_ERROR':
                    logECS('error', `[GraphQL Error]: INTERNAL_SERVER_ERROR - ${message}`, {
                        errorCode: 'INTERNAL_SERVER_ERROR'
                    });
                    router.push('/500-error');
                    break;
                default:
                    logECS('error', `[GraphQL Error]: ${message}`, {
                        errorCode: 'GRAPHQL'
                    });
                    break;
            }
        })
    }

    if (networkError) {
        logECS('error', `[GraphQL Error Network Error]: ${networkError.message}`, {
            errorCode: 'NETWORK_ERROR'
        });
        const customNetworkError = networkError as CustomError;

        customNetworkError.customInfo = { errorMessage: 'There was a problem ' };
        operation.setContext({ networkError: customNetworkError });
    }
});

export const createApolloClient = () => {
    return new ApolloClient({
        link: from([errorLink, authLink, retryLink, httpLink]),
        cache: new InMemoryCache(),
    });
};

Flash Messages

We should probably implement Flash Messages in the frontend app. We can set flash messages in the Apollo Client before redirecting the user on errors like UNAUTHORIZED, FORBIDDEN, and INTERNAL_SERVER_ERROR. Flash Messages could be implemented using a FlashMessageContext.

The FlashMessageContext would display until the user navigated away from the page. The code would look something like this:

'use client'

import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';

type FlashMessageType = 'success' | 'error' | 'info';

interface FlashMessage {
    type: FlashMessageType;
    message: string;
}

interface FlashMessageContextType {
    flashMessage: FlashMessage | null;
    setFlashMessage: (message: FlashMessage) => void;
    clearFlashMessage: () => void;
}

const FlashMessageContext = createContext<FlashMessageContextType | undefined>(undefined);

export const FlashMessageProvider = ({ children }: { children: ReactNode }) => {
    const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);

    useEffect(() => {
        // Load flash message from localStorage on initial render
        const storedMessage = localStorage.getItem('flashMessage');
        if (storedMessage) {
            setFlashMessage(JSON.parse(storedMessage));
        }
    }, []);

    const setFlashMessageWithStorage = (message: FlashMessage) => {
        setFlashMessage(message);
        localStorage.setItem('flashMessage', JSON.stringify(message));
    };

    const clearFlashMessage = () => {
        setFlashMessage(null);
        localStorage.removeItem('flashMessage');
    };

    return (
        <FlashMessageContext.Provider value={{ flashMessage, setFlashMessage: setFlashMessageWithStorage, clearFlashMessage }}>
            {children}
        </FlashMessageContext.Provider>
    );
};

export const useFlashMessage = () => {
    const context = useContext(FlashMessageContext);
    if (!context) {
        throw new Error('useFlashMessage must be used within a FlashMessageProvider');
    }
    return context;
};
jupiter007 commented 3 weeks ago

I've added documentation on form field validations, adding a shared error handler for handling different error types and using Flash Messages, especially for redirects.

I will close out this ticket for now, and the above can be implemented once the backend is able to complete this ticket: https://github.com/CDLUC3/dmsp_backend_prototype/issues/68