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');
}
}
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;
}
);
}
}
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')
);
Si l'appel HTTP réussit, on voit les logs suivant :
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 :
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 :
- On ne peut pas être dans un
COMPLETE
etERROR
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;
}
);
}
}
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')
);
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
) {}
}
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*/)),
);
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
) {}
}
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'uncatchError
, l'effect
estcomplete
. 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
) {}
}
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*/);
})
)