Перейти к содержимому

34. Обработка ошибок

Ошибки — это неизбежная часть любого приложения. Angular даёт тебе несколько уровней защиты: глобальный ErrorHandler, HTTP-перехватчики, зонально-осведомлённая обработка и паттерны для Sentry. Разберём каждый уровень 🛡️


По умолчанию Angular использует встроенный ErrorHandler, который просто логирует ошибку в консоль. Чтобы перехватывать ВСЕ необработанные ошибки, переопредели его:

core/global-error-handler.ts
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):

app.config.ts
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-запросов — лучшее место для централизованной обработки HTTP-ошибок:

core/interceptors/http-error.interceptor.ts
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(', ');
}

Регистрация перехватчика:

app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { httpErrorInterceptor } from './core/interceptors/http-error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([httpErrorInterceptor]))
]
};

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

В сервисах с 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 не обрабатывает ошибки сам — они пробрасываются наверх. Используй 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([]); // Возвращаем пустой массив
})
);
}

pages/error/error-page.component.ts
@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 — популярный инструмент для мониторинга ошибок в production:

core/sentry-error-handler.ts
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:

main.ts
import * as Sentry from '@sentry/angular';
Sentry.init({
dsn: 'https://[email protected]/project-id',
environment: 'production',
tracesSampleRate: 0.1, // 10% транзакций для performance monitoring
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: true })
]
});
bootstrapApplication(AppComponent, appConfig);

// Автоматическая повторная попытка с экспоненциальной задержкой
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
HTTPHttpInterceptorВсе HTTP-запросы
КомпонентcatchError + signalЛокальная обработка состояния
Templateasync pipe + catchErrorПотоки в шаблоне
ProductionSentryМониторинг в продакшене