Ошибки
JobObserver слышит ошибки, которых не несёт ни один исход, как показывает
страница «Наблюдение». Эта страница прослеживает ошибку
от задачи до того, кто за неё отвечает. Ошибка, которую ловит задача, проходит
эти места по порядку и не идёт дальше первого, которое её забирает. Отмена,
которую ловит задача, не идёт дальше onError.
| Место | Что оно делает с ошибкой | Раздел |
|---|---|---|
onError |
наблюдатель её слышит и никуда не переносит | «С наблюдателем» |
| Исход | забирает провал, который несёт Failed, когда этот исход кто-то наблюдает |
«С наблюдателем» |
| Фильтр | забирает ошибку, которую называет Job.isCancellationError, когда задача приняла отмену |
«Ошибки отмены» |
| Ответ | onUnanswered наблюдателя, который подмешивает JobAnswerer, забирает ошибку или отправляет её дальше в зону |
«Ответ за ошибки» |
| Зона | зона, где задача создана, получает ошибку как неперехваченную | «Куда уходят ошибки» |
У задачи без наблюдателя, который отвечает, ответ — это зона. ignoreFailure()
у задачи забирает провал её тела сразу после onError, даже когда задача
кончается Cancelled, как показывает раздел
«Куда идёт каждая ошибка». Свой
движок всё же может пронести пойманную отмену дальше onError: он кончает
задачу Failed с ней, как показывают две последние строки
«Редких маршрутов».
Приложение, которому нужно лишь сообщать о каждой ошибке один раз, начинает со следующего раздела: «Сообщать о каждой ошибке один раз». Страница «Правила ошибок» продолжает эту: через какие места проходит ошибка каждого вида, отладочный канал, ошибка, в которой несколько провалов, и редкие случаи.
Строки под кодом — то, что код печатает:
| Строка | Что это |
|---|---|
cancel |
пользователь отменяет задачу |
outcome: |
то, чем завершается job.done |
onError: |
ошибка, которую слышит наблюдатель |
onUnanswered: |
ошибка, за которую наблюдатель ответил |
zone: |
ошибка, дошедшая до зоны неперехваченной |
Сообщать о каждой ошибке один раз
Заголовок раздела «Сообщать о каждой ошибке один раз»Приложение, которое сообщает об ошибках, хочет услышать каждую один раз. Все их забирают два места, которые не пересекаются: наблюдатель, который отвечает за ошибки, и зона, в которой идёт приложение.
final class Reports extends JobObserver with JobAnswerer { @override void onUnanswered(Job<Object?> job, Object error, StackTrace stackTrace) => print('report: $job: $error');}runZonedGuarded(startApp, (error, stackTrace) => print('report: $error'));startApp — функция, которая запускает приложение, во Flutter, например,
() => runApp(const App()). Каждая корневая задача получает
observer: Reports(), и её дети его наследуют. Кто о чём сообщает:
| Ошибка | Кто сообщает |
|---|---|
| Провал, исход которого никто не наблюдал | Reports, и он называет задачу |
| Провал, исход которого кто-то наблюдал | никто: он у кода, который его наблюдал |
| Провал ребёнка, который родитель выпустил из тела | Reports, один раз, как провал родителя, если родителя никто не наблюдал |
| Ошибка, которой не несёт ни один исход, например ошибка тела отменённой задачи, уборки, колбэка, действия, которое задача перестала ждать | Reports, и он называет задачу |
| Ошибка, которую приложение бросает вне своих задач | зона |
Reports отвечает за все ошибки задач, которых больше ни у кого нет, поэтому
они останавливаются на нём и до зоны не доходят; в зону уходит то, что
приложение бросает вне своих задач. Зона задачи не знает, поэтому её отчёт
задачу не называет. onError в отчёте не участвует: провал, который он слышит,
может попасть и в отчёт, а один провал он слышит по разу на каждой задаче,
исходом которой этот провал стал. Когда исход считается наблюдаемым, сказано
ниже, в разделе «С наблюдателем». Все маршруты ошибки
показывают разделы
«Куда идёт каждая ошибка»
и «Редкие маршруты» страницы о правилах
ошибок.
Куда уходят ошибки
Заголовок раздела «Куда уходят ошибки»Приложение сообщает о каждой ошибке, которая дошла до его зоны неперехваченной.
Задача мигрирует базу, а отмена останавливает миграцию по её токену, как
в разделе «Токен через onCancel»
страницы об отмене. Пользователь отменяет, пока миграция идёт.
Первая попытка
Заголовок раздела «Первая попытка»Dart отправляет в зону ошибку, которую никто не поймал, поэтому приложение полагается на зону, наблюдателя задаче не даёт и связывает токен так же, как страница об отмене:
// Первая попытка: каждая отмена доходит до зоны как сбой.final job = Job<void>((ctx) async { final stop = CancelToken(); ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop));});cancelzone: DatabaseStoppedoutcome: Cancelled(manual)Ничего не сломалось, а зона сообщает о сбое при каждой отмене. Вот что происходит:
- Задача принимает отмену, и
onCancelотменяет токен. С этого момента она кончитсяCancelled, что бы ни сделала миграция. - Миграция останавливается по токену и бросает свой
DatabaseStopped, аjoinбросает его как есть. Тело падает с этой ошибкой на уже отменённой задаче. - Исходом эта ошибка не становится: исход несёт отмену. Задача отвечает
за неё, как за любой провал своего тела, который исход не несёт: её
услышал бы
onErrorнаблюдателя, а дальше она идёт к ответу, и без наблюдателя ответ — зона.
Задача не может знать, что это за ошибка. Здесь операция остановилась по токену задачи. Но настоящий сбой после отмены идёт тем же путём, и его услышать нужно: если база после отмены оказалась заблокирована, приложение должно об этом узнать.
Ошибки отмены
Заголовок раздела «Ошибки отмены»Ошибкой отмены называется ошибка, которую операция бросает потому, что её
остановила отмена задачи: DatabaseStopped от токена, который отменил
ctx.onCancel, или DioException из dio, для которой выполняется
CancelToken.isCancel. Приложение называет такие ошибки один раз, в main:
Job.isCancellationError = (error) => error is DatabaseStopped;canceloutcome: Cancelled(manual)Задача осталась прежней. Job.isCancellationError спрашивают о каждом провале
задачи, которая приняла отмену. Спрашивают, когда задача решает, попросит ли
она ответа за провал, и только тогда. Об ошибке, которой не несёт ни один
исход, и о провале тела задачи, которая уже отменена, когда тело бросает, это
происходит до onError, и фильтр, поставленный после, к этому провалу
не применяется. Исключение — переполнение стека, которое тело бросило до того,
как вернулся его старт: о нём спрашивают, когда задача кончится, а если оно
оборвало и старт, не спрашивают вовсе. Названная фильтром ошибка
останавливается на onError и исхода не меняет. О провале задачи, которую
никто не отменял, его не спрашивают: тело, которое бросает DatabaseStopped,
пока его задача идёт дальше, кончается с ним Failed, и если этот исход никто
не наблюдает, он идёт к ответу. Токен тогда отменил кто-то другой, и задача
должна об этом сказать.
Фильтр вызывается синхронно, с одним провалом за раз и никогда с Cancelled,
поэтому пусть он только проверяет тип. Задача обходит ParallelWaitError
и спрашивает о каждом провале внутри него отдельно. Если фильтр бросил, это
ошибка приложения: она уходит в зону, где создана задача, — раньше, чем
onError услышит провал, если фильтр спросили первым, — а провал, о котором
его спрашивали, идёт дальше к ответу.
Поле принадлежит приложению, как Job.debug, и библиотека его не ставит. Если
свои ошибки называют две части приложения, вторая продолжает цепочку первой:
final previous = Job.isCancellationError;Job.isCancellationError = (error) => error is DatabaseStopped || (previous?.call(error) ?? false);Тест, который ставит поле, возвращает его в tearDown, как вернул бы
Job.debug.
С наблюдателем
Заголовок раздела «С наблюдателем»Ещё приложение хочет видеть ошибки своих задач по мере того, как они случаются. Задача открывает базу, пользователь отменяет, пока она открывается, а затем открытие падает: база заблокирована.
final class Reporter extends JobObserver { @override void onError( Job<Object?> job, Object error, StackTrace stackTrace, { required JobErrorAnswer answer, }) => print('onError: $error');}
final job = Job<Database>( observer: Reporter(), (ctx) => ctx.join(Database.open, discard: (database) => database.close()),);cancelonError: Bad state: database lockedzone: Bad state: database lockedoutcome: Cancelled(manual)Наблюдатель слышит через onError ошибки, которые ловит его задача, кроме её
собственной отмены. Но только слышит: переопределение onError никуда ошибку
не переносит. Упавшее открытие всё равно доходит до зоны, как дошло бы без
наблюдателя: задача кончается Cancelled, а фильтр выше StateError
не называет.
Провал, который несёт исход, идёт к ответу через микрозадачу после конца
задачи, если к тому времени исход никто не наблюдал, и наблюдатель, который
отвечает, получает его в onUnanswered. Исход считается наблюдаемым, когда
кто-то:
- читает
doneилиvalue; - вызывает
ignoreFailure(); - передаёт провал дальше через
then. Продолжение, которое отменили раньше, чем упал его источник, ничего не передаёт, и провал остаётся ненаблюдаемым.
Об этом раздел
«Ошибка, которую никто не ждёт»
страницы об исходах. Хуки наблюдателя исход не наблюдают, даже хук, который
читает job.outcome, как onFinish у Log из раздела
«Наблюдатель».
Работа, которую задача не ждёт
Заголовок раздела «Работа, которую задача не ждёт»Задача отправляет событие аналитики и не ждёт его. Отправка падает: аналитика
недоступна. У задачи Reporter из раздела «С наблюдателем».
Первая попытка
Заголовок раздела «Первая попытка»unawaited из Dart помечает future, которую намеренно не ждут:
// Первая попытка: ошибка уходит в зону, а не в задачу.unawaited(analytics.send('loaded'));outcome: Done(null)zone: Bad state: analytics offlineНаблюдатель не слышит ничего, и ничто не связывает ошибку с задачей. Ошибка
future, которую никто не ждёт, уходит в зону как неперехваченная. unawaited
лишь говорит анализатору, что future не ждут намеренно.
Работа, переданная задаче
Заголовок раздела «Работа, переданная задаче»ctx.unattended(() => analytics.send('loaded'));outcome: Done(null)onError: Bad state: analytics offlinezone: Bad state: analytics offlinectx.unattended(action) оставляет ошибки работы при задаче, даже те, что
приходят, когда задача уже кончилась. Их слышит наблюдатель, а дальше они
уходят в зону, где задача создана, если наблюдатель за них не отвечает. Без
наблюдателя они уходят туда сразу. Задача работу не ждёт и не отменяет.
Запускайте работу внутри колбэка и ничего из него не выносите. Работа идёт в собственной зоне ошибок, и граница действует в обе стороны:
- Future, созданная снаружи и ожидаемая внутри, при ошибке не возвращается никогда, а её ошибка уходит в зону, где создана сама future.
- Future, созданная внутри и ожидаемая снаружи, при ошибке вешает того, кто её ждёт. Если её ждёт тело, зависает задача.
Задача, запущенная внутри колбэка, границе не подчиняется: её тело идёт в зоне, откуда начата работа. У неё свои исход и наблюдатель, и её провал, который никто не наблюдает, идёт к ответу её собственного наблюдателя или в ту же зону, а не к наблюдателю этой задачи. Для работы внутри работы другой задачи это зона, откуда начата внешняя работа.
ctx.abandonable и ctx.join работают внутри колбэка на условиях тела, даже
после конца тела. В задаче, которая приняла отмену, значение освобождается,
а вызов бросает отмену. В задаче, которая уже кончилась, значение с dispose
или discard освобождается, и вызов бросает StateError, а не отдаёт его
закрытым.
Опрос ctx.check() не способ остановить такую работу вместе с задачей.
В задаче, которую никто не отменял, он бросает StateError, пока ядро
разматывает стек уборки, а когда задача кончилась Done, не бросает вовсе, так
что цикл, который его ждёт, держит задачу вечно. Спрашивайте job.isFinished
или ждите job.done.
Ответ за ошибки
Заголовок раздела «Ответ за ошибки»Ошибки, которых не несёт ни один исход, идут от onError дальше, к ответу,
и туда же идут провалы, чей исход никто не наблюдает. Наблюдатель, который
только смотрит, например Reporter, ответа не даёт, поэтому эти ошибки уходят
в зону. Наблюдатель, который отвечает за них сам, подмешивает JobAnswerer
и переопределяет onUnanswered:
final class Answering extends JobObserver with JobAnswerer { @override void onUnanswered(Job<Object?> job, Object error, StackTrace stackTrace) => print('onUnanswered: $error');}Там ошибки и останавливаются, до зоны они не доходят. onUnanswered
по умолчанию отправляет их в зону, где задача создана, как и без ответа.
Поэтому вызов super.onUnanswered(job, error, stackTrace) тоже отправляет
ошибку дальше в зону. Наблюдатель, который отвечает только за знакомые ему
ошибки базы данных, отдаёт остальное super:
final class DatabaseErrors extends JobObserver with JobAnswerer { @override void onUnanswered(Job<Object?> job, Object error, StackTrace stackTrace) { if (error is DatabaseException) { print('onUnanswered: $error'); } else { super.onUnanswered(job, error, stackTrace); } }}Переопределение отвечает за задачу, которой передали наблюдателя, и за дочерние
задачи, которые его наследуют, на любой глубине. Чтобы ответить за все задачи
сразу, приложение пользуется своей зоной, где и так сообщает о неперехваченном:
туда эти ошибки приносит onUnanswered, если его не переопределять. Зона
получает ошибку и её стектрейс, но не задачу, так что отчёту, который называет
задачу, нужно переопределение.
Переопределение, которое бросило, не уносит ошибку с собой. Ошибка уходит
в зону, где создана задача, а следом в текущую зону уходит то, что бросило
переопределение, как и у любого хука; брошенный им Cancelled в зону
не попадает, как и сама ошибка, брошенная обратно, так что ошибка уходит туда
один раз. Переопределение, которое успело сообщить об ошибке и только потом
бросило, сообщит о ней дважды: в свою систему и в зону.
За ошибку, в которой несколько провалов, например ParallelWaitError, отвечают
провал за провалом: об этом раздел
«Несколько провалов в одной ошибке»
страницы о правилах ошибок.
Несколько наблюдателей
Заголовок раздела «Несколько наблюдателей»Приложение отправляет всё, за что никто не ответил, в отчёт о падениях.
У задачи из раздела
«Работа, которую задача не ждёт» остаётся
и её Reporter. Отвечает наблюдатель Crashes:
final class Crashes extends JobObserver with JobAnswerer { @override void onUnanswered(Job<Object?> job, Object error, StackTrace stackTrace) => print('onUnanswered: $error');}Первая попытка
Заголовок раздела «Первая попытка»observer: принимает одного JobObserver, поэтому класс передаёт свои хуки
обоим, а ответ — Crashes:
// Первая попытка: каждый хук передаётся вручную и без try/catch.final class Both extends JobObserver with JobAnswerer { final _reporter = Reporter(); final _crashes = Crashes();
@override void onError( Job<Object?> job, Object error, StackTrace stackTrace, { required JobErrorAnswer answer, }) { _reporter.onError(job, error, stackTrace, answer: answer); _crashes.onError(job, error, stackTrace, answer: answer); }
@override void onUnanswered(Job<Object?> job, Object error, StackTrace stackTrace) => _crashes.onUnanswered(job, error, stackTrace);
// onStart, onFinish и onLog так же, как onError.}outcome: Done(null)onError: Bad state: analytics offlineonUnanswered: Bad state: analytics offlineЭто работает: Reporter слышит ошибку, Crashes за неё отвечает. Но каждый
хук передаётся руками: пять методов на два наблюдателя, а третий наблюдатель
добавит по строке в каждый. Для полноты каждый вызов надо ещё обернуть
в try/catch, иначе наблюдатель, который бросил исключение, выключает тех,
кто идёт после него.
Наблюдатели одним списком
Заголовок раздела «Наблюдатели одним списком»final job = Job<void>(observer: JobObserver.all([Reporter(), Crashes()]), ( ctx,) async { ctx.unattended(() => analytics.send('loaded'));});outcome: Done(null)onError: Bad state: analytics offlineonUnanswered: Bad state: analytics offlineJobObserver.all передаёт каждый хук каждому наблюдателю в порядке списка,
и каждый вызов идёт сам по себе: наблюдатель, который бросил исключение,
не выключает следующего. Наблюдатель с JobAnswerer тоже смотрит на своём
месте в списке и отвечает за весь список, когда onError услышали все. Если
JobAnswerer в списке нет, ошибки уходят в зону, как у наблюдателя, который
не отвечает. Если отвечающих двое или один и тот же наблюдатель стоит дважды,
JobObserver.all бросает ArgumentError. Наблюдатель, собранный
JobObserver.all, может стоять в списке другого и отвечает там, если отвечает
кто-то внутри него.