In Angular abbiamo diversi modi per gestire gli errori: ognuno ha i suoi pro e i suoi contro, vediamoli assieme.
Indice dei contenuti
HttpErrorResponse
Il caso più comune di errore da dover gestire è quello di una chiamata HTTP che non va a buon fine. A seconda del comportamento che vogliamo ottenere possiamo scegliere di agire in modi diversi: possiamo reagire all’errore, rimpiazzarlo o non fare nulla. E pur se volessimo rimediare, rimane la domanda: dove è meglio intercettare l’errore? Nel componente, nel servizio, nello store…?
Iniziamo con calma. Quando c’è un errore in una chiamata HTTP, Angular wrappa l’errore in una classe chiamata HttpErrorResponse: questo accade sia quando l’errore è lato server (es. il server ritorna errore 500) sia quando c’è stato qualche problema sul client nel fare la richiesta.
Questi sono alcuni campi della classe:
class HttpErrorResponse {
message: string;
error: any | null;
status: number;
statusText: string;
url: string | null;
,,,
}
Puoi quindi utilizzare queste proprietà per capire di quale errore si tratta: ad esempio, con lo status puoi capire se la chiamata non è andata a buon fine perché la sessione dell’utente è scaduta!
Ma quali opzioni abbiamo per rimediare ad un eventuale errore?
retry() e retryWhen()
Dato che il servizio HttpClient di Angular ci ritorna un Observable per ogni chiamata, possiamo utilizzare comodamente tutti gli operatori di RxJS.
Il più semplice è forse retry
, che fa in modo che la chiamata venga ripetuta N volte. Se lo lasciamo senza parametri, la chiamata verrà ripetuta all’infinito fino a non avere più errori: a meno di casi particolari, consiglio sempre di inserire un numero massimo.
import { retry } from 'rxjs';
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
retry(3)
);
}
Esiste anche una sua variante chiamata retryWhen
, che permette di ritardare il retry oppure di personalizzare la strategia di retry sulla base dell’errore ricevuto. Ad esempio:
import { retryWhen, delay, delayWhen, timer } from 'rxjs';
// Caso 1: Ritarda di 1 secondo ogni retry
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
retryWhen(errors => errors.pipe(
delay(1000)
))
);
}
// Caso 2: Ritarda il retry sulla base dell'errore
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
retryWhen(errors => errors.pipe(
delayWhen(e => timer(...))
))
);
}
Parlando di chiamate HTTP, non posso fare a meno di ricordare dell’esistenza di un operatore che fa proprio l’opposto: scatena un errore. Sto parlando di timeout
, che manda l’Observable in errore se la chiamata (o l’Observable in generale) non emette nel giro di N millisecondi. Molto utilizzato in accoppiata con retry o altri operatori.
import { timeout } from 'rxjs';
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
timeout(3000)
);
}
catchError
Un altro operatore usatissimo per rimediare ad un errore è catchError
. Questo operatore funziona in modo un po’ particolare, per la natura degli Observable. Mi spiego meglio.
Quando un Observable va in errore, si stoppa completamente. Non può più emettere nulla, la sua vita finisce. L’unico modo per ottenere nuovi valori è quello di sottoscriversi nuovamente, riavviandolo da zero.
Quindi, normalmente uno sviluppatore potrebbe pensare che con questo operatore si possa ritornare direttamente un valore alternativo quando c’è un errore. Una cosa di questo tipo:
import { catchError } from 'rxjs';
const defaultPost: Post = { ... };
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
catchError(error => defaultPost)
);
}
Se provate a scrivere un codice del genere però, scoprirete ben presto che non compilerà. Questo perché la funzione non è pensata per ritornare un valore alternativo, ma un Observable alternativo. Sfrutta quindi al massimo la potenza di RxJS! Possiamo potenzialmente ritornare una nuova chiamata HTTP (magari diversa dall’originale, altrimenti tanto vale usare retry), o un nuovo flusso di valori in generale.
Chiaramente, possiamo anche fargli ritornare un valore alternativo: ci basta wrapparlo in un Observable che emetta solo quel valore, con l’operatore di creazione of
. Questo codice funziona:
import { catchError, of } from 'rxjs';
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
catchError(error => of(defaultPost))
);
}
Ora la domanda da un milione di dollari: dove li mettiamo questi operatori?
Subscribe
Il primo posto dove possiamo intercettare un errore, quello più semplice, è nel subscribe dell’Observable. Quindi, nella stragrande maggioranza dei casi, sto parlando di Componenti. Una cosa di questo genere:
export class PostComponent {
getPosts() {
this.http.get<Post[]>('/api/post').subscribe({
next: posts => this.posts = posts,
error: e => this.error = e,
});
}
}
In questo modo abbiamo la possibilità di gestire gli errori in maniera molto minuziosa, ad esempio mostrando messaggi di errore diversi a seconda della chiamata o della pagina in cui ci troviamo nell’applicazione.
Allo stesso tempo, è anche il sistema più verboso, ma dipende ovviamente da quanti errori vogliamo catturare.
Ma attenzione: in questo caso non stiamo in alcun modo rimediando all’errore, lo stiamo solo rilevando. Nel caso volessimo rimediare all’errore, dovremmo usare gli operatori sopra-citati, ad esempio catchError:
export class PostComponent {
getPosts() {
this.http.get<Post[]>('/api/post').pipe(
catchError(() => of([])),
).subscribe(posts => {
this.posts = posts;
});
}
}
Ricordatevi quindi la differenza fondamentale: nel momento in cui usiamo catchError, non riceviamo alcun errore nel subscribe.
Servizi
Un’alternativa, se utilizziamo dei servizi per dialogare con un’API, potrebbe essere quella di catturare l’errore all’interno del servizio, prima che il valore arrivi al componente. Una cosa di questo tipo:
@Injectable({ providedIn: 'root' })
export class PostService {
constructor(private http: HttpClient) {}
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
catchError(() => of([])),
);
}
}
Questo codice, però, ha un potenziale problema: chiaramente, avendo rimediato all’errore, il componente non saprà mai cos’è successo.
Quindi, nella maggior parte dei casi, non consiglio questo approccio: man mano che l’applicazione cresce, cresce l’esigenza di voler mostrare errori diversi a seconda della sezione dell’app, e così ci precludiamo questa possibilità.
Ma c’è un’alternativa: potremmo ri-scatenare l’errore! In questo modo possiamo gestire l’errore all’interno del servizio, ma mandare comunque l’errore al componente. Per ritornare l’errore nuovamente, usiamo l’operatore di creazione throwError
:
import { throwError } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PostService {
constructor(private http: HttpClient) {}
getPosts() {
return this.http.get<Post[]>('/api/post').pipe(
catchError(e => {
// Fai quello che vuoi con l'errore...
// E poi ritornalo
return throwError(e);
}),
);
}
}
Questa potrebbe essere una soluzione. In questo modo possiamo raggruppare gli errori a livello di servizio, senza dimenticarci dei componenti.
Ma le opzioni non finiscono qui!
Interceptor
Possiamo anche gestire l’errore a livello di Interceptor, in modo da gestire gli errori HTTP globalmente in tutta l’applicazione.
Anche qui valgono le stesse considerazioni dei servizi, quindi se ad esempio utilizziamo catchError dobbiamo assicurarci di ritornare l’errore con throwError, altrimenti nessun componente (o servizio!) si accorgerà dell’errore.
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
catchError((error) => {
// ...
return throwError(error);
})
)
}
}
Questo è tipicamente il posto dove si intercettano, ad esempio, errori di autenticazione, per poter rimandare l’utente a una determinata rotta:
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(private router: Router) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
this.router.navigateByUrl(`/login`);
}
return throwError(error);
})
)
}
}
NgRx Effects
Se utilizzate NgRx, molto molto probabilmente avrete dei servizi che vengono a loro volta chiamati dagli Effect. Anche qui, potete scegliere di gestire gli errori a livello di servizio o di interceptor, anche se in questo caso consiglio quasi sempre di gestire gli errori a livello di Effect, per poterli poi inserire nello Store facilmente. Un esempio classico:
loadPosts$ = createEffect(() => this.actions$.pipe(
ofType(loadPosts),
switchMap(() => this.postService.getPosts()
.pipe(
map(posts => loadPostsSuccess(posts)),
catchError(e => loadPostsError(e))
))
)
);
Chiaramente, se seguite la regola di ritornare sempre l’errore con throwError, potete anche gestire gli errori a livello di servizi o di interceptor, anche se starei molto attento a questa pratica perché rischiate di ritrovarvi con degli stati esterni dallo Store di NgRx, e spesso non è un bene. Ma è una scelta personale.
In alternativa, potreste valutare di raggruppare più azioni di fallimento in un unico effetto, in questo modo:
handleAllErrors$ = createEffect(() => this.actions$.pipe(
ofType(loadPostsError, loadUsersError, loadTodosError),
...
);
ErrorHandler
Se il nostro scopo non è quello di rimediare ad un errore, ma soltanto di catturarlo, possiamo implementare il nostro personale ErrorHandler
sovrascrivendo quello di default di Angular:
import { ErrorHandler } from '@angular/core';
export class CustomErrorHandler implements ErrorHandler {
handleError(error) {
// Fai quel che vuoi con l'errore, ma lancialo per non fartelo scappare in console!
throw new Error(error);
}
}
import { ErrorHandler } from '@angular/core';
@NgModule({
providers: [{
provide: ErrorHandler,
useClass: CustomErrorHandler
}]
})
export class AppModule {}
Questo è forse il punto ideale per connettere servizi come Sentry, per rilevare e salvare gli errori dell’applicazione. Qui infatti riceviamo ogni tipo di errore non gestito, immaginatelo come la parte finale di un imbuto: abbiamo sia gli errori delle chiamate HTTP, sia gli errori di JavaScript a runtime.
Ricordo ancora una volta però che, se utilizzate operatori come catchError e non ritornate l’errore con throwError, non riceverete errori nei componenti e, ovviamente, non li riceverete nemmeno qui.