Перейти к содержимому

Ошибки

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));
});
cancel
zone: DatabaseStopped
outcome: Cancelled(manual)

Ничего не сломалось, а зона сообщает о сбое при каждой отмене. Вот что происходит:

  1. Задача принимает отмену, и onCancel отменяет токен. С этого момента она кончится Cancelled, что бы ни сделала миграция.
  2. Миграция останавливается по токену и бросает свой DatabaseStopped, а join бросает его как есть. Тело падает с этой ошибкой на уже отменённой задаче.
  3. Исходом эта ошибка не становится: исход несёт отмену. Задача отвечает за неё, как за любой провал своего тела, который исход не несёт: её услышал бы onError наблюдателя, а дальше она идёт к ответу, и без наблюдателя ответ — зона.

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

Ошибкой отмены называется ошибка, которую операция бросает потому, что её остановила отмена задачи: DatabaseStopped от токена, который отменил ctx.onCancel, или DioException из dio, для которой выполняется CancelToken.isCancel. Приложение называет такие ошибки один раз, в main:

Job.isCancellationError = (error) => error is DatabaseStopped;
cancel
outcome: 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()),
);
cancel
onError: Bad state: database locked
zone: Bad state: database locked
outcome: 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 offline
zone: Bad state: analytics offline

ctx.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 offline
onUnanswered: 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 offline
onUnanswered: Bad state: analytics offline

JobObserver.all передаёт каждый хук каждому наблюдателю в порядке списка, и каждый вызов идёт сам по себе: наблюдатель, который бросил исключение, не выключает следующего. Наблюдатель с JobAnswerer тоже смотрит на своём месте в списке и отвечает за весь список, когда onError услышали все. Если JobAnswerer в списке нет, ошибки уходят в зону, как у наблюдателя, который не отвечает. Если отвечающих двое или один и тот же наблюдатель стоит дважды, JobObserver.all бросает ArgumentError. Наблюдатель, собранный JobObserver.all, может стоять в списке другого и отвечает там, если отвечает кто-то внутри него.