Обработка ошибок в прикладных контролах

В контролы Wasaby Framework, работающими с источниками данных, внедрён механизм обработки ошибок, описанный в данной статье. Подробнее читайте в статье Обработка ошибок на уровне платформы.

Принципы обработки ошибок в wasaby

Принцип работы механизма отображения ошибок

Для отображения ошибок согласно спецификации разработаны:

Принцип работы

Для отображения ошибки необходимо вставить шаблон контрола Container в ту область, где она должна отобразиться.

В контрол импортируется Controller, и в случае ошибки результат вызова из источника данных передается аргументом при вызове метода process().

И уже результат вызова process() передается в атрибут viewConfig контрола Controls/dataSource:error.Container для его визуального отображения.

Подробный пример будет рассмотрен ниже.

Повторное отображение диалоговых окон

ErrorContainer отобразит ошибку в диалоговом окне только один раз. Повторное возникновение ошибки не вызовет показ диалогового окна. Если вам нужно показывать ошибку в диалоговом окне каждый раз при ее возникновении, воспользуйтесь функцией Controls/error:process.

Режимы отображения ошибок

Существует три стандартных способа отображения ошибки:

  • во всю страницу;
  • в области контрола;
  • в диалоговом окне.

Способ отображения выбирается автоматически в обработчике, но может быть указан явно при вызове метода process().

Особенности обработки ошибок в списках

В платформенные списки встроена обработка ошибок при удалении записей с помощью Controls/list:Remover. Если возникает ошибка при удалении, то будет показано всплывающее окно либо с дружелюбной ошибкой, если ошибка типовая (см. обработчики типовых ошибок), либо с текстом ошибки. Данное поведение по умолчанию можно отключить, если в ответе обработчика afterItemsRemove вернуть false.

Например, если вы хотите показать свое сообщение при удалении:

<!-- WML -->
<Controls.list:Remover name="listRemover" on:afterItemsRemove="_afterItemsRemove()" />
// TypeScript
import {Confirmation} from 'Controls/popup';
 
protected _afterItemsRemove(event: SyntheticEvent, idArray: string[]): boolean {
   Confirmation.openPopup({
      message: 'Нельзя удалять элементы из этого списка!',
      style: 'danger',
      type: 'ok'
   });
 
   return false;
}

Упрощенный способ показа дружелюбной ошибки

Если вы хотите показать дружелюбное сообщение в диалоге, можно не создавать контроллер и самим показывать диалог – достаточно передать ошибку в функцию Controls/error:process. Она сама обработает и покажет ошибку в диалоговом окне. Функция возвращает Promise. Если платформенный диалог открылся, то в промисе будет идентификатор окна, этот идентификатор надо использовать для закрытия окна через Controls/popup:Dialog.closePopup.

В случае обрыва соединения или недоступности сервисов ресурсы, необходимые для показа диалогового окна, могут не загрузиться, в этом случае платформенное диалоговое окно открыть не получится и будет показан браузерный alert. Для показа платформенных диалоговых окон необходимые ресурсы будут загружены асинхронно через 15 секунд после загрузки модуля SbisEnvUI (присутствует на всех страницах).

Таким образом, используя функцию process, вы:

  • сокращаете количество кода и улучшаете его читаемость;
  • уменьшаете вложенность компонентов, не используя ErrorContainer, что также слегка улучшает производительность.

Пример использования функции process:

// TypeScript
import { process } from 'Controls/error';
 
// Функция вызывает БЛ через Types/source:SbisService, возвращает результат метода call().
declare callMethod(): Promise<object>;
 
function callAndHandleResult() {
    return callMethod().catch((error) => process({ error }));
}

Синхронная обработка ошибок

Описание синхронной обработки

Метод ErrorController.process() обрабатывает ошибку асинхронно и возвращает промис. Однако, вонизникают ситуации, когда нужно обработать ошибку синхронно. Характерный пример возник после перехода на react - нужно обработать ошибку, которая пришла вашему компоненту в опциях еще на этапе _beforeMount. Здесь нельзя возвращать промис, и если вы попробуете обработать ошибку как обычно, с помощью метода process, то вы получите конфигурацию отображения уже после монтирования. И тогда будет заметна задержка перед отображением шаблона ошибки.

Для избежания таких ситуаций был создан метод для синхронной обработки ошибки -- ErrorController.processSync(). Результатом работы метода будет готовый ErrorViewConfig.

Особенности синхронной обработки

В редких случаях ошибку не получится обработать синхронно из-за природы самой ошибки. Тогда ErrorViewConfig будет содержать базовое сообщение об ошибке "У СБИС возникла проблема".

Пример с синхронной обработкой ошибки

В этом примере мы получим ошибку в _beforeMount и обработаем ее синхронно.

import { ErrorController } from 'Controls/error';
import { ErrorViewConfig, ErrorViewMode } from 'ErrorHandling/interface';
 
// IOptionWithError - интерфейс со свойством error
export default class MyControl extends Control<IOptionWithError> {
   protected errorController: new ErrorController();
   protected viewConfig: ErrorViewConfig | undefined;
    
   protected _beforeMount({ error }: IOptionsWithError): void {
      // Обрабатываем ошибку синхронно и сразу же получаем конфигурацию отображения
      this.viewConfig = this.errorController.processSync({
         error,
         mode: ErrorViewMode.include
      });
   }
}

Пример подключения

Пошаговое подключение механизма отображения ошибок, состоящее из 4 шагов:

  1. Подключение error.Controller
  2. Вставка error.Container в шаблон
  3. Подготовка ошибки на сервисе представления.
  4. Выбор режима отображения ошибки.

Исходный контрол

Для примера берём исходный код контрола, в котором используется некорректный, устаревший способ отображения ошибки в диалоговом окне по центру страницы.

В листинге представлен JavaScript код в стандарте ES6.

class MyComponent extends Control {
   protected _source = new SbisService(/* ... */);
   protected _template = template;
    
   read(param) {
      return this._source.read(param).then((result) => {
         this._data = result;
         return result;
      },(error) => {
         this._showErrorDialog(error);
      });
   }
   private _showErrorDialog(error) {
      this._children.confirmationOpener.open({
         message: error.message,
         type: 'error'
      });
   }
}

Проблемы с отображением ошибок данного класса:

  1. Если во время построения контрола на сервере возникнет ошибка получения данных, контрол не будет построен, и сообщение об ошибке не будет получено на клиенте.
  2. Отсутствует понимание типов ошибок (для ошибки отмены запроса AbortError рисовать диалоговое окно с ошибкой не правильно), необходима проверка корректности текстов ошибок.
  3. Нет возможности частично построить контрол.

1. Подключение error.Controller

Для решения указанных проблем внедрим контрол обработки ошибок Controls/error:ErrorController, с помощью которого получим данные для отображения "дружелюбной" для пользователя ошибки.

import { ErrorController } from 'Controls/error';
 
class MyComponent extends Control {
   protected _source = new SbisService(/* ... */);
   protected _template = template;
    
   /**
    * Controls/error:ErrorController
    */
   private _errorController = new ErrorController({});
   protected _beforeMount(options, context, receivedState) {/* ... */}
    
   /** Метод чтения данных */
   read(param) {
      return this._source.read(param).then((result) => {
         this._data = result;
         return result;
      },(error) => {
         this._processError(error);
      });
   }
   private _processError(error) {
      return this._errorController.process(error).then((errorViewConfig) => {
         this._showError(errorViewConfig);
         return error;
      });
   }
   private _showError(config) {
      // TODO
   }
}

При вызове метода process() Controls/error:ErrorController проходит по зарегистрированным обработчикам типовых ошибок и возвращает подготовленные данные для отображения парковочного шаблона viewConfig:

  1. {String} template путь до отображаемого шаблона;
  2. {Object} options параметры для шаблона;
  3. {Controls/error.ErrorViewMode} mode режим отображения (диалог, в области контрола, на всю страницу).

2. Вставка error.Container в шаблон

Для отображения полученных данных воспользуемся контролом Controls/dataSource:error.Container, в который передадим полученные от Controls/error:ErrorController данные. При этом убираем у себя opener, который ранее использовали для отображения диалога с ошибкой.

<!-- WML -->
<div class="MyComponent">
   <h1>{{ _options.title }}</h1>
   <Controls.dataSource:error.Container
      name="errorContainer"
      viewConfig="{{ _error }}">
      Содержимое контрола, которое будет скрыто при возникновении ошибки.
   </Controls.dataSource:error.Container>
</div>
// TypeScript
read(param) {
   this._hideError();
   return this._source.read(param).then((result) => {
      this._data = result;
      return result;
   },(error) => {
      this._processError(error);
   });
}
private _processError(error) {/*...*/}
private _showError(viewConfig) {
   this._error = viewConfig;
}
private _hideError() {
   if (this._error) {
      this._error = null;
   }
}

При получении опции viewConfig контрол Controls/dataSource:error.Container проверяет режим отображения и, в зависимости от него, скрывает своё содержимое, отображая парковочный шаблон, или же отображает диалоговое окно с нужными параметрами.

3. Подготовка ошибки на сервисе представления

До сих пор вызываемый в _beforeMount() метод read возвращал Promise с ошибкой, и VDOM генерировал пустой элемент вместо верстки. Чтобы контрол был построен на сервисе представления, необходимо всегда возвращать успешный Promise.

Выделим приватный метод _read, который в случае ошибки будет создавать объект описания ошибки. Использоваться он должен исключительно в методе _beforeMount().

Публичный метод read будет использоваться в любых других случаях и будет возвращать реальный результат запроса из БЛ.

read(param) {
   this._hideError();
   return this._read(param).then((result) => {
      if (this._error) {
         return Promise.reject(result.error);
      }
      return result.data;
   })
}
private _read(param) {
   return this._source.read(param).then(
      (data) => {
         this._data = data;
         return { data };
      },
      (error) => this._processError(error).then(
         (errorConfig) => ({ errorConfig, error })
      )
   );
}
private _processError(error) {
   return this._errorController.process(error).then((errorViewConfig) => {
      this._showError(errorViewConfig);
      return errorViewConfig;
   });
}

Далее в методе _beforeMount() на сервисе представления будем сохранять в recievedState лишь объект описания ошибки для получения его на клиенте, если она произошла:

protected _beforeMount(options, context, receivedState) {
   if (!receivedState) {
      return this._read().then((rawData) => {
         delete rawData.error;
         return rawData;
      });
   }
   if (recievedState.errorConfig) {
      return this._showError(recievedState.errorConfig);
   }
   return recievedState.data;
}

Дружелюбные ошибки в ответе диспетчера

При обработке ошибок на стороне сервера можно воспользоваться методом выше. Но тогда пользователь получит относительно тяжёлую страницу с платформенными зависимостями и кодом 200. Если вам нужен стандартный шаблон заглушки и его нужно показать на весь экран, то можно отдать пользователю легкую страницу с заглушкой от диспетчера с соответствующим HTTP-кодом. Для этого нужно воспользоваться функцией Parking.routing из библиотеки SbisEnvUI/Maintains.

Функция работает только на стороне сервера. При выполнении в браузере она ничего не делает. Эту функцию удобно использовать вместе с Controls/error:ErrorController:

errorController.process({error}).then((viewConfig) => {
    // Этим вызовом мы говорим, что в случае ошибки пользователю надо
    // отдать стандартную страницу с заглушкой и соответствующим кодом HTTP.
    Parking.routing(viewConfig);
    // ...
});

Для примера выше достаточно сделать так:

read(param) {
   this._hideError();
   return this._source.read(param).then((result) => {
      this._data = result;
      return result;
   },(error) => {
      this._processError(error);
   });
}
private _processError(error) {/*...*/}
private _showError(viewConfig) {
   Parking.routing(viewConfig);
   this._error = viewConfig;
}
private _hideError() {
   if (this._error) {
      this._error = null;
   }
}

4. Выбор режима отображения ошибки

По умолчанию все ошибки отображаются в режиме диалогового окна Controls/error:ErrorViewMode.dialog.

Чтобы контрол Container отобразил сообщение об ошибке внутри себя (вместо содержимого), необходимо при вызове обработки ошибки передать режим отображения Controls/error:ErrorViewMode.include.

private _read(param) {
   return this._source.read(param).then((result) => {
      this._data = result;
      return {
         data: result
      };
   }, this._processError.bind(this));
}
private _processError(error, mode) {
   return this._errorController.process({
      error,
      mode: mode || Mode.include
   }).then((errorConfig) => {
      this._showError(errorConfig);
      return {
         errorConfig,
         error
      };
   });
}

Тогда при возникновении ошибки содержимое контрола Container скрывается, и на его месте отображается шаблон ошибки.

Однако некоторые типы возникающих ошибок могут декларировать собственный режим отображения, и обработчик использует его как более приоритетный.

Теперь наш контрол:

  • не привязан к типам ошибок;
  • корректно строится на сервисе представления, если возникает ошибка получения данных;
  • не занимается отображением ошибок, но может контролировать режим отображения; например, при чтении — показать шаблон на весь контрол, при удалении/обновлении – диалог.

Создание собственного обработчика ошибки

Обработчик ошибки — это функция, которая принимает на вход ошибку и желаемый режим отображения, а возвращает объект, содержащий отображаемый шаблон, данные для шаблона и режим отображения.

type Handler = (config: {
   error: Error;
   mode: Mode;
}) => {
   template: string | TemplateFunction;
   options: object;
   mode?: Mode;
} | void;
function myHandler({mode, error}) {
   return {
      template: MyTemplate,
      options: {
         message: 'К сожалению, функционал для вас недоступен.',
         details: 'Оформите подписку и повторите попытку.',
         action: PaymentButton,
         image: 'https://i.pinimg.com/474x/54/5a/0f/545a0f6074c7a8eeeb396082c768952.jpg'
      }
   };
}

Если конкретный обработчик нацелен только на конкретный тип ошибки, то он должен проверять ошибку только на этот тип и ничего не возвращать, если тип ошибки не соответствует ожидаемому.

/**
 * Этот обработчик распознаёт только ошибки вызова метода myObject.method.
 */
function myHandler({mode, error}) {
   if (
      error instanceof RPCError &&
      error.methodName == 'myObject.method'
   ) {
      return {
         template: MyTemplate,
         options: {
            message: 'К сожалению, функционал для вас недоступен.',
            details: 'Оформите подписку и повторите попытку.',
            action: PaymentButton,
            image: 'https://i.pinimg.com/474x/54/5a/0f/545a0f6074c7a8eeeb396082c768952.jpg'
         }
      }
   }
};

Интерфейс параметра options тут полностью регламентируется возвращаемым шаблоном template.

Для корректного построения на сервере требуется, чтобы все возвращаемые данные поддавались платформенному механизму сериализации.

Выбор обработчика ошибки

При инициализации обработки ошибки, Controller обходит зарегистрированные обработчики, пока один из них не вернёт данные для отображения шаблона ошибки. Порядок выполнения обработчик такой:

  1. Обработчики, переданные в конструктор при создании экземпляра контроллера.
  2. Обработчики, добавленные в контроллер методом addHandler().
  3. Обработчики, переданные в конфигурацию приложения с сервиса представления.

Регистрация обработчиков

Для обработки ошибок, которые могут возникнуть только в вашем контроле, необходимо добавить обработчики в экземпляр Controller в вашем контроле:

/**
 * Controls/error:ErrorController
 */
_errorController = new ErrorController({
   handlers: [myHandler]
});

Или:

this._errorController.addHandler(myHandler);

Для регистрации обработчиков ошибок, которые могут возникнуть в любой точке вашего приложения, необходимо:

  1. Согласовать добавление обработчика ошибки на уровне приложения с Санниковым Кириллом.
  2. Добавить ваш обработчик в конфигурацию приложения:
import { constants } from 'Env/Env';
constants.ApplicationConfig.errorHandlers.push(myHandler);

Создание собственного шаблона "дружелюбной" ошибки на основе платформенного

В прикладных задачах может возникнуть потребность что-то изменить в некоторых сообщениях об ошибках: текст, изображение, кнопку-ссылку и т.д. Для этого не нужно создавать новый шаблон, достаточно взять готовые контролы и переопределить в них только нужные параметры.

Данные для шаблонов:

Готовые парковочные шаблоны, тексты сообщений, изображения и кнопки действий находятся в библиотеке SbisEnvUI/Maintains:Parking.

Для примера создадим обработчик, который распознаёт ошибку при проведении платежа, когда у пользователя недостаточно средств на счёте.

// Предположим, что у нас есть кнотрол-кнопка для повторения платежа.
import { TryAgainAction } from 'Payment/Page';
// Импортируем платформенные шаблоны и готовые тексты и картинки для них.
import { Parking } from 'SbisEnvUI/Maintains';
 
export function paymentErrorHandler({ error, mode }) {
    /**
     * Определяем ошибку по названию метода БЛ и уникальному коду ошибки.
     * PAYMENT_ERROR_CLASSID – некая прикладная константа
     * Можно задавать критерий для вашего специфического случая, например,
     * сравнение по error_code и прочее.
     */
    const isPaymentError = 
        error.method === 'Payment.Pay' &&
        error.classid = PAYMENT_ERROR_CLASSID;
 
    // Если это не нужная нам ошибка, то возвращаем undefined,
    // контроллер ошибок продолжит выполнение других обработчиков.
    if (!isPaymentError) {
        return;
    }
 
    // Режим отображения передаётся обработчику в аргументах,
    // получим готовый платформенный шаблон для этого режима.
    const template = Parking.templates.getBaseTemplateForMode(mode);
 
    // Соберём параметры для шаблона.
    const options = {
        // Возьмём готовую картинку для ошибки 500
        image: Parking.Const.IMAGE.INTERNAL,
        // Сообщение напишем своё. Здесь нужно сообщить пользователю, что произошло.
        message: rk('Платёж не был проведён.'),
        // В пояснении нужно указать, что делать пользователю для исправления ошибки.
        details: rk('Пополните свой счёт, на нём недостаточно средств.'),
        // Ни одна из платформенных кнопок-действий из Parking.actions нам не подходит,
        // используем свой контрол.
        action: TryAgainAction
    };
 
    return { template, options };
}

В следующем примере мы сами покажем диалоговое окно с ошибкой "документ не найден".

import { DialogOpener } from 'Controls/popup';
import { ErrorViewMode } from 'Controls/error';
// Импортируем платформенные шаблоны и готовые опции для них.
import { Parking } from 'SbisEnvUI/Maintains';

const dialogOpener = new DialogOpener() 
/**
* Это приватный метод какого-то нашего контрола.
*/
private _showErrorDialog(): Promise<string | void> {
    // Открываем диалоговое окно.
    return dialogOpener.open({
        // Получим готовый платформенный шаблон для диалога.
        template: Parking.templates.getBaseTemplateForMode(ErrorViewMode.dialog),
        // Возьмём готовые опции (картинку, тексты, кнопку действия) для ошибки 404.
        // При необходимости можно перезаписать некоторые опции из готового набора,
        // но в этом примере мы всё оставим без изменений.
        templateOptions: Parking.options.notFound,
        opener: this
    });
}

Вопрос-ответ

  1. Как не отображать диалоговое окно или не заменять контент в случае специфичной ошибки в платформенном контроле, например в Controls/form:Controller?
    Необходимо добавить свой обработчик, который в случае вашей специфичной ошибки будет бросать ошибку PromiseCanceledError, или возвращать Promise, завершённый с такой ошибкой. Бросание ошибки PromiseCanceledError сработает также в случае, если ошибка обрабатывается синхронно методом ErrorController.processSync().
import { PromiseCanceledError } from 'Types/entity';
 
function mySyncHandler({ error, mode }) {
   const MY_ERROR_CODE = 55555;
   if (error.status === MY_ERROR_CODE ) {
      // Ошибку с таким кодом обрабатывать не надо.
      // Бросаем специальное исключение, чтоб прервать выполнение цепочки обработчиков.
      throw new PromiseCanceledError();
   }
}
 
function myAsyncHandler({ error, mode }) {
   const MY_ERROR_CODE = 55555;
   if (error.status === MY_ERROR_CODE ) {
      return Promise.reject(new PromiseCanceledError());
   }
}
  1. Почему не сделать контейнер, который ловит события с ошибкой, а надо вызвать руками обработку?
    Всплытие событий не работает при серверном построении контрола.
  2. Почему не сделать обработку ошибки фазой жизненного цикла?
    Нецелесообразно: набор контрол, взаимодействующих с сервисом, сильно ограничен.
  3. Почему регистрация обработчиков в виде массива, а не Map|HashMap?
    Непонятно, что должно быть "ключом" в структуре, по которой потом необходимо произвести поиск подходящего обработчика по ошибке.

Полный листинг примера

Handler

import { Parking } from 'SbisEnvUI/Maintains';
import { RPC } from 'Browser/Transport';
let getBaseTemplateForMode = Parking.templates.getBaseTemplateForMode;
let RPCError = RPC.Error;
 
let myHandler = ({mode, error}) => {
   if (
      error instanceof RPCError &&
      error.methodName == 'myObject.method'
   ) {
      return {
         template: getBaseTemplateForMode(mode),
         options: {
            message: 'К сожалению функционал для вас недоступен',
            details: 'Оформите подписку и повторите попытку',
            image: 'https://i.pinimg.com/474x/54/5a/0f/545a0f60746c7a8eeeb396082c768952.jpg'
         }
      }
   }
};
export { myHandler }

Template

<!-- WML -->
<div class="MyComponent">
   <h1>Мой чудный контрол</h1>
   <Controls.dataSource:error.Container
      name="error.Controller"
         viewConfig="{{ __error }}" >
            Контент контрола, который будет скрыт, при возникновении ошибки
   </Controls.dataSource:error.Container>
</div>

Module

import { myHandler } from './handler';
import * as template from 'wml!myModule';
import { SbisService as Source } from 'Types/source';
import {Control} from 'UI/Base';
import { ErrorController, ErrorViewMode } from 'Controls/error';
 
class MyComponent extends Control {
   _source = new Source(/* ... */);
   _template = template;
    
   /**
    * Controls/error:ErrorController
    */
   _errorController = new ErrorController({
      handlers: [myHandler]
   });
    
   _beforeMount(options, context, receivedState = {}) {
      let { errorConfig, data } = receivedState;
       
      if (errorConfig) {
         return this._showError(errorConfig);
      }
      if (data) {
         return data;
      }
      return this._read().then((rawData) => {
         delete rawData.error;
         return rawData;
      });
   }
    
   /**
    * Метод чтения данных
    * @param {*} [param]
    * @return {Promise.<Types/entity:Model>}
    */
   read(param) {
      this._hideError();
      return this._read(param).then(getData);
   }
   delete() {
      this._source.delete().catch(this._onReject.bind(this))
   }
   _read(param) {
      return this._source.read(param).then((result) => {
         this._data = result;
         return {
            data: result
         };
      }, this._processError.bind(this));
   }
   _processError(error, mode) {
      return this._errorController.process({
         error: error,
         mode: mode || ErrorViewMode.include
      }).then((errorViewConfig) => {
         this._showError(errorViewConfig);
         return {
            errorConfig: errorViewConfig,
            error: error
         };
      });
   }
   _onReject(error) {
      return this._processError(error).then((rawData) => {
         if (rawData.error) {
            return Promise.reject(rawData.error);
         }
         return rawData.data;
      });
   }
   _onResolve(result) {
      this._data = result;
      return {
         data: result
      };
   }
   _showError(config) {
      this.__error = config;
      this._forceUpdate();
   }
   _hideError() {
      if (this.__error) {
         this.__error = null;
         this._forceUpdate();
      }
   }
}
 
export { MyComponent };