Gestion des erreurs RXJS -NGRX
Tags: angular, rxjs, ngrx
Published: 2021-04-09 12:28:42

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.

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

  findHobbits(): Observable<Hobbit[]> {
    return this.http.get<Hobbit[]>('api/hobbits');
  }
}
``` 
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`.

```typescript
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;
      }
    );
  }
}
```
> 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. 

```typescript
this.hobbitsService.findHobbits().subscribe(
	() => console.log('Next'),
	() => console.log('Error'),
    () => console.log('Completed')
);
```

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

```typescript
Next
Completed
```
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 : 

```typescript
Error
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cowlhu1tsys9zu43l78z.png)
 

- 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`.

```typescript
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;
      }
    );
  }
}
```
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 : 

```typescript
this.hobbitsService.findHobbits().subscribe(
	() => console.log('Next'),
	(error) => console.log('Error', error),
    () => console.log('Completed')
);
```

## 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](https://ngrx.io/) et les [effects](https://ngrx.io/guide/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

```typescript
@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
  ) {}
}
```

> 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.

```typescript
this.hobbitsService.findHobbits().pipe(
    map(() => /*SUCCESS*/),
    catchError(() => of(/*ERROR*/)),
);
```

### 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.

```typescript
@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
  ) {}
}
```

**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.

```typescript
@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
  ) {}
}
```

### 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 : 

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