Accueil / Articles

Concevoir pour les demandes échouées dans Angular : états, intercepteurs et tentatives de réessai

Apprenez à classer les échecs HTTP dans Angular, à gérer correctement l’état de chargement, à centraliser le traitement via des intercepteurs, à réessayer en toute sécurité et à afficher des messages sur lesquels les utilisateurs peuvent agir.

Éditeur : REACTAPP.TOP9 min de lecture2160 mots

Sources: **Handling API Failures Gracefully in Angular: Practical Error Handling Patterns Every Frontend Developer Should Know**

La plupart des composants Angular sont conçus pour le scénario idéal : 200 OK, les données dans une propriété, le template affiché. En production, les connexions se coupent, les sessions expirent, les backends plantent et les utilisateurs font un double-clic sur les boutons de soumission. Les patterns présentés ci-dessous permettent à une application Angular de détecter ces pannes, de les expliquer à l’utilisateur, de se remettre en marche lorsque c’est possible, et de fournir aux développeurs suffisamment d’informations pour résoudre la cause.

Le prix à payer pour ignorer le callback d’erreur

Une requête vers un composant typique s’abonne à la réponse, lui attribue les données et ne fait rien d’autre.

this.http.get('/api/accounts').subscribe({
  next: (response) => {
    this.accounts = response;
  }
});

Si le serveur répond par un 500, rien de visible ne se produit. La page reste vide ou un indicateur d’affichage tourne en boucle, et la seule trace est une entrée dans la console que l’utilisateur n’ouvre jamais.

L’amélioration minimale consiste à mettre en place un gestionnaire d’erreur qui enregistre la panne et stocke un message lisible pour le template.

this.http.get('/api/accounts').subscribe({
  next: (response) => {
    this.accounts = response;
  },
  error: (error) => {
    console.error('Failed to load accounts', error);
    this.errorMessage = 'Unable to load accounts. Please try again.';
  }
});

Cela fonctionne pour une seule page. Sur des dizaines de pages, copier le gestionnaire partout entraîne des messages incohérents, des cas limites oubliés et du code dupliqué, c’est pourquoi les patterns réutilisables sont importants.

Tous les échecs ne signifient pas la même chose

Le code d’état indique généralement qui peut résoudre le problème, ce qui détermine la manière de réagir.

400 Bad Request : les données saisies sont incorrectes

Le frontend a envoyé des données invalides.

400 Bad Request

Généralement, l’utilisateur peut résoudre le problème en corrigeant par exemple un champ de formulaire.

401 Unauthorized : la session est manquante ou a expiré

Les identifiants sont absents, le plus souvent parce que le jeton d’accès a expiré.

401 Unauthorized

La réaction habituelle est de rediriger vers la page de connexion, ou bien de renouveler d’abord le jeton.

403 Forbidden : l’utilisateur est authentifié mais n’a pas les droits

Le serveur connaît l’utilisateur mais refuse l’action demandée.

403 Forbidden

C’est une procédure classique de contrôle d’accès basé sur les rôles. Envoyer l’utilisateur sur la page de connexion ne servirait à rien ; il doit être informé qu’il n’a pas les permissions nécessaires.

404 Non trouvé : la ressource n’existe pas

L’entité a été supprimée ou l’URL est obsolète.

404 Not Found

500 Erreur interne du serveur : le backend ne fonctionne pas

Quelque chose a échoué sur le serveur.

500 Internal Server Error

L’utilisateur ne peut pas résoudre ce problème ; il faut lui fournir un message clair ainsi qu’une façon de réessayer plus tard.

Erreurs réseau : la requête n’a jamais atteint le serveur

Parfois, la requête n’arrive jamais au serveur.

Internet connection lost
Server unavailable
DNS failure
Request timeout

Angular signale ces cas sous forme d’HttpErrorResponse avec un status égal à 0, ce qui les distingue des vraies réponses du serveur. Cette catégorie détermine toutes les actions à entreprendre : s’il faut réessayer, rediriger ou informer l’utilisateur.

Modélisez la requête en tant qu’ensemble d’états

Tenir uniquement compte des succès est une erreur fréquente. Traitez une requête comme une petite machine à états : elle commence par le chargement et se termine soit par succès, soit par erreur.

Loading
   ↓
Success

or

Loading
   ↓
Error

Dans un composant, cela signifie des indicateurs explicites pour le chargement et le message d’erreur, mis à jour dans chaque branche.

loading = false;
errorMessage = '';
loadAccounts() {
  this.loading = true;
  this.errorMessage = '';
  this.http.get('/api/accounts').subscribe({
    next: (data) => {
      this.accounts = data;
      this.loading = false;
    },
    error: () => {
      this.errorMessage =
        'Unable to load accounts. Please try again.';
      this.loading = false;
    }
  });
}

Le détail crucial est de réinitialiser loading dans la fonction de rappel en cas d’erreur ; sans cela, chaque échec laisse un indicateur de chargement infini. Effacer errorMessage au début empêche également un erreur obsolète de persister à côté de données fraîches.

Laissez finalize() gérer le flag de chargement

Réinitialiser ce flag en deux endroits est source d’erreurs. RxJS finalize() centralise cette tâche de nettoyage.

import { finalize } from 'rxjs';
loadAccounts() {
  this.loading = true;
  this.http.get('/api/accounts')
    .pipe(
      finalize(() => {
        this.loading = false;
      })
    )
    .subscribe({
      next: (data) => {
        this.accounts = data;
      },
      error: () => {
        this.errorMessage =
          'Unable to load accounts.';
      }
    });
}

La fonction de rappel finalize() s’exécute lorsque l’observable se termine ou rencontre une erreur, ainsi que lorsqu’une abonnement est résilié prématurément. Cela en fait un outil idéal pour cacher les indicateurs de chargement, et les lignes dupliquées this.loading = false disparaissent alors.

Centraliser le comportement partagé dans un intercepteur HTTP

Une session expirée ou une panne générale doit être gérée de la même manière sur toutes les pages ; donc, l’implémentation par composant est répétitive et fragile. Un intercepteur HTTP voit toutes les requêtes HttpClient, ce qui en fait l’endroit approprié pour définir des politiques applicables à l’ensemble de l’application. Celui-ci utilise le style fonctionnel HttpInterceptorFn et prend en compte l’état de la réponse.

export const errorInterceptor: HttpInterceptorFn =
  (req, next) => {
    return next(req).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          console.log('Session expired');
        }
        if (error.status === 403) {
          console.log('Access denied');
        }
        if (error.status >= 500) {
          console.log('Server error');
        }
        return throwError(() => error);
      })
    );
  };

Les appels de journalisation ne sont que des placeholders pour une redirection, un message d’alerte ou un événement de surveillance. Plus important encore, l’intercepteur se termine par throwError(() => error), de sorte que l’erreur atteint néanmoins le composant qui l’a appelée. L’intercepteur gère les aspects transversaux, tandis que le composant s’occupe de ce qu’il seul comprend ; cette séparation permet de maintenir le code facilement. Les intercepteurs fonctionnels sont enregistrés à l’aide de provideHttpClient(withInterceptors([...])).

Traduire les erreurs techniques en messages compréhensibles pour les utilisateurs

Les backends ne renvoient pas toujours du texte convivial :

{
  "message": "NullPointerException at AccountService.java:142"
}

Cela aide les responsables du service, mais présenté aux clients, cela crée de la confusion et révèle des détails d’implémentation. Présentez plutôt quelque chose écrit spécifiquement pour l’utilisateur.

We couldn't load your accounts right now.
Please try again.

Une session expirée mérite une formulation adaptée :

Your session has expired.
Please sign in again.

Il en va de même pour un problème de permissions :

You don't have permission to access this resource.

Un bon message répond à trois questions :

  1. Qu’est-ce qui ne va pas ?
  2. L’utilisateur doit-il prendre des mesures ?
  3. Que peut-il faire ensuite ?

Associer les codes d’état aux messages en un seul endroit permet de maintenir une formulation cohérente.

Réessayer les pannes temporaires, mais uniquement celles qui le sont

Certaines pannes sont passagères, comme un problème de réseau. Pour celles-ci, RxJS propose la méthode retry().

this.http.get('/api/accounts')
  .pipe(
    retry(2)
  )
  .subscribe({
    next: (data) => {
      this.accounts = data;
    },
    error: () => {
      this.errorMessage =
        'Unable to load accounts.';
    }
  });

retry(2) réinscrit l’observateur jusqu’à deux fois de plus avant que l’erreur n’atteigne la fonction de rappel error. Comme les observables de HttpClient sont de type « froids », chaque réinscription envoie une nouvelle requête.

C’est aussi là le danger. Si une demande de paiement réussit sur le serveur mais que sa réponse est perdue, une tentative de réessai peut facturer le client deux fois, à moins que l’arrière-plan ne prenne en charge l’idempotence. Faites attention aux tentatives de réessai automatiques pour :

  • Les paiements
  • Les commandes
  • Création de compte
  • Mises à jour des données
  • Opérations de suppression
  • Les requêtes GET en lecture seule sont beaucoup plus faciles à comprendre. Notez également qu’un appel simple à retry() réessaie à chaque erreur, y compris les erreurs 400 ou 401 qui ne réussiront jamais ; en général, on souhaite seulement réessayer en cas d’erreurs réseau et de réponses 5xx.

    Réessais avec un délai

    Un réessai immédiat frappe souvent le même serveur surchargé. Le formulaire de configuration de retry() permet d’ajouter une pause.

    import { retry } from 'rxjs';
    this.http.get('/api/accounts')
      .pipe(
        retry({
          count: 2,
          delay: 1000
        })
      )
      .subscribe();
    

    La requête dispose de deux chances supplémentaires, séparées d’une seconde. delay accepte également une fonction, ce qui vous permet d’implémenter un backoff exponentiel (chaque attente étant plus longue que la précédente) et d’éviter les réessais pour les erreurs qui ne peuvent pas aboutir. Le backoff empêche de nombreux clients en échec d’assaillir en même temps un serveur en train de se remettre en service.

    Considérer les sessions expirées comme un problème global

    Situation classique : un utilisateur ouvre l’application le matin et y revient des heures plus tard. Le jeton est expiré, ce qui fait échouer la prochaine requête.

    GET /api/accounts
    401 Unauthorized
    

    L’intercepteur est l’endroit idéal pour détecter cela :

    if (error.status === 401) {
      // Clear authentication state
      // Redirect to login
    }
    

    Gérer ce problème en un seul endroit évite que chaque composant ne réimplémente la logique de vérification de l’expiration des sessions. Avec les jetons de renouvellement, l’intercepteur peut tenter le renouvellement avant de rediriger ; lorsque plusieurs requêtes échouent en même temps, consultez pourquoi les erreurs 401 simultanées déconnectent les utilisateurs pour connaître l’approche à un seul essai.

    Fournir un bouton de tentative aux utilisateurs

    Souvent, l’outil de récupération le plus efficace est un simple bouton permettant de répéter la requête.

    <div *ngIf="errorMessage">
      <p>{{ errorMessage }}</p>
      <button (click)="loadAccounts()">
        Try Again
      </button>
    </div>
    

    Imaginez un utilisateur dont la connexion est interrompue pendant quelques secondes. Une fois de nouveau connecté, il n’a pas besoin de recharger l’application ni de perdre son état ; il suffit qu’il appuie sur :

    Try Again
    

    Puisque loadAccounts() réinitialise d’abord son état, il sert à la fois le chargement initial et chaque tentative de récupération. Les templates d’Angular 17+ peuvent utiliser @if à la place de *ngIf.

    Recourir à des données obsolètes lorsque leur fraîcheur le permet

    Si la mise à jour d’un tableau de bord échoue, les dernières valeurs chargées accompagnées d’une notice explicite valent souvent mieux qu’une page vide.

    Showing your last available data.
    We couldn't refresh the dashboard.
    

    Un cache rend cela possible, et la notice est essentielle. Évitez le cas où la fraîcheur des données est cruciale : les soldes, les prix boursiers, les stocks et les détails de paiement doivent afficher une erreur plutôt qu’une valeur obsolète.

    Journaliser suffisamment de contexte pour déboguer l’échec

    Cacher des détails aux utilisateurs ne signifie pas les jeter. Un enregistrement de journal utile contient :

    Endpoint
    HTTP status
    Timestamp
    Request identifier
    User/session context where appropriate
    Error category
    

    Une version minimale d’intercepteur :

    console.error({
      endpoint: req.url,
      status: error.status,
      message: error.message
    });
    

    En production, personne ne consulte la console ; envoyez donc le chargement à un service de surveillance des erreurs où l’identifiant de la requête permet de le relier aux journaux du backend. Ne journalisez jamais de tokens ni de données personnelles inutiles.

    L’équilibre à rechercher : l’utilisateur reçoit un message utile, et le développeur dispose de suffisamment de détails pour identifier la cause.

    Ne cachez pas les échecs derrière des résultats vides

    Une solution rapide et tentante consiste à ignorer l’erreur et à retourner une valeur vide :

    catchError(() => {
      return of([]);
    })
    

    Cela change discrètement le sens : un tableau vide indique que le serveur a renvoyé zéro enregistrement. Un utilisateur ne voyant aucune transaction peut en conclure qu’il n’y en a pas, ce qui est pire qu’une erreur. Les solutions de secours peuvent être appropriées, mais de manière intentionnelle ; sinon, propagez l’erreur :

    catchError((error) => {
      return throwError(() => error);
    })
    

    Séparer les responsabilités entre le traitement global et celui au niveau des fonctionnalités

    Le traitement global convient aux problématiques qui ont le même sens partout :

    401 → Authentication handling
    403 → Authorization handling
    500 → Generic server error
    

    Ces cas relèvent d’un intercepteur. Examinons maintenant :

    POST /api/transfer-money
    

    Une transaction échouée nécessite une explication précise ainsi qu’une indication claire quant au transfert d’argent, ce que des messages génériques ne peuvent pas fournir. La fonctionnalité doit recevoir l’erreur, c’est pourquoi l’intercepteur la relance. Une conception robuste consiste en un traitement global pour les problématiques communes, associé à un traitement spécifique aux fonctionnalités lorsque le contexte métier est important.

    Checklist d’évaluation pour les scénarios de défaillance

    Gérer les erreurs consiste à planifier ce qui se passe lorsque quelque chose ne fonctionne pas. Lors de l’évaluation d’une fonctionnalité, demandez-vous :

    • L’utilisateur comprend-il ce qui s’est passé ?
    • Pourra-t-il se remettre en état sans recharger la page ?
    • Existe-t-il un moyen de réessayer ?
    • L’indicateur de chargement s’arrête-t-il toujours ?
    • Les détails techniques sont-ils absents de l’interface ?
    • La défaillance est-elle enregistrée à un endroit accessible aux développeurs ?
    • Que se passe-t-il lorsque le jeton expire au cours d’une session ?
    • Que se passe-t-il lorsque le réseau est interrompu ?
    • Que se passe-t-il lorsque le backend renvoie des données sous une forme inattendue ?

    Points clés

    Les défaillances sont normales, il faut donc viser une gestion élégante des pannes. Dans Angular, cela se traduit par :

    • Des états explicites de chargement, de succès et d’erreur pour chaque requête
  • finalize() afin que les opérations de nettoyage s’exécutent quel que soit le dénouement de la requête
  • catchError() utilisé pour gérer les erreurs, et non pour les cacher
  • Un intercepteur pour l’expiration de la session, les permissions et les erreurs serveur générales, qui relance l’erreur afin que les fonctionnalités puissent encore y réagir
  • Les tentatives de reprise sont limitées aux requêtes sûres et idempotentes, avec des délais ou un mécanisme de backoff
  • Des messages indiquant aux utilisateurs ce qui s’est passé et quelle est la prochaine étape à suivre
  • Des boutons de reprise manuelle et, lorsque la fraîcheur des données le permet, des données de secours clairement étiquetées
  • Un journalisation structurée envoyée à un service de surveillance en environnement de production
  • Une application fiable se définit par son comportement en cas de problème.

    Lectures complémentaires