Как стать автором
Обновить

Пятый шаг в мир RxJS: Обработка ошибок

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров1K

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

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

Теги:
Хабы:
+3
Комментарии0

Публикации

Работа

Ближайшие события