Skip to content

Gestion des erreurs [Proposition]

Ivan Gabriele edited this page Nov 27, 2023 · 11 revisions

Cas d'erreurs

Erreurs Inattendues & Non Gérées

Erreur internes ou provenant d'une librairie dont on ne gère pas le cas :

  • soit parce qu'on ne l'attrape pas,
  • soit parce qu'on l'attrape mais de manière générique,
  • soit parce qu'on l'attrape de manière ciblée mais ne la transformons pas.

Erreurs Inattendues & Gérées

Erreurs internes ou provenant d'une librairie dont on gère le cas en la transformant en une erreur typée et manipulée.

Erreurs Attendues & Non Gérées

Il ne devrait pas exister d'erreurs attendues et non gérées. S'il y en a, c'est qu'elles sont forcément Inattendues & Non Gérées. Il faut alors en corriger la gestion pour qu'elles deviennent Attendues & Gérées.

Erreurs Attendues & Gérées

Erreurs qui ne sont pas des erreurs de code mais représentent des actions impossibles (ex: tentative de suppression d'une entité liée à une autre et protégée par une clé étrangère).

Types d'erreurs

Inattendues & Non Gérées Inattendues & Gérées Attendues & Gérées
Backend - BackendApiError - BackendUsageError
Backend - BackendError
Frontend - FrontendApiError - FrontendUsageError
- FrontendError

Codes d'erreurs

BackendError

500 HTTP Status Code.

{
  "type": "BackendError",
  "code": "500"
}

BackendApiError

4XX HTTP Status Code en suivant les conventions.

Important

Tout sauf 400 et 5XX.

  • 401 : Données d'authentification manquantes ou mal formatées.
  • 403 : Données d'authentification comprise mais interdiction d'accès à la ressource.
  • 404 : Entité, enfant ou parent non trouvé.
  • 422 : Paramètre manquant ou mauvais format de paramètre. Propriété manquante ou mauvais format de propriété. Mauvais format de corps.

Example :

{
  "type": "BackendApiError",
  "code": "404"
}

BackendUsageError & FrontendUsageError

  • CHILD_ALREADY_ATTACHED : Tentative de ré-attachement d'un enfant à une entité qui a déjà cet enfant.
  • FOREIGN_KEY_CONSTRAINT : Tentative de suppression d'une entité liée à d'autres entités.
  • UNARCHIVED_CHILD : Tentative d'archivage d'une entité dont les enfants ne sont pas tous archivés.

Example :

{
  "type": "BackendUsageError",
  "code": "CHILD_ALREADY_ATTACHED"
}

Proposition de conventions

Réponse Backend

Note

Besoin d'autres props ?

{
  type: ApiErrorCode | null
}

Pour les erreurs inattendues, gérées ou non, type doit être égal à null.
Pour les erreurs attendues, type doit être égal à ApiErrorCode.

ApiErrorCode peut être :

Note

Liste à compléter.

  • CHILD_ALREADY_ATTACHED : Tentative de ré-attachement d'un enfant à une entité qui a déjà cet enfant.
  • FOREIGN_KEY_CONSTRAINT : Tentative de suppression d'une entité liée à d'autres entités.
  • UNARCHIVED_CHILD : Tentative d'archivage d'une entité dont les enfants ne sont pas tous archivés.

Sentry

Note

Tableau à compléter.

Erreurs génériques inattendues Erreurs d'API
Backend { side: "backend", type: "error" } { side: "backend", type: "api_error" }
Frontend { side: "frontend", type: "error" } { side: "frontend", type: "api_error" }

UX & UI

Erreur d'usage

  • Modal affichée à l'utilisateur expliquant pourquoi l'action est impossible.
  • Pas de log console.
  • Pas de log Sentry.

Erreur inattendue d'API

  • Modal ou Toast affichée à l'utilisateur explicitant quelle action a échouée.
  • Log console.
  • Log Sentry.

Erreur inattendue interne au code Frontend

  • ??? (=> décider de comment gérer ça).
  • Log console.
  • Log Sentry.

Implémentation

Backend

class ControllersExceptionHandler {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ForeignKeyConstraintException::class)
    fun handleForeignKeyConstraintException(e: ForeignKeyConstraintException): ApiError {
        return ApiError(ErrorCode.FOREIGN_KEY_CONSTRAINT)
    }

    // TODO À compléter !
}


class ApiError(val errorCode: ErrorCode) {
    // TODO À compléter !
}

enum class ErrorCode {
    /** Thrown when attempting to attach the same child to an entity. */
    CHILD_ALREADY_ATTACHED,
    /** Thrown when attempting to delete an entity linked to other entities. */
    FOREIGN_KEY_CONSTRAINT,
    /** Thrown when attempting to archive an entity linked to non-archived children. */
    UNARCHIVED_CHILD,

    // TODO À compléter !
}

Frontend

Types d'erreur

Erreur générique

class FrontendError extends Error {
  /** Technical error message for logs and debugging purpose. */
  public override message: string,
  /** Originally thrown error. */
  public originalError?: any,
  /** Sentry `Scope` object (tags, context, extras, etc). */
  public scope?: Scope
}

Erreur d'API

class FrontendApiError extends FrontendError {
  /** User-friendly message describing which operation failed. */
  public userMessage: string,
}

interface CustomRTKResponseError {
  path: string
  requestData: AnyObject | undefined
  responseData: BackendApiErrorResponse
  status: number | 'FETCH_ERROR' | 'PARSING_ERROR' | 'TIMEOUT_ERROR' | 'CUSTOM_ERROR'
}

interface BackendApiErrorResponse {
  type: ApiErrorCode | null
}

enum ApiErrorCode {
  /** Thrown when attempting to attach the same child to an entity. */
  CHILD_ALREADY_ATTACHED = 'CHILD_ALREADY_ATTACHED',
  /** Thrown when attempting to delete an entity linked to other entities. */
  FOREIGN_KEY_CONSTRAINT = 'FOREIGN_KEY_CONSTRAINT',
  /** Thrown when attempting to archive an entity linked to non-archived children. */
  UNARCHIVED_CHILD = 'UNARCHIVED_CHILD'
}

Erreur d'usage

class UsageError {
  /** User-friendly message explaining why the operation couldn't be processed. */
  public userMessage: string
}

Affichage des erreurs

Note

La proposition ici est de gérer les erreurs en vanilla JS pour pouvoir créer des fonctions de catch qui puissent être appelés de n'importe où.

class NotifierEvent extends CustomEvent<NotifierEventDetail> {
  /** Should the error be displayed as a modal or as a toast? */
  public isModalError: boolean
  /** Should the error be displayed in the side-window? */
  public isSideWindow: boolean
  public type: 'error' | 'info' | 'success' | 'warning'
}

export type NotifierEventDetail = {
  isDialogError: boolean
  isSideWindowError: boolean
  message: string
  type: TypeOptions
}

// TODO La mettre dans la classe `FrontendError` en static ?
function handleErrorIfAny(
  error: any,
  isSideWindowError: boolean = false
) {
  if (!error) {
    return
  }

  if (error.userMessage) {
    const isDialogError = error instanceof UsageError

    window.document.dispatchEvent(new NotifierEvent(error.userMessage, 'error', isDialogError, isSideWindowError))

    return
  }

  if (error instanceof FrontendError) {
    // TODO Définir quoi faire ici

    return
  }

  // TODO Définir quoi faire ici.
  // throw new FrontendError('An unexpected error happened.', err)
}

// Utilisation
const { data, error } = useGetMyEntityQuery()
handleErrorIfAny(error)


try {
  const response = await dispatch(commentsApi.endpoints.myEndpoint.initiate(params)).unwrap()
} catch(err) {
  handleErrorIfAny(err)
}

RTK

Personnalisation des erreurs RTK

type RTKBaseQueryArgs =
  // Query
  | string
  // Mutation
  | {
      body?: AnyObject
      method: 'DELETE' | 'POST' | 'PUT'
      /** URL Path (and not full URL). */
      url: string
    }

const myApiBaseQuery = retry(fetchBaseQuery({ baseUrl }, { maxRetries })

export const myApi = createApi({
  baseQuery: async (args: RTKBaseQueryArgs, api, extraOptions) => {
    const result = await normalizeRtkBaseQuery(myApiBaseQuery)(args, api, extraOptions)
    if (result.error) {
      const error: CustomRTKResponseError = {
        path: typeof args === 'string' ? args : args.url,
        requestData: typeof args === 'string' ? undefined : args.body,
        responseData: result.error.data as BackendApiErrorResponse,
        status: result.error.status
      }

      return { error }
    }

    return result
  },
  // ...
})

Requêtes RTK

Important

TOUTES les fonctions d'endpoint doivent renvoyer soit une FrontendApiError soit une UsageError.

// Unexpected errors
const CREATE_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE = "Nous n'avons pas pu créer ce moyen."
const CAN_DELETE_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE = "Nous n'avons pas pu vérifier si ce moyen est supprimable."
const DELETE_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE = "Nous n'avons pas pu supprimé ce moyen."
const GET_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE = "Nous n'avons pas pu récupérer ce moyen."

// Usage errors
const IMPOSSIBLE_CONTROL_UNIT_RESOURCE_DELETION_ERROR_MESSAGE =
  "Ce moyen est rattaché à des missions. Veuillez l'en détacher avant de le supprimer."

export const monitorenvControlUnitResourceApi = monitorenvApi.injectEndpoints({
  endpoints: builder => ({
    canDeleteControlUnitResource: builder.query<boolean, number>({
      transformErrorResponse: response =>
        new FrontendApiError(CAN_DELETE_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE, response),
    }),

    createControlUnitResource: builder.mutation<void, ControlUnit.NewControlUnitResourceData>({
      transformErrorResponse: response =>
        new FrontendApiError(CREATE_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE, response)
    }),

    // Deux cas ici : l'erreur d'usage attendue et l'erreur inattendue
    deleteControlUnitResource: builder.mutation<void, number>({
      transformErrorResponse: response => {
        if (response.responseData.type === ApiErrorCode.FOREIGN_KEY_CONSTRAINT) {
          return new UsageError(IMPOSSIBLE_CONTROL_UNIT_RESOURCE_DELETION_ERROR_MESSAGE)
        }

        return new FrontendApiError(DELETE_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE, response)
      }
    }),

    getControlUnitResource: builder.query<ControlUnit.ControlUnitResource, number>({
      transformErrorResponse: response =>
        new FrontendApiError(GET_CONTROL_UNIT_RESOURCE_ERROR_MESSAGE, response)
    }),

    // etc...
  })
})