Strona główna / Artykuły

Projektowanie aplikacji w Angular dla nieudanych żądań: stany, interceptorzy i ponawiania prób

Dowiedz się, jak klasyfikować błędy HTTP w Angularze, oczyszczać stan ładowania, centralizować obsługę w interceptorach, bezpiecznie próbować ponownie oraz wyświetlać komunikaty, na które użytkownicy mogą zareagować.

Wydawca: REACTAPP.TOP9 min czytania2160 słów

Źródła: **Handling API Failures Gracefully in Angular: Practical Error Handling Patterns Every Frontend Developer Should Know**

Większość komponentów Angular jest pisana z myślą o normalnym przebiegu: 200 OK, dane w właściwości, wyrenderowany szablon. W środowisku produkcyjnym połączenia się rozrywają, sesje wygasają, serwery padają, a użytkownicy dwukrotnie klikają przyciski wysyłania. Poniższe wzorce pozwalają aplikacji Angular zauważyć te awarie, wyjaśnić je użytkownikowi, odzyskać stan w bezpieczny sposób oraz dostarczyć programistom wystarczająco dużo informacji, by naprawić przyczynę.

Koszt ignorowania funkcji zwrotnej dla błędów

Typowy żądanie komponentu subskrybuje się, przypisuje odpowiedź i nic więcej nie robi.

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

Jeśli serwer odpowiada kodem 500, nie dzieje się nic widocznego. Strona pozostaje pusta lub spiner nadal się obraca, a jedynym śladem jest wpis w konsoli, którego użytkownik nigdy nie otwiera.

Minimalną poprawą jest obsługa błędów typu error, która rejestruje awarię i przechowuje czytelne komunikaty dla szablonu.

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

To działa w przypadku jednego ekranu. Przy dziesiątkach ekranów kopiowanie obsługi we wszystkich miejscach powoduje niespójne komunikaty, zapomniane przypadki graniczne oraz powtórzone kod, dlatego tak ważne są wzory wielokrotnego użycia.

Nie wszystkie błędy oznaczają to samo

Kod stanu zazwyczaj podaje, kto może naprawić problem, a to decyduje o sposobie reakcji.

400 Bad Request: dane wejściowe są błędne

Frontend wysłał nieważne dane.

400 Bad Request

Użytkownik zazwyczaj może to naprawić, na przykład poprzez skorygowanie pola formularza.

401 Unauthorized: sesja jest brakująca lub wygasła

Brakuje danych uwierzytelniających, najczęściej dlatego, że token dostępu wygasł.

401 Unauthorized

Zwykłą reakcją jest przekierowanie do logowania lub najpierw odnowienie tokena.

403 Forbidden: użytkownik jest uwierzytelniony, ale nie ma uprawnień

Serwer zna użytkownika, ale odrzuca żądanie.

403 Forbidden

To jest rutyna z kontrolą dostępu opartą na rolach. Przesłanie użytkownika na stronę logowania nie pomoże; musi on usłyszeć, że brakuje mu uprawnień.

404 Not Found: zasób nie istnieje

Entytet został usunięty lub adres URL jest przestarzały.

404 Not Found

500 Internal Server Error: błąd w serwerze

Coś poszło nie tak na serwerze.

500 Internal Server Error

Użytkownik nie może tego naprawić; należy podać jasną informację oraz możliwość ponownej próby później.

Błędy sieciowe: żądanie nigdy nie dotarło

Czasami żądanie w ogóle nie dociera do serwera.

Internet connection lost
Server unavailable
DNS failure
Request timeout

Angular raportuje je jako HttpErrorResponse o statusie 0, co odróżnia je od rzeczywistych odpowiedzi serwera. Ta kategoria określa dalsze działania: czy próbować ponownie, przekierować użytkownika oraz co mu powiedzieć.

Modeluj żądanie jako zbiór stanów

Częstym błędem jest śledzenie tylko sukcesu. Traktuj żądanie jak małą maszynę stanową: zaczyna się od ładowania i kończy albo sukcesem, albo błędem.

Loading
   ↓
Success

or

Loading
   ↓
Error

W komponencie oznacza to wyraźne flagi dla stanu ładowania i komunikatu o błędzie, aktualizowane na każdej ścieżce.

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;
    }
  });
}

Kluczowym elementem jest resetowanie zmiennej loading w funkcji odwołania przy błędzie; bez tego każda niepowodzenie powoduje ciągłe pokazywanie się ikony spinera. Usunięcie errorMessage na początku zapobiega również pozostawaniu starych błędów obok świeżych danych.

Pozwól funkcji finalize() zająć się flagą ładowania

Resetowanie flagi w dwóch miejscach jest podatne na błędy. Funkcja RxJS finalize() umożliwia centralne zarządzanie tym procesem.

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

Funkcja powrotna finalize() jest wykonywana, gdy obserwowalny obiekt zakończy swoją pracę lub pojawi się błąd, a także wtedy, gdy subskrypcja zostanie zakończona przed czasem. Dzięki temu jest idealna do ukrywania wskaźników ładowania, a duplikowane linie this.loading = false znikają.

Centralizacja wspólnego zachowania w interfeksie HTTP

Wygasła sesja lub ogólny awarię należy obsługiwać w identyczny sposób na każdej stronie, więc implementacja tego rozwiązania w poszczególnych komponentach jest powtarzalna i krucha. Interfeks HTTP przetwarza każdą prośbę HttpClient, co czyni go odpowiednim miejscem na zasady obowiązujące w całym aplikacji. Ten przykład wykorzystuje funkcjonalny styl HttpInterceptorFn i rozróżnia różne stany.

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);
      })
    );
  };

Wywołania do logowania są zastępnikami przekierowania, komunikatu albo zdarzenia monitoringu. Co ważniejsze, interceptor kończy się instrukcją throwError(() => error), więc błąd nadal dociera do komponentu, który go wywołał. Interceptor zajmuje się aspektami wspólnymi dla wielu komponentów, natomiast komponent obsługuje tylko to, co sam rozumie – taka separacja ułatwia utrzymanie kodu. Funkcjonalne interceptory są rejestrowane za pomocą provideHttpClient(withInterceptors([...])).

Tłumaczenie błędów technicznych na zrozumiałe dla użytkownika komunikaty

Backendy nie zawsze zwracają przyjazny tekst:

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

To pomaga właścicielom usług, ale pokazanie tego klientowi powoduje zamieszanie i ujawnia szczegóły implementacji. Lepiej pokazać coś napisanego specjalnie dla użytkownika.

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

Wygasła sesja wymaga specjalnego sformułowania:

Your session has expired.
Please sign in again.

Podobnie jest z problemem uprawnień:

You don't have permission to access this resource.

Dobra wiadomość odpowiada na trzy pytania:

  1. Co poszło nie tak?
  2. Czy użytkownik musi podjąć jakieś działanie?
  3. Co może zrobić dalej?

Zmapowanie kodów stanu na wiadomości w jednym miejscu zapewnia spójność sformułowań.

Próbuj ponownie w przypadku tymczasowych błędów, ale tylko tych bezpiecznych

Część błędów jest chwilowa, np. awaria sieci. W takich przypadkach RxJS oferuje metodę retry().

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

retry(2) ponownie subskrybuje się maksymalnie dwa razy, zanim błąd dotrze do funkcji zwrotnej error. Ponieważ obserwowalne obiekty HttpClient są typu „cold”, każda ponowna subskrypcja wysyła nowe żądanie.

To jest również zagrożenie. Jeśli żądanie płatności zakończyło się sukcesem na serwerze, ale jego odpowiedź została utracona, ponowna próba może obciążyć klienta dwukrotnie, chyba że backend obsługuje zasadę idempotencji. Uważaj na automatyczne próby ponowne w przypadku:

  • Płatności
  • Zamówień
  • Tworzenie konta
  • Uaktualnianie danych
  • Operacje usuwania
  • Zapytania typu GET do odczytu są znacznie łatwiejsze do zrozumienia. Należy również pamiętać, że prosta funkcja retry() powtarza próby przy każdym błędzie, w tym przy błędach 400 lub 401, które nigdy nie przyniosą sukcesu; zazwyczaj chce się powtarzać próby tylko w przypadku błędów sieciowych i odpowiedzi typu 5xx.

    Powtarzanie prób z opóźnieniem

    Nagłe powtórzenie próby często skutkuje kontaktem z tym samym przeciążonym serwerem. Formularz konfiguracji funkcji retry() umożliwia dodanie pauzy.

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

    Zapytanie otrzymuje dwa dodatkowe szanse, z odstępem jednej sekundy. Funkcja delay może przyjmować również funkcję, co pozwala zaimplementować eksponencjalne opóźnienie (każde oczekiwanie trwa dłużej niż poprzednie) oraz pominąć powtarzanie prób w przypadku błędów, które nie mogą się udać. Takie podejście zapobiega temu, by wiele nieudanych klientów jednocześnie obciążało serwer w trakcie jego odbudowy.

    Traktuj wygasłe sesje jako problem globalny

    Klasyczny scenariusz: użytkownik otwiera aplikację rano i wraca kilka godzin później. Token wygasł, więc następna prośba zawodzi.

    GET /api/accounts
    401 Unauthorized
    

    Interceptor to miejsce, w którym można to wykryć:

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

    Rozwiązywanie tego problemu w jednym miejscu oznacza, że żaden komponent nie musi ponownie implementować mechanizmu wygaszania sesji. Dzięki tokenom odświeżania interceptor może spróbować ich odnowienia przed przekierowaniem; gdy kilka prób jednocześnie zawodzi, zapoznaj się z powodem, dla którego jednoczesne błędy 401 wyprowadzają użytkowników w celu zrozumienia podejścia typu „single-flight”.

    Daj użytkownikowi przycisk do ponownej próby

    Często najskuteczniejszym narzędziem do naprawy sytuacji jest zwykły przycisk, który powtarza prośbę.

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

    Załóżmy użytkownika, u którego połączenie zostaje przerwane na kilka sekund. Gdy ponownie się połączy, nie musi ładować aplikacji na nowo i tracić stanu; wystarczy, że naciśnie:

    Try Again
    

    Ponieważ loadAccounts() najpierw sformatuje swój stan, może być użyty zarówno przy pierwszym ładowaniu, jak i przy każdej próbie ponownego załadowania. W szablonach Angular 17+ można używać @if zamiast *ngIf.

    Użyj starych danych, jeśli ich aktualność na to pozwala

    Jeśli odświeżenie panelu kontrolnego się nie powiedzie, ostatnie załadowane dane wraz z jasnym komunikatem są często lepsze niż pusta strona.

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

    Kasza umożliwia to, a komunikat jest niezbędny. Unikaj sytuacji, gdy aktualność danych ma kluczowe znaczenie: wskaźniki, ceny akcji, stan zapasów oraz szczegóły płatności powinny pokazywać błąd, a nie przestarzałe wartości.

    Zapisuj wystarczająco dużo informacji, aby móc naprawić błąd

    Ukrywanie szczegółów przed użytkownikami nie oznacza ich eliminacji. Przydatny zapis dziennika zawiera:

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

    Minimalna wersja interceptora:

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

    W środowisku produkcyjnym nikt nie obserwuje konsoli, więc należy wysłać dane do usługi monitorującej błędy, gdzie identyfikator żądania pozwala je powiązać z logami serwera. Nigdy nie rejestrować tokenów ani niepotrzebnych danych osobowych.

    Równowaga, do której należy dążyć: użytkownik otrzymuje pomocną informację, a programista ma wystarczająco dużo szczegółów, by znaleźć przyczynę.

    Nie ukrywaj błędów za pustymi wynikami

    Kuszącym sposobem obejścia jest pochłonięcie błędu i zwrócenie wartości pustej:

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

    W taki sposób po cichu zmienia się znaczenie: pusta tablica sugeruje, że serwer zwrócił zero rekordów. Użytkownik, który nie widzi żadnych transakcji, może założyć, że ich w ogóle nie ma, co jest gorsze niż błąd. Rozwiązania awaryjne mogą być słuszne, ale tylko celowo; w przeciwnym razie należy przenieść błąd dalej:

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

    Rozdzielenie obowiązków pomiędzy obsługą globalną a specyficzną dla funkcji

    Obsługa globalna nadaje się do spraw, które mają takie samo znaczenie wszędzie:

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

    Te przypadki powinny znajdować się w intercepcie. Teraz rozważmy:

    POST /api/transfer-money
    

    Nieudana transza wymaga konkretnej wyjaśnienia oraz jasnego stwierdzenia, czy pieniądze zostały przeniesione, czego nie może zapewnić ogólne komunikat. Funkcja musi otrzymać błąd, dlatego intercepcja go ponownie rzuca. Solidny projekt polega na obsłudze globalnej dla wspólnych problemów w połączeniu z obsługą specyficzną dla funkcji, gdy ma znaczenie kontekst biznesowy.

    Lista kontrolna do przeglądu ścieżek awarii

    Zarządzanie błędami polega na zaprojektowaniu tego, co dzieje się po awarii. Przy przeglądaniu nowej funkcji zadaj sobie następujące pytania:

    • Czy użytkownik rozumie, co się stało?
    • Czy może odzyskać dostęp bez ponownego ładowania strony?
    • Czy istnieje możliwość ponownej próby?
    • Czy wskaźnik ładowania zawsze się wyłącza?
    • Czy szczegóły techniczne nie są widoczne w interfejsie?
    • Czy awaria jest rejestrowana w miejscu, gdzie będą ją mogli zobaczyć deweloperzy?
    • Co się dzieje, gdy token wygaśnie w trakcie sesji?
    • Co się dzieje, gdy połączenie sieciowe zostanie przerwane?
    • Co się dzieje, gdy serwer zwraca dane w nieoczekiwanej formie?

    Główne wnioski

    Awarie są normalne, dlatego staraj się radzić im z godnością. W Angularze łączy się to z:

    • Jasno określonymi stanami ładowania, sukcesu i błędu dla każdej żądania
  • finalize() – zapewnia wykonanie czynności końcowych niezależnie od tego, jak zakończy się żądanie
  • catchError() – służy do kontrolowania błędów, a nie do ich ukrywania
  • Interceptor obsługujący wygaśnięcie sesji, uprawnienia oraz ogólne błędy serwera, który ponownie rzuca te błędy, aby funkcje mogły nadal zareagować
  • Ponawianie prób ograniczone do bezpiecznych, idempotentnych żądań, z opóźnieniami lub strategią backoff
  • Wiadomości informujące użytkowników o tym, co się stało i co należy zrobić dalej
  • Przyciski do ręcznego ponawiania prób oraz, tam gdzie to możliwe ze względu na aktualność danych, wyraźnie oznaczone dane awaryjne
  • Strukturyzowane logi wysyłane do usługi monitoringu w środowisku produkcyjnym
  • Zaufalna aplikacja jest określana przez to, jak zachowuje się w sytuacjach awaryjnych.

    Literatura pokrewna

  • Rozszyfrowanie Angular AuthService: Gateway, Store, Facade i Ports — Podzielenie przeciążonego Angular AuthService na gateway, magazyn sygnałów, port przechowywania i fasadę, z odnawianiem tokena w jednym kroku, interceptorem skonfigurowanym na poziomie kontekstu oraz zabezpieczeniami nadającymi się do testowania.
  • Niezmienny budownik zapytań oparty na clozure dla Angular HttpClient — Zastąpienie powtarzającego się kodu HttpParams małym budownikiem żądań opartym na clozure, połączonym za pomocą InjectionToken, który pozostaje niezmienny, typowany i łatwy do testowania.
  • Projektowanie frontendów typu offline-first jako replik: kolejki, próby ponowne, konflikty — Dowiedz się, dlaczego podejście offline-first przekształca przeglądarkę w replikę danych oraz jak lokalne magazyny, trwałe kolejki mutacji, idempotentne próby ponowne i zasady rozwiązywania konfliktów współpracują ze sobą.