Второй шаг в мир RxJS: Операторы RxJS — как изучать и зачем они нужны
Четвертый шаг в мир RxJs: незавершенные потоки — тихие убийцы приложений
Вы уже встречались с этими "веселыми" историями, когда разработчик заканчивает работу над задачей, она проходит тестирование, отправляется в прод, а там встречается неожиданным отказом какого-нибудь мелкого метода api и укладывает всё приложение так, что пользователи наблюдают только белый экран?
Я в своё время познакомился с ними чересчур близко... И, честно сказать, потоки RxJs прекрасные учителя - тебе не захочется снова повторять их уроки. Чему же они нас учат? В первую очередь тому, что не стоит доверять внешним источникам; вы не контролируете ни соединение с сервером, ни api-сервис, а значит не имеете никаких оснований слепо доверять им и ожидать безотказной работы.
Если ваш бэк имеет коэффициент доступности в пять девяток (отличный результат!), он по-прежнему не работает несколько минут в году. Отказы бывают у любых систем.
Типичные сценарии новичков
Призрачные данные
this.api.getData().subscribe(
data => this.render(data) // А если data === undefined?
);
Немой сбой
combineLatest(
loadUsers(),
loadProducts() // Если упадёт здесь — всё остановится
).subscribe();
Эффект домино
interval(1000).pipe(
switchMap(() => new Observable(o => {
o.error('Error!')
}))
).subscribe(); // При ошибке падает весь поток, данные перестают обновляться
«Хороший разработчик пишет код. Отличный — предвидит, как он сломается»
— Неизвестный Архитектор*
Базовые операторы для работы с ошибками: ваш набор "скорой помощи" (актуально для RxJS 7.8+)
catchError: цифровая аптечка
Как это работает
Представьте, что ваш Observable — это курьерская служба. catchError
— это страховая компания, да, она не сможет вернуть потерянный при доставке товар, но попробует предложить замену (денежная компенсация вас устроит?).
Практика
import {catchError} from 'rxjs/operators';
import {of} from 'rxjs';
const request = new Observable(o => {
o.error('Error!');
o.complete();
});
request.pipe(
catchError(error => {
console.log(error); // Логируем проблему
return of([]); // Возвращаем пустой массив как fallback
})
).subscribe(orders => {
console.log(orders); // Всегда получим данные
});
Здесь и далее в примерах я буду приводить именно такую форму, с самописной
Observable
. Давайте договоримся, что в реальности там будет что-то в духеthis.http.get('/api/orders')
; для примера эта запись не подходит, потому что её придется переписывать для собственных экспериментов, а сObservable
можно скопировать код и проводить опыты. Так же вместо записей в духеthis.logService.report
(error)
я оставляюconsole.log(error)
, по той же причине.
retry: умный повтор с контролем
Философия
Как хороший бариста делает кофе заново при ошибке — так retry
повторяет запросы. Но помните: не все операции идемпотентны!
Пример с конфигурацией
import {retry, timer} from 'rxjs';
const request = new Observable(o => {
o.error('Error!');
o.complete();
});
request.pipe(
retry({
count: 3, // Максимум 3 попытки
delay: (error, retryCount) => timer(1000 * retryCount) // Задержка растёт: 1s, 2s, 3s
})
).subscribe();
Правила безопасности
Никогда не используйте для POST/PUT-запросов
Всегда устанавливайте разумный лимит попыток
Комбинируйте с задержками для защиты сервера
finalize: гарантированная уборка
Почему это важно
Война войной, а обед по расписанию, finalize
выполнится при любом исходе:
Успешное завершение
Ошибка
Ручная отписка
Идеальный кейс
this.loading = true;
const request = new Observable(o => {
o.error('Error!');
o.complete();
});
request.pipe(
finalize(() => {
this.loading = false; // Всегда сбрасываем флаг
console.log('DataLoadCompleted');
})
).subscribe();
Почему мы больше не используем retryWhen?
Устаревший подход
retryWhen
объявлен deprecated в RxJS 7.8+Новые возможности
Объект конфигурацииretry
проще и безопаснее:
retry({
count: 4,
delay: (_, i) => timer(1000 * 2 ** i) // Экспоненциальная задержка
})
3. Читаемость кода
Конфиг-объект делает логику повторов явной
Советы из боевого опыта
Правило трёх уровней
Уровень 1: Повтор запроса (
retry
)Уровень 2: Fallback-данные (
catchError
)Уровень 3: Глобальный обработчик
Правило 80/20
80% ошибок обрабатывайте черезcatchError
, 20% — через сложные стратегии.Логирование — как дневник
Всегда записывайте:Тип ошибки
Контекст операции
Временную метку
Тестируйте failure-сценарии
На каждый десяток позитивных тестов добавляйте 1-2 теста с ошибками.
"Код без обработки ошибок — как дом без пожарного выхода: работает, пока не случится беда"
Примеры использования операторов: от теории к практике
Сценарий 1: Грациозная деградация данных
Проблема
Приложение падает, если API возвращает 404 на странице товара.
Решение с catchError
this.product$ = this.http.get(`/api/products/${id}`).pipe(
catchError(error => {
if (error.status === 404) {
return of({
id,
name: 'Товар временно недоступен',
image: '/assets/placeholder.jpg'
});
}
throw error; // Пробрасываем другие ошибки
})
);
Эффект:
Пользователь видит информативную карточку вместо белого экрана.
Сценарий 2: Умный повтор запросов
Проблема
Мобильные клиенты часто теряют связь при загрузке ленты новостей.
Решение с retry
this.http.get('/api/feed').pipe(
retry({
count: 3, // Максимум 3 попытки
delay: (error, retryCount) => timer(1000 * retryCount) // Линейная задержка
}),
catchError(err => {
this.offlineService.showWarning();
return EMPTY;
})
).subscribe();
Статистика:
Уменьшение ошибок загрузки в условиях нестабильной сети.
Сценарий 3: Комплексная обработка платежей
Проблема
Нужно гарантировать выполнение клиринга даже при ошибках.
Комбинация операторов
processPayment(paymentData).pipe(
retry(2), // Повтор для временных сбоев
catchError(error => {
this.fallbackProcessor.process(paymentData);
return EMPTY;
}),
finalize(() => {
this.cleanupResources();
this.logService.flush(); // Гарантированная запись логов
})
).subscribe();
Архитектурный совет:
Всегда разделяйте "повторяемые" и "фатальные" ошибки.
Сценарий 4: Фоновые синхронизации
Проблема
Фоновый процесс синхронизации "зависает" при ошибках.
Решение с finalize
this.syncJob = interval(30_000).pipe(
switchMap(() => this.syncService.run()),
finalize(() => {
this.jobRegistry.unregister('background-sync');
this.memoryCache.clear();
})
).subscribe();
Важно:
Даже при ручной отписке ресурсы будут освобождены.
Советы из боевых условий
Паттерн "Слоёная защита"
Комбинируйте операторы как фильтры:
Поток → retry(3) → catchError → finalize
Метрики — ваши друзья
Добавляйте счётчики ошибок:
catchError(error => {
this.metrics.increment('API_ERRORS');
throw error;
})
Тестируйте крайние случаи
Используйте marble-диаграммы для моделирования ошибок:
cold('--a--#', null, new Error('Timeout')).pipe(...)
Стратегии обработки ошибок: как не закопаться в исключениях
1. Стратегия "Острова безопасности"
Концепция
Разбивайте поток на независимые сегменты с локальной обработкой ошибок. Как водонепроницаемые отсеки в корабле.
Реализация
merge(
this.loadUserData().pipe(
catchError(() => of(null)) // Ошибка не сломает другие потоки
),
this.loadProducts().pipe(
retry(2) // Своя политика повторов
)
).pipe(
finalize(() => this.hideLoader()) // Общая точка очистки
);
Эффект:
Ошибка в одном потоке не останавливает работу всей системы.
2. Стратегия "Эшелонированная защита"
Трехуровневая модель
Уровень запроса:
Повторы для временных сбоев
this.http.get(...).pipe(retry(3))
Уровень компонента:
Fallback-данные
catchError(() => this.cache.getData())
Уровень приложения:
Глобальный перехватчик ошибок
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error) {
this.sentry.captureException(error);
}
}
3. Стратегия "Умного повтора"
Когда использовать
Сервисы с нестабильным соединением
Критически важные операции
Фоновые синхронизации
Шаблон "Экспоненциальный бекофф"
retryWhen(errors => errors.pipe(
retry({
count: 4,
delay: (error, retryCount) => timer(1000 * 2 ** i)
}),
catchError(err => this.fallbackStrategy())
));
Статистика из практики:
Успешное восстановление в большинстве случаев при 4 попытках.
4. Стратегия "Тихий отказ"
Для чего
Не критичные к данным компоненты
Демо-режимы
Возможна деградация функционала (переход со стримов на http-запросы)
Реализация
this.liveUpdates$ = websocketStream.pipe(
catchError(() => interval(5000).pipe(
switchMap(() => this.http.get('/polling-endpoint'))
)
)
);
Эффект:
Пользователь продолжает работу в ограниченном режиме.
5. Стратегия "Явного краха"
Когда нужно
Операции с деньгами
Юридически значимые действия
Системы безопасности
Реализация
processTransaction().pipe(
tap({error: () => this.rollbackTransaction()}),
catchError(error => {
this.showFatalError();
throw error;
})
);
Золотое правило:
Лучше явная ошибка, чем некорректное состояние.
Чек-лист выбора стратегии
Насколько критична операция?
Деньги/безопасность → "Явный крах"
Просмотр данных → "Тихий отказ"
Как часто возникают ошибки?
Часто → "Эшелонированная защита"
Редко → "Острова безопасности"
Какие ресурсы доступны?
Есть кеш → "Умный повтор"
Нет резервов → "Тихий отказ"
"Стратегия без метрик — как компас без стрелки"
— Принцип observability в микросервисах
Распространённые ошибки новичков: как не наступить на грабли
1. Молчаливое проглатывание ошибок
❌ Проблемный подход
this.http.get('/api/data').subscribe(data => {
// Ошибки? Какие ошибки?
this.render(data);
});
Последствия:
Пользователь видит "зависший" интерфейс, ошибки не логируются.
✅ Правильное решение
this.http.get('/api/data').subscribe({
next: data => this.render(data),
error: err => this.handleError(err) // Всегда обрабатываем ошибку
});
2. Бесконечные повторы
❌ Опасный код
this.http.get('/api/orders').pipe(
retry() // Бесконечный цикл при 500 ошибке
);
Риски:
DDoS своего же сервера.
✅ Безопасный вариант
retry(3) // Чёткий лимит попыток
3. Игнорирование отписок
❌ Типичная ситуация
ngOnInit()
{
interval(1000).subscribe(data => {
// При переходе на другой роут — подписка живёт вечно
this.updateRealTimeData(data);
});
}
Эффект:
Утечки памяти, конфликты обновлений.
✅ Профессиональный подход
let destroy$ = new Subject<void>();
ngOnInit()
{
interval(1000).pipe(
takeUntil(this.destroy$)
).subscribe(...);
}
ngOnDestroy()
{
this.destroy$.next();
this.destroy$.complete();
}
4. Глобальный перехватчик как "мусорка"
❌ Антипаттерн
// global-error-handler.ts
handleError(error)
{
// Ловим ВСЕ ошибки без разбора
this.sentry.captureException(error);
}
Проблема:
Невозможно кастомизировать обработку для конкретных сценариев.
✅ Стратифицированный подход
// Локальная обработка
catchError(err => handleLocalError(err))
// Глобальный перехватчик
handleError(error)
{
if (error.isCritical) {
this.sentry.captureException(error);
}
}
Чек-лист для самопроверки
Все ли подписки имеют обработку
error
?Есть ли ограничения у
retry
?Используется ли
takeUntil
для отписок?Разделены ли глобальные и локальные ошибки?
«Ошибки — как грабли: чтобы перестать наступать, нужно сначала увидеть их в коде»
— Очередной "ауф"-принцип из пацанских пабликов
Заключение: Ошибки как путь к мастерству
Что мы узнали?
За последние годы работы с RxJS я понял: настоящее мастерство начинается там, где другие видят проблемы. Обработка ошибок — не рутина, а искусство проектирования отказоустойчивых систем.
Главные уроки:
Обработка ошибок — индикатор зрелости кода
КаждыйcatchError
в вашем коде — это шаг к профессиональному уровню.Повторы ≠ панацея
Правильно настроенный и продуманныйretry
спасёт там, где базовая реализация навредит.
Куда двигаться дальше?
Экспериментируйте с комбинациями
Пример продвинутой цепочки:data$.pipe( retryWhen(exponentialBackoff(1000, 3)), catchError(switchToCache), finalize(cleanup) )
Изучайте реальные кейсы
Исходники Angular HttpClient и Ngrx — кладезь паттернов.Делитесь знаниями
Напишите пост о своей самой сложной ошибке — это лучший способ закрепить опыт.
"За 20 лет в разработке я видел много 'идеальных' систем. Все они ломались. Выживали те, где ошибки были частью дизайна."
— Кто-то это, определенно, когда-то кому-то сказал*
Ваш следующий шаг:
Откройте свой последний проект. Найдите хотя бы один поток без обработки ошибок — и превратите его в пример надёжности. Помните: каждый обработанный сбой — это спасённые часы поддержки и тысячи довольных пользователей.