Головна / Статті

Проєктування для невдалих запитів у Angular: стани, інтерцептори та повторні спроби

Дізнайтеся, як класифікувати помилки HTTP у Angular, очищувати стан завантаження, централізувати обробку через інтерцептори, безпечно повторювати спроби та відображати повідомлення, які дозволяють користувачам діяти.

Видавець: REACTAPP.TOP9 хв читання2160 слів

Джерела: **Handling API Failures Gracefully in Angular: Practical Error Handling Patterns Every Frontend Developer Should Know**

Більшість компонентів 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 Not Found: ресурс не існує

Об’єкт було видалено або URL застарів.

404 Not Found

500 Internal Server Error: виникла проблема на сервері

Щось зламалося на сервері.

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.

Хороше повідомлення відповідає на три запитання:

  1. Що пішло не так?
  2. Чи потрібно користувачеві щось робити?
  3. Що він може зробити далі?

Зберігання кодів статусу та відповідних повідомлень у одному місці забезпечує їхню послідовність.

Перепробуйте тимчасові помилки, але лише безпечні

Деякі помилки є тимчасовими, наприклад перебої в мережі. Для таких випадків 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. Оскільки об’єкти observables у 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+ можна використовувати @if замість *ngIf.

    Використовувати застарілі дані, якщо це дозволяє їх свіжість

    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() використовується для керування помилками, а не для їх приховування
  • Інтерцептор для обробки закінчення терміну дії сесії, проблем з правами та загальних помилок сервера; він знову кидає ці помилки, щоб функції могли все одно на них відреагувати
  • Повторні спроби обмежені безпечними, ідемпотентними запитами з можливістю затримок чи стратегії backoff
  • Повідомлення, які інформують користувачів про те, що сталося та що робити далі
  • Кнопки для ручного повторення спроб та, якщо це дозволяє свіжість даних, чітко позначені резервні дані
  • Структуроване логування, яке надсилається до сервісу моніторингу у продакшн-середовищі
  • Надійний додаток визначається тим, як він поводиться, коли щось йде не так.