34. Обработка ошибок
🚨 Обработка ошибок в Angular
Заголовок раздела «🚨 Обработка ошибок в Angular»Ошибки — это неизбежная часть любого приложения. Angular даёт тебе несколько уровней защиты: глобальный ErrorHandler, HTTP-перехватчики, зонально-осведомлённая обработка и паттерны для Sentry. Разберём каждый уровень 🛡️
Глобальный ErrorHandler
Заголовок раздела «Глобальный ErrorHandler»По умолчанию Angular использует встроенный ErrorHandler, который просто логирует ошибку в консоль. Чтобы перехватывать ВСЕ необработанные ошибки, переопредели его:
import { ErrorHandler, Injectable, NgZone } from '@angular/core';import { Router } from '@angular/router';
@Injectable()export class GlobalErrorHandler implements ErrorHandler { constructor( private router: Router, private ngZone: NgZone ) {}
handleError(error: unknown): void { const err = error as Error;
console.error('💥 Глобальная ошибка:', err);
// Важно: навигация должна выполняться внутри NgZone this.ngZone.run(() => { if (err?.message?.includes('ChunkLoadError')) { // Lazy-loaded модуль не загрузился — обновляем страницу window.location.reload(); return; }
// Перенаправляем на страницу ошибки this.router.navigate(['/error'], { queryParams: { message: err?.message || 'Неизвестная ошибка' } }); }); }}Регистрация в app.config.ts (Standalone):
import { ApplicationConfig } from '@angular/core';import { provideRouter } from '@angular/router';import { ErrorHandler } from '@angular/core';import { GlobalErrorHandler } from './core/global-error-handler';
export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), { provide: ErrorHandler, useClass: GlobalErrorHandler } ]};Или в AppModule:
@NgModule({ providers: [ { provide: ErrorHandler, useClass: GlobalErrorHandler } ]})export class AppModule {}HTTP Error Interceptor
Заголовок раздела «HTTP Error Interceptor»Перехватчик HTTP-запросов — лучшее место для централизованной обработки HTTP-ошибок:
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse} from '@angular/common/http';import { inject } from '@angular/core';import { catchError, throwError } from 'rxjs';import { Router } from '@angular/router';import { NotificationService } from '../services/notification.service';
export const httpErrorInterceptor: HttpInterceptorFn = ( req: HttpRequest<unknown>, next: HttpHandlerFn) => { const router = inject(Router); const notify = inject(NotificationService);
return next(req).pipe( catchError((error: HttpErrorResponse) => { let message = 'Что-то пошло не так 😬';
switch (error.status) { case 0: message = 'Нет соединения с сервером. Проверь интернет 🌐'; break; case 400: message = error.error?.message || 'Неверный запрос'; break; case 401: message = 'Необходима авторизация'; router.navigate(['/login']); break; case 403: message = 'Доступ запрещён 🚫'; router.navigate(['/forbidden']); break; case 404: message = 'Ресурс не найден 🔍'; break; case 422: // Ошибки валидации — берём из тела ответа message = extractValidationErrors(error.error); break; case 429: message = 'Слишком много запросов. Подожди немного ⏳'; break; case 500: case 503: message = 'Ошибка сервера. Мы уже работаем над этим 🔧'; break; }
notify.showError(message);
// Пробрасываем ошибку дальше для обработки в компоненте return throwError(() => new Error(message)); }) );};
function extractValidationErrors(errorBody: any): string { if (!errorBody?.errors) return 'Ошибка валидации'; return Object.values(errorBody.errors).flat().join(', ');}Регистрация перехватчика:
import { provideHttpClient, withInterceptors } from '@angular/common/http';import { httpErrorInterceptor } from './core/interceptors/http-error.interceptor';
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(withInterceptors([httpErrorInterceptor])) ]};Zone-aware обработка ошибок
Заголовок раздела «Zone-aware обработка ошибок»Angular работает внутри Zone.js — патча, который отслеживает асинхронные операции. Ошибки в setTimeout, Promise и event listeners перехватываются автоматически. Но есть нюанс:
// Ошибка ВНУТРИ зоны — попадёт в GlobalErrorHandler ✅setTimeout(() => { throw new Error('Ошибка в таймере');}, 1000);
// Ошибка СНАРУЖИ зоны — НЕ попадёт в GlobalErrorHandler ❌this.ngZone.runOutsideAngular(() => { setTimeout(() => { throw new Error('Эта ошибка потеряется'); }, 1000);});
// Правильно — перехватывай вручную:this.ngZone.runOutsideAngular(() => { setTimeout(() => { try { riskyOperation(); } catch (error) { this.ngZone.run(() => { this.errorHandler.handleError(error); }); } }, 1000);});try/catch в async/await и Observable
Заголовок раздела «try/catch в async/await и Observable»В сервисах с async/await:
@Injectable({ providedIn: 'root' })export class UserService { async loadUser(id: string): Promise<User> { try { const response = await fetch(`/api/users/${id}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { // Логируем и пробрасываем с контекстом console.error(`Не удалось загрузить пользователя ${id}:`, error); throw error; // GlobalErrorHandler поймает } }}В компонентах с Observable:
@Component({ template: ` @if (loading()) { <app-spinner /> } @else if (error()) { <app-error-message [message]="error()!" /> } @else { <app-user-card [user]="user()!" /> } `})export class UserProfileComponent { private userService = inject(UserService);
user = signal<User | null>(null); error = signal<string | null>(null); loading = signal(false);
ngOnInit() { this.loading.set(true);
this.userService.getUser('123').pipe( catchError(err => { this.error.set(err.message); return EMPTY; // Прекращаем поток без ошибки }), finalize(() => this.loading.set(false)) ).subscribe(user => this.user.set(user)); }}async pipe и обработка ошибок
Заголовок раздела «async pipe и обработка ошибок»async pipe не обрабатывает ошибки сам — они пробрасываются наверх. Используй catchError в потоке:
@Component({ template: ` @if (users$ | async; as users) { @for (user of users; track user.id) { <app-user-card [user]="user" /> } } `})export class UsersListComponent { private userService = inject(UserService);
// Оборачиваем в catchError — ошибка не сломает поток users$ = this.userService.getUsers().pipe( catchError(error => { console.error('Ошибка загрузки:', error); return of([]); // Возвращаем пустой массив }) );}Страница ошибки
Заголовок раздела «Страница ошибки»@Component({ selector: 'app-error-page', template: ` <div class="error-container"> <div class="error-icon">💥</div> <h1>Что-то пошло не так</h1> <p class="error-message">{{ errorMessage() }}</p> <div class="error-actions"> <button (click)="goHome()">На главную</button> <button (click)="retry()">Попробовать снова</button> </div> </div> `})export class ErrorPageComponent { private router = inject(Router); private route = inject(ActivatedRoute);
errorMessage = signal('Неизвестная ошибка');
ngOnInit() { this.route.queryParams.subscribe(params => { if (params['message']) { this.errorMessage.set(params['message']); } }); }
goHome() { this.router.navigate(['/']); }
retry() { window.history.back(); }}Интеграция с Sentry
Заголовок раздела «Интеграция с Sentry»Sentry — популярный инструмент для мониторинга ошибок в production:
import { ErrorHandler, Injectable } from '@angular/core';import * as Sentry from '@sentry/angular';
@Injectable()export class SentryErrorHandler implements ErrorHandler { handleError(error: unknown): void { const err = error as Error;
// Не логируем 4xx ошибки — они ожидаемы if (isHttpError(err) && (err as any).status < 500) { console.warn('Client error:', err); return; }
// Отправляем в Sentry с контекстом Sentry.withScope(scope => { scope.setTag('source', 'angular-error-handler'); scope.setExtra('timestamp', new Date().toISOString()); Sentry.captureException(err); });
console.error(err); }}
function isHttpError(err: Error): boolean { return 'status' in err && 'url' in err;}Инициализация Sentry в main.ts:
import * as Sentry from '@sentry/angular';
Sentry.init({ environment: 'production', tracesSampleRate: 0.1, // 10% транзакций для performance monitoring integrations: [ Sentry.browserTracingIntegration(), Sentry.replayIntegration({ maskAllText: true }) ]});
bootstrapApplication(AppComponent, appConfig);Retry стратегия для HTTP
Заголовок раздела «Retry стратегия для HTTP»// Автоматическая повторная попытка с экспоненциальной задержкойimport { retryWhen, delay, scan, tap } from 'rxjs/operators';
export function retryWithBackoff(maxRetries = 3, delayMs = 1000) { return retryWhen(errors => errors.pipe( scan((retryCount, error) => { if (retryCount >= maxRetries) throw error; return retryCount + 1; }, 0), tap(count => console.log(`Попытка ${count}...`)), delay(delayMs * Math.pow(2, 0)) // 1s, 2s, 4s ) );}
// Использованиеthis.http.get('/api/data').pipe( retryWithBackoff(3, 1000), catchError(err => { notify.showError('Не удалось загрузить данные после 3 попыток'); return EMPTY; }));Лучшие практики 📋
Заголовок раздела «Лучшие практики 📋»| Слой | Инструмент | Когда использовать |
|---|---|---|
| Глобальный | ErrorHandler | Необработанные ошибки JS |
| HTTP | HttpInterceptor | Все HTTP-запросы |
| Компонент | catchError + signal | Локальная обработка состояния |
| Template | async pipe + catchError | Потоки в шаблоне |
| Production | Sentry | Мониторинг в продакшене |