ATL-2026-007architectureexperimental

Ошибка поймана. Кто решает, что делать дальше?

Почему перехват исключения не определяет исход сохранения. Dart-эксперимент с ark_error_manager разделяет диагностику, пользовательское уведомление и решение о повторе.

Опубликовано
16 сентября 2026 г.
Проверено
14 сентября 2026 г.

Ошибка поймана. Кто решает, что делать дальше?

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

Но что именно не удалось в первый раз? Передать запрос? Применить изменение? Получить ответ об уже выполненном сохранении? Для пользователя это одна неудачная попытка. Для приложения — несколько ситуаций с разными последствиями.

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

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

Одинаковая ошибка, разные данные

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

Существенная часть метода выглядит так; mode задаёт поведение хранилища, а value представляет сохранённое значение:

Future<bool> save(String input) async {
  requests++;
  if (mode == Mode.rejected) return false;
  if (mode == Mode.beforeCommit) throw TransportFailure();
  value = input;
  if (mode == Mode.afterCommit) throw TransportFailure();
  return true;
}

В двух ветках вызывающая сторона получает один и тот же тип TransportFailure. В первой хранилище ещё содержит старые данные. Во второй — уже новые. Приложение не видит внутренний mode: знание о нём есть только у проверки.

Поэтому результат на клиенте содержит три значения:

enum Outcome { saved, rejected, unknown }

saved означает полученное подтверждение сохранения. rejected — определённый контрактом отказ. unknown — отсутствие достаточных сведений об исходе. Последний вариант не утверждает ни успех, ни откат. Это не дополнительная разновидность сетевой ошибки, а граница знания вызывающей стороны.

Из неё следует и ограничение для интерфейса. Формулировка «Не удалось подтвердить сохранение» точнее, чем безусловное «Данные не сохранены». Дальнейшее действие зависит от возможностей операции: сверить состояние, запросить статус по идентификатору или повторить запрос, если контракт допускает такой повтор. В примере автоматического повтора нет; это выбранная граница проверки, а не рекомендация запрещать повторные запросы вообще.

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

Кто сохраняет смысл операции

Компонент, выполняющий сохранение, знает введённые данные и ожидаемый результат. В примере он называется SaveFlow: это обычный объект с методом run, без привязки к конкретному способу организации Flutter-экранов. Он вызывает хранилище, фиксирует исход и учитывает необходимость локального уведомления.

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

Ожидаемое транспортное исключение перехватывается рядом с вызовом. Введённое значение остаётся у SaveFlow, исход становится unknown, а диагностическому механизму передаются исключение, стек и имя операции. Содержимое профиля в этот вызов не включается. Это ограничивает передаваемые данные, но не заменяет отдельную проверку всей системы на утечки персональной информации.

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

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

Диагностика и восстановление в одном механизме

Централизованную обработку можно собрать собственными адаптерами или использовать инструмент с раздельными обязанностями. В эксперименте подключён ark_error_manager 1.0.0 из ArkTelos. Его роль здесь конкретна: принять диагностическое событие и выполнить заданную приложением политику, не подменяя исход сохранения.

Пакет не ограничивается logger. ErrorPolicy выбирает план действий; ErrorReporter записывает или передаёт подготовленный отчёт; ErrorPresenter отвечает за пользовательский показ. ErrorRecoveryController выполняет восстановление, определённое приложением. В API есть директивы сброса области, состояния приложения и завершения — их наличие не означает, что пакет самостоятельно знает, когда такие действия допустимы.

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

ErrorActionPlan(
  reporting: ErrorReportingDirective.record,
  presentation: ErrorPresentationDirective.none,
  recovery: ErrorRecoveryDirective.none,
)

Запись требуется, дополнительный показ и глобальное восстановление — нет. Локальное уведомление остаётся у операции. Это не стандартная политика на все случаи: неожиданная ошибка другого сценария может потребовать иного плана. Важно, что выбор сделан явно, а не получился случайно из порядка подписчиков.

Здесь также различаются два результата вызова менеджера. captureError сообщает, принят ли инцидент в обработку. Значение true не подтверждает доставку отчёта и тем более сохранение профиля. handleError позволяет дождаться выполнения запланированных действий, но тоже не заменяет результат бизнес-операции. Контракты этих методов можно посмотреть в закреплённой реализации.

Откуда берутся два сообщения

В исправленном варианте операция учитывает одно локальное уведомление, а политика менеджера выбирает presentation: none. Для отрицательного контроля политика меняется на passive: подключённый ErrorPresenter тоже получает вызов показа.

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

В опыте уведомления представлены счётчиками, а не настоящими snackbar. Тем не менее причина дублирования наблюдаема: изменение одной политики добавляет второй вызов. Возврат к none убирает его, не отключая диагностику.

Это не универсальная дедупликация. Если приложение отправит один инцидент в менеджер несколько раз, данный опыт не обещает объединить записи. Он проверяет более узкое правило: для конкретного исхода выбран один владелец пользовательского сообщения. Правила повторной доставки и существования экрана потребуют отдельной проверки.

Если сломалась сама диагностика

Ещё один случай заставляет ErrorReporter выбросить исключение вместо успешной записи. Отдельный ErrorPipelineFailureHandler получает отказ диагностической обработки. В результате остаются один запрос сохранения, одно локальное уведомление и исход unknown; дополнительно фиксируется один вызов резервного обработчика.

Ошибка отчёта не делает сохранение успешным и не запускает его заново. При этом было бы неправильно написать, что диагностика «ничего не потеряла»: reporter не завершил работу, и успешной доставки отчёта опыт не подтверждает. Проверено наличие отдельного пути реакции на отказ одной стадии.

В последнем случае менеджер закрывается до сохранения. captureError возвращает false, а вызывающий код учитывает отказ приёма. Это полезнее молчаливого предположения, что любой вызов менеджера обязательно будет обработан. Но проверка закрытого менеджера ничего не доказывает о переполнении очереди, долговечном хранении или аварийном завершении процесса.

Что именно воспроизведено

На Dart 3.12.2, macOS arm64 выполнены восемь случаев, каждый дважды в одном процессе. Итоговые данные совпали; сохранённый вывод содержит исходы, состояние хранилища и счётчики. Анализатор завершился без замечаний.

Случай Исход клиента Значение в хранилище Уведомления
Подтверждённое сохранение saved новое 1
Бизнес-отказ rejected старое 1
Ошибка до записи unknown старое 1
Запись с потерей ответа unknown новое 1
Отказ reporter unknown старое 1
Только локальный показ unknown старое 1
Локальный и глобальный показ unknown старое 2
Закрытый менеджер unknown старое 1

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

После двух транспортных отказов проверка дополнительно читает текущее значение хранилища. В одном случае оно старое, в другом новое. Исторический unknown при этом не переписывается. Успешное чтение здесь задано тестовым адаптером; нет конкурирующих изменений, устаревшей реплики или нового сетевого отказа. В реальной системе совпадение значения с отправленным ещё не доказывает, что именно этот запрос его записал.

Где в этой картине Flutter

Глобальные точки перехвата нужны, но решают другую задачу. Flutter направляет ошибки, пойманные framework, в FlutterError.onError; для необработанных ошибок вне этих callback предусмотрен другой путь. Это различие разобрано в официальной документации.

PlatformDispatcher.onError относится к необработанным ошибкам корневого isolate. Ошибки дочерних isolates не поступают туда напрямую. Возвращаемое значение сообщает об обработке ошибки этому механизму, а не об успешном завершении пользовательского действия. Ограничения описаны в контракте API.

ark_error_manager_flutter связывает эти точки с менеджером. Его binding по умолчанию сохраняет предыдущие обработчики. При подключении существующего crash-reporting нужно проверить цепочку: сохранение прежнего обработчика может быть намеренным, но не даёт автоматической гарантии единственной отправки отчёта. Flutter-интеграция здесь рассмотрена по коду и документации, не исполнена в описанном Dart-примере.

С какого вопроса начать ревью

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

Если ответы существуют только внутри общего catch, стоит проверить, действительно ли там есть необходимые данные. Если же общий механизм получает полный контекст и выполняет явно выбранную политику, переносить его логику по экранам ради формальной «локальности» незачем.

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


Другие инженерные разборы: ArkTelos Lab. Пакеты и новости проекта: официальный сайт ArkTelos.

Каналы: Lab RU | Lab EN | ArkTelos RU | ArkTelos EN.

Границы результата

  • Управляемое хранилище и счётчики вместо сети и UI. Восемь случаев, два прохода в одном процессе; S03 и S06 повторяют базовый режим. Не проверялись конкурентные записи, Flutter hooks, сбой процесса, переполнение и безопасность повторов. Нового прогона при сборке пакета не было.

CODE / DATA / AGENTS

Связанные эксперименты

experimentglobal-error-handler-boundary

Проверить неизвестный исход сохранения, владение уведомлением и отказ диагностики.

Управляемое хранилище и счётчики вместо сети и UI. Восемь случаев, два прохода в одном процессе; S03 и S06 повторяют базовый режим. Не проверялись конкурентные записи, Flutter hooks, сбой процесса, переполнение и безопасность повторов. Нового прогона при сборке пакета не было.

Открыть паспорт эксперимента →

Версия в Telegram

Читать в ArkTelos Lab

Читать в ArkTelos Lab ↗

КАНАЛЫ ARKTELOS

Новости и инженерные материалы — без смешивания языков.

Основные каналы рассказывают о развитии ArkTelos. ArkTelos Lab публикует архитектурные разборы, эксперименты и воспроизводимые исследования.