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

Ошибки

Задача, которая упала, кончается Failed. С её ошибкой имеют дело три части кода:

  • enqueue(ifFailed: ...) превращает провал в состояние, которое показывает экран;
  • onError, хук контроллера, слышит ошибку, чтобы о ней сообщить;
  • тот, кто наблюдает исход, отвечает за ошибку.

Когда исход никто не наблюдает, за ошибку отвечает onUnanswered. По умолчанию он отдаёт её в зону создания, то есть в зону, где задачу создали. Там она неперехваченная ошибка, а в корневой зоне неперехваченная ошибка может завершить приложение. Во Flutter-приложении она идёт дальше в PlatformDispatcher.onError, и остаток её пути описан в README пакета flutter_solo. У некоторых ошибок нет исхода, который бы их нёс, и за них тоже отвечает onUnanswered. Первый раздел, «Куда уходит каждая ошибка», перечисляет все виды.

Разделы страницы по тому, что нужно приложению:

Что нужно приложению Что это даёт Раздел
Отчёт о каждой ошибке задачи onError контроллера или SoloObserver для всех контроллеров «Отправка ошибки»
Провал, который не дойдёт до зоны Наблюдение исхода: await job.value там, где ошибку ловят, или job.ignoreFailure(); для ошибок, которых задача ждёт, — isExpectedError «Назвать ожидаемые ошибки»
Ответ за ошибку, которую не несёт ни один исход, или за Failed, который никто не наблюдает onUnanswered или Solo.unansweredHandler для всего приложения «Ответ за ошибку»
catch, который пропускает отмену ctx.check() первой строкой catch «Перехват ошибок внутри тела»
Новая попытка после провала ctx.pause между попытками или цикл у вызывающего кода «Повтор упавшего вызова»
Правило, которое отказывает Правило, которое отвечает false, а не бросает «Ошибки правил состояния»
Фоновая работа, чьи ошибки доходят до хуков ctx.unattended «Фоновая работа»

Строки под некоторыми блоками — то, что печатает код. report: — ошибка, переданная сервису отчётов о сбоях, а zone: — ошибка, которая дошла до зоны создания неперехваченной.

Ошибка задачи может идти двумя дорогами сразу, а future, которую отдаёт задача, добавляет третью, собственную дорогу Dart:

Дорога Кто получает ошибку, по порядку Где она кончается
Отчёт onError у SoloObserver, затем onError контроллера её слышат Здесь она не кончается: хуки слышат её и возвращаются, а ошибка идёт дальше по дороге ответа
Ответ Тот, кто наблюдает исход, потом onUnanswered контроллера; ошибку, которую задача назвала в isExpectedError, — только тот, кто наблюдает исход У того, кто наблюдает исход; иначе у переопределённого onUnanswered или у Solo.unansweredHandler; если нет ни того, ни другого, в зоне создания. Ошибка, которую задача назвала в isExpectedError и которую никто не наблюдает, не кончается нигде: ни в onUnanswered, ни в зоне
Dart Тот, кто держит future job.value или ctx.run(child) Там, где эту future обрабатывают; без обработки — в зоне, как любая ошибка future

Таблица ниже называет для каждого вида ошибки, кто её слышит и кто за неё отвечает. onError в ней — это оба хука: они слышат одни и те же ошибки. Ошибка, которую не несёт ни один исход, входит на дорогу ответа у onUnanswered и идёт оттуда дальше, как говорит строка «Ответ» выше. Обращение к job.value помечает исход наблюдённым: если эту future не обработали, её ошибка уходит дорогой Dart в зону, один раз, и onUnanswered её не получает.

Текущая зона — та, в которой позвали хук, то есть зона кода, который встретил ошибку. Тело корневой задачи и хуки, до которых оно доводит, идут в зоне, где задачу создали, а тело ребёнка — в зоне родителя, который его запускает. Колбэк ctx.onCancel идёт в зоне вызова, который отменяет задачу. Правило запуска спрашивает тот, кто двигает очередь: вызов, который ставит задачу в очередь, или задача перед ней, когда кончается. Изменение через externalSetState идёт в зоне того, кто его позвал. У дороги ответа своя зона: ошибка, за которую никто не ответил, уходит в зону создания, где бы её ни услышали хуки.

Ошибка Кто её слышит Кто за неё отвечает
Ошибка тела, и задача кончается Failed с ней onError Тот, кто наблюдает исход; если никто, onUnanswered, кроме ошибки, которую называет isExpectedError
Ошибка стартового правила: canStart или keepWhile, спрошенный на старте, бросил вместо ответа onError Так же: задача кончается Failed с ней
Ошибка тела, а задача кончается Cancelled onError onUnanswered, если у задачи не звали ignoreFailure() и её не называет isExpectedError
Поздняя ошибка операции, которую abandonable перестал ждать onError onUnanswered, если её не называет isExpectedError
Ошибка уборщика, колбэка ctx.onCancel или обработчика состояния задачи onError onUnanswered, если её не называет isExpectedError
Ошибка слушателя whenCancelled или whenFinished onError onUnanswered
keepWhile, который бросил, когда его перепроверяло изменение состояния onError onUnanswered, если ошибку не называет isExpectedError
Ошибка работы, отданной ctx.unattended onError onUnanswered, если её не называет isExpectedError
Ошибка ветки ctx.runAll, которую группа не бросила onError onUnanswered, если у ветки не звали ignoreFailure() и ошибку не называет isExpectedError
Ошибка, которую называет Job.isCancellationError, у отменённой задачи onError Никто
Ошибка, которую называет isExpectedError onError, с never Тот, кто наблюдает исход, который её несёт; больше никто: ни onUnanswered, ни Solo.unansweredHandler, ни зона
Cancelled снаружи тела, например от брошенной операции, которая кончилась отменой другой задачи onError Никто
Cancelled, который тело выпустило наружу, например отменённой дочерней задачи Никто Никто: задача кончается Cancelled с ним
Собственная отмена задачи, которую брошенная операция бросила обратно Никто Никто
Ошибка future, которую тело не ждало Никто Зона, где работает тело
Ошибка, которую бросил хук Никто Текущая зона
Ошибка, которую бросил слушатель при оповещении onListenerError onListenerError; по умолчанию текущая зона

Ошибка может дойти и до onError, и до зоны. Приложение, которое сообщает об ошибках в обоих местах, услышит её дважды. Раздел «Обработанные и необработанные ошибки» показывает, как вызывающий код наблюдает исход, а раздел «Ответ за ошибку» — переопределение, обработчик и Job.isCancellationError.

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

Дорога Dart в таблице выше — не дорога движка. Неперехваченная ошибка job.value или ctx.run(child) остаётся необработанной ошибкой future по правилам Dart, даже если это Cancelled. Обрабатывайте эти future как любые другие: ждите их через await там, где ошибку перехватят, или дайте им onError либо Future.ignore(). job.ignoreFailure() этого за них не сделает. Вызов говорит задаче, что её провал никому не нужен. Future, которую уже взяли у job.value, остаётся обычной future, и её ошибку всё равно надо обработать.

Контроллер профиля отправляет провалы своих задач в сервис отчётов о сбоях:

final class ProfileController extends Solo<ProfileState> {
final ProfileApi api;
ProfileController(this.api) : super(const Initial());
// ...load() контроллера профиля, без его `isExpectedError`...
@override
void onError(
Job<Object?> job,
Object error,
StackTrace stackTrace, {
required JobErrorAnswer answer,
}) => reportCrash(error, stackTrace);
}

У контроллера есть хук на каждое событие с ним самим и с его задачами: onStart, onFinish, onError, onUnanswered, onLog, onChange и onClose, а ещё onListenerError для слушателя, который бросил при оповещении.

onError слышит ошибки, на которые наткнулись задачи контроллера:

  • провал тела;
  • ошибку уборки или операции, которую abandonable перестал ждать;
  • правило, которое бросило вместо ответа.

Хук отправляет отчёт и возвращается. Он ни за что не отвечает, и его переопределение не двигает ни одну ошибку никуда. Его answer говорит, что задача сделает с ошибкой дальше: при answered о ней спросят и onUnanswered, если до того у задачи не позовут ignoreFailure(). Остальные значения разбирает раздел «Куда идёт каждая ошибка» страницы о правилах ошибок пакета async_job. Один случай never есть только у solo, и на той странице его нет: ошибка, которую задача назвала в isExpectedError, как показывает раздел «Назвать ожидаемые ошибки».

Он слышит каждую ошибку по одному разу на каждую задачу. Ошибка, которую бросила дочерняя задача и пропустила родительская, становится провалом обеих: хук слышит её дважды. Различить два вызова даёт переданная ему job. Сервис отчётов о сбоях, которого кормят отсюда, может получить одну ошибку трижды: от дочерней задачи, от родительской и, если исход родительской никто не наблюдает, из onUnanswered. По умолчанию onUnanswered отдаёт её Solo.unansweredHandler или в зону создания.

Экран запускает задачу профиля из раздела «Контроллер профиля» страницы о задачах, но без isExpectedError, и своего хука отчёта у профиля нет: profile.load(), и результата никто не ждёт. Сети нет, и fetchName бросает ApiException. Приложению нужно, чтобы провал попал в сервис отчётов о сбоях, и больше ничего.

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

// Первая попытка: сбои всё равно уходят в зону.
final class Failures extends SoloObserver {
@override
void onError(
Solo<Object> solo,
Job<Object?> job,
Object error,
StackTrace stackTrace, {
required JobErrorAnswer answer,
}) => reportCrash(error, stackTrace);
}
report: ApiException: no network
zone: ApiException: no network

Отчёт уходит, а следом та же ошибка доходит до зоны. Вот что происходит:

  1. Загрузка падает, и задача кончается Failed. onError наблюдателя слышит ошибку и отправляет отчёт.
  2. Хук, которому рассказали о провале, исход не наблюдает, какой бы это ни был хук. Исход загрузки никто не прочитал.
  3. Никто не отвечает и за этот Failed. onUnanswered контроллера отдаёт его Solo.unansweredHandler, а когда обработчика нет — в зону создания через Zone.handleUncaughtError. В корневой зоне это может завершить приложение через мгновение после того, как отправили отчёт.

Наблюдатель остаётся, а экран наблюдает исход:

profile.load().ignoreFailure();
report: ApiException: no network

Обращение к job.done или job.value, а также вызов job.ignoreFailure() помечают исход наблюдённым. Чтение job.outcome исход наблюдённым не делает. ignoreFailure() пишет вызывающий код, которому результат не нужен и который оставляет отчёты хукам. Тот, кому результат нужен, берёт его через await job.value и отвечает за ошибку, перехватывая её.

Одну ошибку такой код не перехватит. Если задача кончилась Cancelled после того, как загрузка упала, await job.value бросает Cancelled. Провал уходит в onUnanswered, как показывает раздел «Ответ за ошибку». ignoreFailure() гасит и этот провал: onError его по-прежнему слышит, а дальше он не идёт.

Профиль знает, каких ошибок ему ждать: когда сети нет, fetchName бросает ApiException. Его load() называет этот тип в isExpectedError:

Job<String> load() => enqueue<ProfileState, String>(
key: 'load',
policy: Policy.droppable,
ifFailed: (state, error, stackTrace) => Failure(error),
isExpectedError: (error) => error is ApiException,
ifCancelled: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
);

Наблюдатель из первой попытки остаётся, а экран зовёт profile.load(), и результата никто не ждёт:

report: ApiException: no network

onError слышит ошибку с JobErrorAnswer.never, а ответа за неё не просят ни у кого: ни у onUnanswered, ни у Solo.unansweredHandler, ни у зоны. Исход по-прежнему Failed, экран показывает Failure из ifFailed, а await job.value по-прежнему бросает эту ошибку.

Баг — не ApiException. Допустим, fetchName читает имя из ответа через !, а ответ приходит без имени: ! бросает TypeError, и эта ошибка пойдёт дорогой первой попытки:

report: Null check operator used on a null value
zone: Null check operator used on a null value

Предикат спрашивают о каждой ошибке задачи по отдельности, звали ifFailed или нет: о провале тела и об ошибках, которых не несёт ни один исход (их список — в разделе «Ответ за ошибку»), кроме ошибок слушателя. ParallelWaitError сначала разбирают на части, поэтому error is ParallelWaitError не совпадает никогда. Об отмене предикат не спрашивают: Cancelled и ошибка, которую называет Job.isCancellationError, идут как прежде. Предикат, который бросил, ничего не назвал: ошибка идёт дальше так же, как без него, а то, что бросил предикат, уходит в зону, где задачу создали.

Ребёнок, запущенный в теле, берёт предикат задачи, которая его запустила, если у него нет собственного, а собственный бывает только у ребёнка из job(...). Широкая проверка вроде error is Exception гасит и баги каждого ребёнка, поэтому держите предикат проверкой типов, которые бросает API.

Продолжение отвечает за ошибку своей ручкой. load() — droppable, поэтому отброшенный вызов возвращает задачу первого вызова вместе с её предикатом, а задачу droppable продолжают только через shared(): простой then на ней бросает. Ручка, которую возвращает shared(), и then на этой ручке ничего не знают об isExpectedError: без наблюдения они отдают провал в зону, как говорит раздел «Когда звено падает» страницы о цепочках пакета async_job.

Большую часть ошибок несёт исход: провал тела становится Failed, и за него отвечает тот, кто его наблюдает. Когда его никто не наблюдает, за ошибку отвечает в onUnanswered контроллер, чья это была задача. Там же он отвечает за ошибки, у которых нет исхода, который бы их нёс. Здесь профиль складывает их в список:

final class ProfileController extends Solo<ProfileState> {
// ...задачи и хук отчёта выше...
@override
void onUnanswered(Job<Object?> job, Object error, StackTrace stackTrace) =>
_apiFailures.add(error);
}

Вот ошибки, которых не несёт ни один исход:

  • операция, которую abandonable перестал ждать, если она упала позже;
  • то, что задача зовёт вне своего тела: уборщик, колбэк ctx.onCancel, обработчик состояния задачи;
  • слушатель whenCancelled или whenFinished, которого ставит вызывающий код задачи;
  • keepWhile, который бросил, когда его перепроверяло изменение состояния;
  • работа, отданная ctx.unattended;
  • провал ветки ctx.runAll, который группа не бросила;
  • провал тела задачи, которая кончается Cancelled: кто бы ни читал такой исход, он получает отмену.

Переопределение выше отвечает за них и за Failed, который никто не наблюдает. Дальше они не идут никуда. Этот контроллер владеет тем, на чём упали его задачи, и сказал об этом. Ошибка, которую задача назвала в isExpectedError, до переопределения не доходит, если её не бросил слушатель: слушатель — код вызывающего, и о его ошибках предикат не спрашивают.

У контроллера без такого переопределения остаётся тело по умолчанию. Оно передаёт ошибку в Solo.unansweredHandler, один обработчик на всё приложение, который ставится один раз при старте:

Solo.unansweredHandler = (solo, job, error, stackTrace) =>
Sentry.captureException(error, stackTrace: stackTrace);

Если обработчика нет, ошибка уходит в зону создания. Переопределение сохраняет и этот маршрут, если позовёт super.onUnanswered(job, error, stackTrace). Так каждый контроллер решает, услышит ли его ошибки обработчик приложения вообще.

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

onUnanswered и Solo.unansweredHandler получают по одному провалу за раз. ParallelWaitError, который [a, b].wait бросает с несколькими ошибками внутри, приходит несколькими вызовами, каждый провал со своим стеком. Неперехваченный Cancelled, отдельный или вложенный в него, не приходит вовсе. onError тоже слышит их по одному, и каждый Cancelled тоже.

Последний пункт списка чаще всего приходит от самой отмены. Вызов, которого ещё дожидается ctx.join, останавливается на токене, который отменил ctx.onCancel, и, останавливаясь, бросает. Отличить такую ошибку от сбоя вызова движок не может. Поэтому приложение один раз, при старте, называет такие ошибки в поле Job.isCancellationError ядра. Провал отменённой задачи, который оно называет, доходит до onError и дальше не идёт: его не слышат ни onUnanswered, ни Solo.unansweredHandler.

Раздел «Токен» страницы об отмене называет в Job.isCancellationError ошибку, которую плеер бросает, останавливаясь. Раздел «Ошибки отмены» страницы об ошибках пакета async_job разбирает поле. Тело, которое ловит такую ошибку, спрашивает у задачи, а не у ошибки: такой catch показан в разделе «Пропустить отмену дальше».

Ответить за ошибку — обязанность, которую кто-то берёт на себя сам. Включённый лог её не берёт, и поставленный SoloObserver тоже: наблюдать не значит отвечать.

Тело управляет камерой. При сбое устройства ему нужно собственное состояние, Broken, и камера, оставшаяся полуоткрытой, возвращённая в исходное положение.

Устройство может упасть где угодно, поэтому тело ловит всё:

// Первая попытка: отмена сбрасывает исправную камеру.
try {
await ctx.join(hw.open);
await ctx.join(() => hw.setZoom(zoom));
} on Object catch (error) {
await hw.reset();
ctx.emit(Broken(error));
rethrow;
}

Пользователь отменяет эту задачу, пока hw.setZoom в полёте. Ничего не упало, а камеру сбросили. Вот что происходит:

  1. join дожидается вызова и бросает Cancelled вместо его результата.
  2. Cancelled реализует Exception, поэтому широкий catch забирает его и читает как сбой камеры. Он сбрасывает камеру, которая открылась и приняла зум. Тот, кто отменил задачу, ждёт ещё и сброса.
  3. ctx.emit на отменённой задаче бросает Cancelled в свою очередь. Broken не доходит до экрана, а rethrow под ним не выполняется.
  4. Исход тот же Cancelled, который дал бы rethrow.

Задача кончается ровно так, как положено отменённой. Ни в хуках, ни в зоне о ней ни слова, а единственный след остаётся на устройстве.

try {
await ctx.join(hw.open);
await ctx.join(() => hw.setZoom(zoom));
} on Object catch (error) {
ctx.check();
await hw.reset();
ctx.emit(Broken(error));
rethrow;
}

ctx.check() спрашивает у задачи, а не у ошибки. Он бросает Cancelled самой задачи, если она приняла отмену, что бы ни поймал catch. Строки после него обрабатывают сбои той задачи, которую никто не отменял.

Секция, которая спрашивает у ошибки, умеет меньше. Возьмём on Cancelled { rethrow; } перед широкой секцией или if (error is Cancelled) rethrow; первой её строкой. Такая секция работает, пока каждый вызов в try идёт до конца, как эти два. Раздел «Токен» страницы об отмене подключает токен, который отменяет ctx.onCancel. Вызов, которому велит остановиться такой токен, может остановиться броском. Тогда catch получает вместо Cancelled ошибку вызова, потому что join бросает ошибку своего вызова как есть. Секция её пропускает, и камеру снова сбрасывают по отмене. Раздел «Перехват ошибок операции» страницы об отмене пакета async_job разбирает эту секцию.

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

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

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

Цикл ловит ошибку и ждёт перед следующей попыткой:

// Первая попытка: отмена дожидается конца задержек.
ctx.emit(const Loading());
for (var attempt = 1; ; attempt++) {
final String name;
try {
name = await ctx.abandonable(api.fetchName);
} on Object {
if (attempt == 3) rethrow;
await Future<void>.delayed(Duration(seconds: attempt));
continue;
}
ctx.emit(Loaded(name));
return name;
}

catch стоит здесь ради сети, а забирает и отмену. Пользователь отменяет задачу, пока первый вызов в полёте, и задача кончается через три секунды. Вот что происходит:

  1. abandonable бросает Cancelled. catch засчитывает это как неудачную попытку и ждёт секунду.
  2. Обычный Future.delayed не служит контрольной точкой, поэтому задача отсиживает эту секунду целиком.
  3. Следующий abandonable бросает сразу, не вызывая fetchName, и catch ждёт ещё две секунды.
  4. Наружу выходит только третий бросок. Задача кончается Cancelled через три секунды после отмены.

Всё это время задача держит очередь. Следующая задача ждёт тело, которому уже нечего делать, и close() ждёт его так же.

ctx.emit(const Loading());
for (var attempt = 1; ; attempt++) {
final String name;
try {
name = await ctx.abandonable(api.fetchName);
} on Object {
if (attempt == 3) rethrow;
await ctx.pause(Duration(seconds: attempt));
continue;
}
ctx.emit(Loaded(name));
return name;
}

ctx.pause даёт задержку, которая служит контрольной точкой. Если catch забрал отмену, пауза бросает её снова, ещё не начав ждать. Отмена, пришедшая во время паузы, обрывает её на месте вместе с таймером. В обоих случаях задача сразу кончается Cancelled. Как и ctx.check() в разделе «Пропустить отмену дальше», пауза спрашивает у задачи, а не у ошибки, поэтому catch не нужна отдельная секция для отмены.

После третьей ошибки сети rethrow выпускает её наружу, и задача кончается Failed с этой ошибкой. on Object повторяет любую ошибку вызова, в том числе баг в разборе ответа. Секция только для ошибок, которые стоит повторять, например on ApiException, кончает задачу на первом же баге. Для отмены ей тоже ничего не нужно.

Пауза в теле при этом всё равно принадлежит задаче. Корневые задачи идут по одной, поэтому следующая задача профиля ждёт все попытки и все паузы между ними. Если между попытками должны идти другие задачи, повторяет вызывающий код, а не тело. Задача здесь load() из раздела «Контроллер профиля» страницы о задачах, без его isExpectedError, на профиле из раздела «Отправка ошибки», и она пробует один раз.

Future<Outcome<String>> loadWithRetries(ProfileController profile) async {
for (var attempt = 1; ; attempt++) {
final outcome = await profile.load().done;
if (outcome is! Failed || attempt == 3) return outcome;
await Future<void>.delayed(Duration(seconds: attempt));
}
}

Здесь Future.delayed на своём месте: он не принадлежит никакой задаче и поэтому ничего не держит. Задачи, отправленные за это время, выполняются в этом промежутке, и close() его не ждёт. Как кончается цикл:

  • Cancelled кончает цикл так же, как Done.
  • cancelAll() во время попытки отменяет её, и цикл возвращает этот Cancelled.
  • cancelAll() в промежутке не находит задачи для отмены, и следующая попытка после него всё равно начнётся.
  • Если закрыть профиль, следующую попытку он отклонит, и цикл вернёт её Cancelled(closed) в конце промежутка.

Каждая попытка здесь отдельная задача, и профиль показывает, чем кончилась каждая. Между попытками состояние равно Failure из ifFailed, и экран показывает ошибку, пока следующая попытка не выпустит Loading; при цикле в теле состояние всё время остаётся Loading.

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

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

Задача, которой нужен свободный слот, просит его в стартовом правиле.

Правило, которое не может продолжать, бросает, как это делает функция:

// Первая попытка: исключение в canStart означает сбой.
canStart: (state) {
if (state.free == 0) throw StateError('no free slot');
return true;
},

Бросок читается как отказ, а оказывается провалом. Вот что происходит:

  1. Ошибка уходит в onError: отчёт о сбое ради обычного «не сейчас».
  2. Задача кончается Failed, и за этот исход отвечает onUnanswered, если его никто не наблюдает.
  3. Обработчик состояния, переданный тому же enqueue как ifFailed, не вызывается. Обработчик состояния вычисляет состояние после задачи, которая работала, а эта не стартовала.
  4. Очередь переходит к следующей задаче.
// Правило отвечает; чтобы отказать, оно не бросает исключение.
canStart: (state) => state.free > 0,

Ответ false от правила ошибкой не считается: он отменяет задачу, и трассу такого Cancelled берут там, где правило ответило «нет». keepWhile, перепроверенный на изменении, отвечает «нет» изнутри него, и потому трасса ведёт к самому изменению: к emit или externalSetState, чьё состояние нарушило правило.

Там, где правило отвечает «нет» вдали от изменения, трасса называет место, которое заметило. canStart спрашивают один раз, когда задача уходит из очереди, и трасса называет очередь. А задачу, нарушившую собственное правило, на её же emit не перепроверяют: она узнаёт об этом на ближайшей контрольной точке, будь то ctx.state, ctx.check(), ожидающий метод или ctx.each, и трасса называет эту контрольную точку.

От того, где правило всё-таки бросило, зависит, что станет с ошибкой:

Где бросило Что происходит
Стартовое правило Job проваливается, очередь продолжает работать.
Правило в контрольной точке контекста Ошибку получает тело.
Переоценка после обновления состояния О ней сообщают; работающее тело она сама не отменяет.
Та же переоценка у Job с обработчиком состояния О ней сообщают, а обработчик запрещается.

Задачу с обработчиком состояния перепроверяют и после конца её тела. Об этом раздел «Допуск обработчиков и их ошибки» страницы о состоянии. Ошибка переоценки идёт той дорогой, которую таблица раздела «Куда уходит каждая ошибка» даёт keepWhile: в onError, потом в onUnanswered, а когда никто не отвечает, в зону создания.

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

unawaited говорит, что вызывающий код не ждёт:

// Первая попытка: ошибка не принадлежит никому в задаче.
unawaited(analytics.send('zoom'));

Больше он не говорит ничего. Ошибка этой future в задаче не принадлежит никому. Хуки её не увидят, а Solo.unansweredHandler не спросят. Она всплывёт необработанной ошибкой в зоне, где работает тело, а у задачи из очереди это зона, где задачу создали. Назвать задачу, из которой она пришла, там нечему.

// Работа со своей жизнью: Job её не ждёт и не отменяет, а ошибки всё
// равно доходят до хуков этой Job.
ctx.unattended(() => analytics.send('zoom'));

ctx.unattended(action) запускает работу, которую задача не ждёт и не отменяет. Её ошибки остаются за задачей даже после её завершения. Их слышит onError, а отвечает за них onUnanswered; если нет ни переопределения, ни обработчика, они кончаются в зоне создания. Используйте его для работы с независимым временем жизни.

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

Эта работа не сама задача, и контекст отказывает в ней всему, что действует на задачу: ctx.run, ctx.runAll, ctx.each и ctx.uncancellable бросают StateError с именем вызова, для ctx.run это cannot run a child inside unattended work. Этот бросок считается ошибкой той самой работы, поэтому он идёт дорогой, названной выше, к хукам. Сама задача всё равно кончается Done.

Захваченный контекст всё ещё принадлежит исходной задаче: emit работает, пока задача активна, и отклоняется после отмены или завершения. Фоновая работа не продлевает время жизни контекста.