Пятый шаг в мир 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 лет в разработке я видел много ‘идеальных’ систем. Все они ломались. Выживали те, где ошибки были частью дизайна.»
— Кто-то это, определенно, когда-то кому-то сказал*

Ваш следующий шаг:
Откройте свой последний проект. Найдите хотя бы один поток без обработки ошибок — и превратите его в пример надёжности. Помните: каждый обработанный сбой — это спасённые часы поддержки и тысячи довольных пользователей.


ссылка на оригинал статьи https://habr.com/ru/articles/910232/