Проектирование приложений в Angular для обработки неудачных запросов: состояния, интерцепторы и повторные попытки
Узнайте, как классифицировать сбои HTTP в Angular, очищать состояние загрузки, централизовать обработку в интерцепторах, безопасно повторять попытки и отображать сообщения, на которые могут отреагировать пользователи.
Большинство компонентов Angular написаны с учётом успешного сценария: код 200 OK, данные в свойстве, отрисовка шаблона. В реальных условиях происходят сбои соединений, истекают сессии, ломаются бэкенды, а пользователи дважды нажимают кнопки отправки. Приведённые ниже паттерны позволяют приложению Angular обнаруживать такие сбои, объяснять их пользователю, восстанавливаться там, где это безопасно, и предоставлять разработчикам достаточно информации для устранения причины.
Цена игнорирования обработчика ошибок
Типичный запрос к компоненту подписывается на ответ, присваивает его и больше ничего не делает.
this.http.get('/api/accounts').subscribe({
next: (response) => {
this.accounts = response;
}
});
Если сервер отвечает кодом 500, ничего заметного не происходит. Страница остаётся пустой или продолжает отображаться индикатор загрузки, а единственным следом является запись в консоли, которую пользователь никогда не откроет.
Минимальным улучшением будет наличие обработчика error, который записывает информацию о сбое и сохраняет понятное сообщение для шаблона.
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.';
}
});
Это сработает для одного экрана. Но при десятках экранов копирование обработчиков во все места приводит к несогласованным сообщениям, упущенным крайним случаям и дублированию кода, поэтому так важны переиспользуемые шаблоны.
Не все сбои означают одно и то же
Код состояния обычно указывает, кто может решить проблему, и это определяет способ реагирования.
400 Bad Request: неверные данные ввода
Фронтенд отправил недействительные данные.
400 Bad Request
Обычно пользователь может это исправить, например, скорректировав поле формы.
401 Unauthorized: сессия отсутствует или истекла
Отсутствуют учетные данные, чаще всего из-за истечения срока действия токена доступа.
401 Unauthorized
Обычная реакция — перенаправление на вход или сначала обновление токена.
403 Forbidden: пользователь авторизован, но действие запрещено
Сервер знает пользователя, но отказывает в выполнении действия.
403 Forbidden
Это стандартная ситуация с контролем доступа на основе ролей. Перенаправление пользователя на вход не поможет; ему необходимо ясно сообщить, что у него нет разрешений.
404 Не найдено: ресурс отсутствует
Этот объект был удален или URL устарел.
404 Not Found
500 Внутренняя ошибка сервера: сбой на стороне бэкенда
Что-то пошло не так на сервере.
500 Internal Server Error
Пользователь не может это исправить; необходимо предоставить понятное сообщение и возможность попробовать снова позже.
Ошибки сети: запрос так и не дошел
Иногда запрос вообще не достигает сервера.
Internet connection lost
Server unavailable
DNS failure
Request timeout
Angular отображает их как HttpErrorResponse с status 0, отличая таким образом от настоящих ответов сервера. Эта категория определяет все последующие действия: следует ли попробовать снова, перенаправить пользователя или что сообщить ему.
Моделируйте запрос как набор состояний
Частой ошибкой является отслеживание только успешных результатов. Рассматривайте запрос как небольшую машину состояний: она начинает загрузку и заканчивается либо успехом, либо ошибкой.
Loading
↓
Success
or
Loading
↓
Error
В компоненте это означает наличие явных флагов для отображения состояния загрузки и сообщения об ошибке, которые обновляются на каждом шаге выполнения.
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;
}
});
}
Критически важным моментом является сброс значения loading в обработчике ошибок; без этого каждая неудача приводит к бесконечному отображению индикатора загрузки. Также важно очищать значение errorMessage в начале работы, чтобы старые сообщения об ошибках не оставались рядом с новыми данными.
Позвольте функции finalize() заниматься очисткой флага загрузки
Сброс флага в двух местах увеличивает риск ошибок. Функция RxJS finalize() позволяет выполнять очистку в одном месте.
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.';
}
});
}
Функция-колбэк finalize() выполняется, когда объект с возможностью наблюдения завершает работу или возникает ошибка, а также при досрочном отменении подписки. Это делает её идеальной для скрытия индикаторов загрузки, и дублирующиеся строки this.loading = false исчезают.
Централизация общего поведения в интерцепторе HTTP
Проблемы, связанные с истекшей сессией или общими сбоями, должны решаться одинаково на всех страницах; поэтому их реализация в каждом компоненте является избыточной и нестабильной. Интерцептор HTTP обрабатывает каждый запрос HttpClient, что делает его подходящим местом для внедрения правил, действующих во всем приложении. В данном примере используется функциональный стиль HttpInterceptorFn с разветвлением в зависимости от статуса ответа.
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);
})
);
};
Вызовы для логирования являются местоположениями для перенаправления, уведомлений или событий мониторинга. Что ещё важнее, интерцептор заканчивается строкой throwError(() => error), поэтому ошибка всё равно доходит до компонента-звонка. Интерцептор обрабатывает универсальные аспекты, а компонент — то, что понимает только он сам, и такое разделение делает код более удобным для обслуживания. Функциональные интерцепторы регистрируются с помощью provideHttpClient(withInterceptors([...])).
Переводите технические ошибки в понятные для человека сообщения
Бэкенды не всегда возвращают дружелюбный текст:
{
"message": "NullPointerException at AccountService.java:142"
}
Это помогает владельцам сервисов, но для клиента это вызывает путаницу и раскрывает детали реализации. Вместо этого покажите что-то, написанное для пользователя.
We couldn't load your accounts right now.
Please try again.
Истекшая сессия заслуживает своего собственного описания:
Your session has expired.
Please sign in again.
То же самое касается проблем с правами:
You don't have permission to access this resource.
Хорошее сообщение отвечает на три вопроса:
- Что пошло не так?
- Необходимо ли действие со стороны пользователя?
- Что он может сделать дальше?
Сопоставление кодов состояния с сообщениями в одном месте обеспечивает единообразие формулировок.
Повторяйте попытки при временных сбоях, но только при безопасных из них
Некоторые сбои являются временными, например, проблемы с сетью. Для таких случаев RxJS предоставляет метод retry().
this.http.get('/api/accounts')
.pipe(
retry(2)
)
.subscribe({
next: (data) => {
this.accounts = data;
},
error: () => {
this.errorMessage =
'Unable to load accounts.';
}
});
retry(2) повторно подписывается до двух раз перед тем, как ошибка дойдет до обработчика error. Поскольку объекты типа observable от HttpClient являются «холодными», каждая повторная подписка отправляет новый запрос.
В этом и кроется опасность. Если запрос на оплату успешно обработан на сервере, но его ответ потерян, повторные попытки могут взимать сумму с клиента дважды, если только бэкенд не поддерживает идемпотентность. Остерегайтесь автоматических повторных попыток для:
- Оплат
- Заказов
Запросы типа GET с режимом только чтения гораздо проще для анализа. Также следует отметить, что простая функция retry() повторяет попытки при каждой ошибке, включая 400 или 401, которые никогда не приведут к успеху; обычно следует повторять попытки только при сетевых ошибках и ответах типа 5xx.
Повторные попытки с задержкой
Немедленная повторная попытка часто направляется на тот же перегруженный сервер. Форма конфигурации функции retry() позволяет добавить паузу.
import { retry } from 'rxjs';
this.http.get('/api/accounts')
.pipe(
retry({
count: 2,
delay: 1000
})
)
.subscribe();
Запрос получает ещё две попытки с интервалом в одну секунду. Параметр delay также принимает функцию, что позволяет реализовать экспоненциальное увеличение задержек (каждая пауза длиннее предыдущей) и пропускать повторные попытки при ошибках, которые не могут привести к успеху. Такой подход предотвращает массовые попытки нескольких сбоющих клиентов одновременно нагружать восстанавливающийся сервер.
Рассматривайте истекшие сессии как глобальную проблему
Классическая ситуация: пользователь открывает приложение утром и возвращается к нему спустя несколько часов. Токен уже истек, поэтому следующий запрос терпит неудачу.
GET /api/accounts
401 Unauthorized
Интерцептор — это место, где можно обнаружить эту проблему:
if (error.status === 401) {
// Clear authentication state
// Redirect to login
}
Обработка этой проблемы в одном месте исключает необходимость повторной реализации логики проверки истечения сессии в отдельных компонентах. С использованием токенов обновления интерцептор может попытаться их обновить перед перенаправлением; если несколько запросов терпят неудачу одновременно, ознакомьтесь с причинами, по которым одновременные ошибки 401 приводят к выходу пользователей для ознакомления с подходом «один запрос».
Дайте пользователю кнопку для повторной попытки
Часто самым эффективным инструментом восстановления является простая кнопка, которая повторяет запрос.
<div *ngIf="errorMessage">
<p>{{ errorMessage }}</p>
<button (click)="loadAccounts()">
Try Again
</button>
</div>
Представьте пользователя, у которого соединение прерывается на несколько секунд. Как только он снова подключается, ему не нужно перезагружать приложение и терять сохраненные данные; ему достаточно просто нажать:
Try Again
Поскольку функция loadAccounts() сначала сбрасывает свое состояние, она используется как при первоначальной загрузке, так и при каждой попытке повторного запуска. В шаблонах Angular 17+ вместо *ngIf можно использовать @if.
Использование устаревших данных, если это допускает их свежесть
Если обновление панели управления не удалось, последние загруженные данные вместе с честным уведомлением обычно лучше, чем пустая страница.
Showing your last available data.
We couldn't refresh the dashboard.
Кэш делает это возможным, а уведомление имеет решающее значение. Избегайте ситуаций, когда свежесть данных критически важна: показатели балансов, цены акций, информация о запасах и детали платежей должны отображать ошибку, а не устаревшие значения.
Фиксируйте достаточно контекста для отладки сбоев
Сокрытие деталей от пользователей не означает их уничтожения. Полезная запись журнала содержит:
Endpoint
HTTP status
Timestamp
Request identifier
User/session context where appropriate
Error category
Минимальная версия интерцептора:
console.error({
endpoint: req.url,
status: error.status,
message: error.message
});
В производственной среде никто не следит за консолью, поэтому отправляйте данные в сервис мониторинга ошибок, где идентификатор запроса позволяет связать его с логами серверной части. Никогда не записывайте токены или ненужные персональные данные.
Цель — достичь баланса: пользователь получает полезное сообщение, а разработчик — достаточно деталей для выявления причины.
Не скрывайте сбои под пустыми результатами
Привлекательным упрощением является подавление ошибки и возврат пустого значения:
catchError(() => {
return of([]);
})
Это косвенно меняет смысл: пустой массив указывает на то, что сервер вернул нуль записей. Пользователь, не видящий транзакций, может предположить, что их вообще нет, что хуже, чем ошибка. Режимы фолбэка могут быть правильными, но только если это сделано умышленно; в противном случае необходимо передать ошибку:
catchError((error) => {
return throwError(() => error);
})
Разделение обязанностей между глобальной и функциональной обработкой
Глобальная обработка подходит для задач, которые одинаковы везде:
401 → Authentication handling
403 → Authorization handling
500 → Generic server error
Такие задачи должны находиться в интерцепторе. Теперь рассмотрим следующее:
POST /api/transfer-money
При неудачной передаче требуется конкретное объяснение и четкое указание на то, переместились ли деньги, чего нельзя дать с помощью общего уведомления. Функциональный модуль должен получать ошибку, поэтому интерцептор снова её выбрасывает. Надежная архитектура — это глобальная обработка общих задач плюс функциональная обработка там, где важен бизнес-контекст.
Чек-лист для проверки сценариев сбоев
Обработка ошибок заключается в планировании действий после возникновения сбоя. При анализе новой функции задайте себе следующие вопросы:
- Понимает ли пользователь, что произошло?
- Может ли он восстановить ситуацию без перезагрузки страницы?
- Есть ли возможность попробовать снова?
- Прекращается ли индикатор загрузки всегда?
- Исключены ли технические детали из интерфейса?
- Записывается ли информация о сбое в месте, доступном для разработчиков?
- Что происходит, когда токен истекает во время сессии?
- Что происходит, когда связь прерывается?
- Что происходит, когда бэкенд возвращает данные в неожиданном формате?
Основные выводы
Сбои — это нормально, поэтому старайтесь добиваться их корректного устранения. В Angular это достигается за счёт:
- Явного указания состояний загрузки, успеха и ошибки для каждого запроса
finalize() — для выполнения операций по очистке независимо от того, как завершился запросcatchError() используется для обработки ошибок, а не для их скрытияНадежное приложение определяется тем, как оно ведет себя при возникновении проблем.