Gestion des erreurs RXJS -NGRX

Clara Belair - Apr 9 '21 - - Dev Community

Dans un projet frontend, lorsqu'on fait un appel HTTP, il ne faut pas oublier la gestion des cas d'erreurs. Un appel HTTP peut être en erreur pour diverses raisons, on peut citer :

  • un serveur est inaccessible : le backend est "tombé" à cause d'une erreur interne par exemple
  • un timeout si la requête prend plus d'un certain temps à répondre
  • une erreur renvoyée par le backend avec un message spécifique : l'utilisateur n'a pas le droit d'accéder à cette ressource par exemple

Dans chaque cas, si le frontend ne gère pas ces erreurs, on se retrouve avec une application qui fonctionne mal ou, dans le pire des cas, plus du tout.

Dans cet article, je vais vous présenter la façon de gérer vos erreurs lors d'un appel HTTP pour un projet Angular. On verra d'abord la gestion des erreurs dans un subscribe, puis la gestion des erreurs dans un effect.

Prenons l'exemple d'un service HobbitsService et de la méthode findHobbits qui fait un appel HTTP pour retourner un observable d'une liste d'Hobbits.

@Injectable()
export class HobbitsService {
  constructor(private http: HttpClient) {}

  findHobbits(): Observable<Hobbit[]> {
    return this.http.get<Hobbit[]>('api/hobbits');
  }
}
Enter fullscreen mode Exit fullscreen mode

On veut afficher la liste des Hobbits, et pendant que la requête HTTP est en cours, on affiche un loader à l'utilisateur.

Gérer les erreurs dans un subscribe

Exemple d'une erreur non traitée

Dans le composant HobbitsComponent une liste d'Hobbits est récupérée à l'initialisation du composant. Un loader est affiché lorsque le booléen isLoading est à true.

export class HobbitsComponent implements OnInit {

  isLoading = true;
  hobbits: Hobbit[] = [];

  constructor(private hobbitsService: HobbitsService) {}

  ngOnInit() {
    this.hobbitsService.findHobbits().subscribe(
      (hobbits: Hobbit[]) => {
        this.hobbits = hobbits;
        this.isLoading = false;
      }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Que se passe-t-il si l'appel findHobbits échoue ?

Le loader va être affiché, sans s'arrêter, alors que l'appel est terminé.

Pourquoi ?

La gestion du statut du loader est placé dans la fonction NEXT du subscribe. Quand une erreur survient, on ne passe pas dans NEXT mais dans la fonction ERROR du subscribe.

NEXT, ERROR, COMPLETE : les 3 fonctions d'un subscribe

subscribe a 3 fonctions optionnelles : NEXT, ERROR, COMPLETE.

this.hobbitsService.findHobbits().subscribe(
    () => console.log('Next'),
    () => console.log('Error'),
    () => console.log('Completed')
);
Enter fullscreen mode Exit fullscreen mode

Si l'appel HTTP réussit, on voit les logs suivant :

Next
Completed
Enter fullscreen mode Exit fullscreen mode

En cas de succès, la valeur est émise dans la fonction NEXT. Puis l'observable se ferme et il passe dans la fonction COMPLETE. C'est la fin du lifecycle de l'observable, aucune erreur n'a été émise.

Si l'appel HTTP échoue, on voit les logs suivant :

Error
Enter fullscreen mode Exit fullscreen mode

En cas d'erreur, aucune valeur n'est émise dans la fonction NEXT. On passe dans la fonction ERROR, c'est la fin du lifecycle de l'observable.

A savoir :

  • Un appel HTTP est un observable qui "complete" après avoir émit une valeur. On a alors deux "chemins" possibles :

image

  • On ne peut pas être dans un COMPLETE et ERROR dans le lifecycle d'un observable, c'est soit l'un, soit l'autre.

Pour résoudre le problème

Pour gérer l'affichage du loader en cas d'erreur, on va traiter son état dans la fonction NEXT et dans la fonction ERROR.

export class HobbitsComponent implements OnInit {

  isLoading = true;
  hobbits: Hobbit[] = [];

  constructor(private hobbitsService: HobbitsService) {}

  ngOnInit() {
    this.hobbitsService.findHobbits().subscribe(
      (hobbits: Hobbit[]) => {
        this.hobbits = hobbits;
        this.isLoading = false;
      },
      () => {
        this.isLoading = false;
      }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Si l'appel HTTP réussit ou échoue, on aura le booléen isLoading à false et donc on n'aura plus le loader affiché à l'infini.

Traiter ou logger l'erreur

Dans le cas où on veut utiliser l'erreur pour debugger ou pour afficher un message précis à l'utilisateur par exemple, on peut utiliser l'erreur retournée comme ceci :

this.hobbitsService.findHobbits().subscribe(
    () => console.log('Next'),
    (error) => console.log('Error', error),
    () => console.log('Completed')
);
Enter fullscreen mode Exit fullscreen mode

Gestion les erreurs dans un effect

Pour gérer vos effets de bord, par exemple vos appels backends, vous pouvez également utiliser la librarie NGRX et les effects. Personnellement c'est la manière dont je gère ces appels. Je ne donne pas la responsabilité au composant de récupérer les données.

L'action loadHobbits met un booléen isLoading à true dans le store. L'action loadHobbitsSuccess passe ce booléen à false et enregistre la liste des Hobbits dans le store. Le loader est affiché si le booléen isLoading est à true

Exemple sans gestion d'erreur

@Injectable()
export class HobbitsEffects {

  loadHobbits$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadHobbits),
      concatMap(() =>
        this.hobbitsService.findHobbits().pipe(
          map((hobbits: Hobbit[]) => loadHobbitsSuccess({ hobbits }))
        )
      )
    )
  );

  constructor(
      private actions$: Actions,
      private hobbitsService: HobbitsService
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Que se passe-t-il si l'appel findHobbits échoue ?

Le loader va être affiché, sans s'arrêter, alors que l'appel est terminé.

Pourquoi ?

Seul l'action loadHobbitsSuccess met le booléen isLoading à false. Or, en cas d'erreur, on ne passe pas dans le map qui suit l'appel HTTP. Il faudrait attraper l'erreur à l'aide de l'opérateur catchError.

catchError

L'opérateur catchError va permettre, comme son nom l'indique, d'attraper l'erreur et de retourner un nouvel observable.

this.hobbitsService.findHobbits().pipe(
    map(() => /*SUCCESS*/),
    catchError(() => of(/*ERROR*/)),
);
Enter fullscreen mode Exit fullscreen mode

Pour résoudre le problème

On va créer une nouvelle action loadHobbitsError qui va permettre dans notre exemple de mettre le booléen isLoading à false et donc d'arrêter d'afficher le loader en cas d'erreur.

@Injectable()
export class HobbitsEffects {

  loadHobbits$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadHobbits),
      concatMap(() =>
        this.hobbitsService.findHobbits().pipe(
          map((hobbits: Hobbit[]) => loadHobbitsSuccess({ hobbits })),
          catchError(() => of(loadHobbitsError()))
        )
      )
    )
  );

  constructor(
      private actions$: Actions,
      private hobbitsService: HobbitsService
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

A savoir :

  • Si vous êtes sur une version antérieure à la version 8 d'NGRX, en cas d'erreur "non attrapée" dans l'observable principal à l'aide d'un catchError, l'effect est complete. Depuis la version 8, si aucune erreur est "attrapée" dans l'observable principal, l'effect se resouscrit avec une limite maximum d'erreurs.

Appels multiples

En cas d'appels multiples, on peut choisir de retourner un observable avec des données pour gérer les cas d'appels qui ont échoués.

Dans l'exemple ci-dessous, on a une liste d'ids d'Hobbits donnée par l'action loadHobbitsBeers.
Pour chaque id d'Hobbit, on fait un appel HTTP via favoriteBeersByHobbitId qui va retourner une liste de string qui correspond aux bières préférées d'un Hobbit donné.
Ces appels sont effectués en parallèles, et si l'un d'eux échoue, on enregistre l'id du Hobbit, ainsi que la bière Prancing Pony's Ale par défaut. Ainsi, les appels qui ont échoué sont traités avec des données par défaut.

@Injectable()
export class HobbitsEffects {
  loadHobbitsDetails$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadHobbitsBeers),
      mergeMap(({ hobbitsIds }) =>
        forkJoin(
          hobbitsIds.map(hobbitId =>
            this.hobbitsService.favoriteBeersByHobbitId(hobbitId).pipe(
              map((beers: string[]) => ({
                id: hobbitId,
                beers,
              })),
              catchError(() =>
                of({
                  id: hobbitId,
                  beers: [`Prancing Pony's Ale`]
                })
              )
            )
          )
        )
      ),
      map((hobbitsBeers: HobbitsBeers[]) => loadHobbitsBeersSuccess({ hobbitsBeers }))
    )
  );

  constructor(
      private actions$: Actions,
      private hobbitsService: HobbitsService
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Traiter ou logger l'erreur

Dans le cas où on veut utiliser l'erreur pour debugger ou pour afficher un message précis à l'utilisateur par exemple, on peut utiliser l'erreur retournée comme ceci :

this.hobbitsService.findHobbits().pipe(
  map((hobbits: Hobbit[]) => /*SUCCESS*/),
  catchError((error) => { 
      console.log('ERROR', error);
      return of(/*ERROR*/);
  })
)
Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .