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ć.
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:
- Co poszło nie tak?
- Czy użytkownik musi podjąć jakieś działanie?
- 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ń
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ę żądaniecatchError() – służy do kontrolowania błędów, a nie do ich ukrywaniaZaufalna aplikacja jest określana przez to, jak zachowuje się w sytuacjach awaryjnych.
Literatura pokrewna
- Projektowanie ekranów React dla stanów ładowania, pustki, błędu i ponawiania — Naucz się modelować każdy możliwy stan ekranu i formy w React, od struktur początkowych i ponownych pobierania danych po puste wyniki, przydatne błędy oraz ochronę przed podwójnym wysyłaniem danych.
- Przyczyny w Store, wnioskowanie z nich: projektowanie minimalnego stanu React — Naucz się identyfikować zbędny stan w React, zastępować łańcuchy synchronizacji oparte na Effect oraz flagi logiczne wartościami wyprowadzanymi i zbiorem stanów, a także decydować, gdzie powinien znajdować się stan.