Weird (incorrect?) error handling behaviors
See original GitHub issueHello,
Weāve been enjoying using redux-saga
for a month now, and weāre quite happy with it (so, thank you for that š ).
There is something that we cannot figure out though: error bubbling and how to properly handle it in the root saga. (our goal is to have our root saga handle all the uncaught errors and restart the app when thatās the case)
As a disclaimer: I donāt know if all the following is expected behavior or not (I guess not). But even if it is, it is really confusing and we could not find anything in the docs related to that.
Anyway, here it goes:
Iāve noticed the following error handling (/bubbling) patterns:
try {
yield fork(...);
}
catch (error) { ... }
try {
const task = yield fork(...);
yield task.done;
}
catch (error) { ... }
and
const task = yield fork(...);
try {
yield task.done;
}
finally {
const error = task.error();
}
Iāll post different scenarii, all quite similar, but with different error bubbling behaviors (except scenario 2, for which I found none) .
(all these have been run against redux-saga@0.11.0
ā transformed with babel@6.10.1
and react native packager)
Scenario 1: Immediately throwing in a forked function
// this is not a generator but fork expect a generator or a "normal" function that returns a Promise.
function immediatelyFailingFork () {
throw new Error('immediatelyFailingFork');
}
export default function* root () {
const error = yield fork(immediatelyFailingFork);
console.log(error); // logs the error properly
}
Note: we donāt fork a generator, but a function.
Expected behavior: a task is returned by fork
.
Actual behavior: the error is returned by fork
.
Scenario 2: Throwing in a function forked from a forked generator
function immediatelyFailingFork () {
throw new Error('immediatelyFailingFork');
}
function* launcher () {
yield fork(immediatelyFailingFork);
}
export default function* root () {
yield fork(launcher);
}
Note: same as Scenario 1, but we put the fork
of the throwing function inside another fork
.
Excepted behavior: Error bubbling to the root
saga.
Actual behavior: The error is swallowed completely. No āuncaught at rootā error is logged by redux-saga
(Seems only logical since no task is returned in scenario 1, hence no error propagation can take place).
For this scenario, I tried the following error handling strategies:
2.1
export default function* root () {
try {
yield fork(launcher);
}
catch (error) {
console.log(error); // never called
}
}
2.2
export default function* root () {
const task = yield fork(launcher);
try {
yield task.done;
}
catch (error) {
console.log(error); // never called
}
finally {
console.log(task.error()); // logs undefined
}
}
2.3
export default function* root () {
const task = yield fork(launcher);
task.done.catch(error => console.error(error)); // never called
}
Scenario 3 Immediately throwing in a forked generator (similar to 1)
function* immediatelyFailingForkGenerator () {
throw new Error('immediatelyFailingForkGenerator');
}
export default function* root () {
try {
yield fork(immediatelyFailingForkGenerator);
}
catch (error) {
console.error(error); // logs the error properly
}
}
Note: Similar to scenario 1, but instead of throwing in a fork
ed function, we throw in a fork
ed generator.
Expected behavior: (from some comments in the issues) use task.done
to catch the error.
Actual behavior: no Task
is returned from fork
, an error is thrown instead.
Scenario 4 Throw after a put in a forked generator
function* failingAfterPutFork () {
yield put({ type: 'AN_ACTION' });
throw new Error('failingAfterPutFork');
}
export default function* root () {
// we can put this into the try block, with no change, since the error is not thrown at this point
const task = yield fork(failingAfterPutFork);
try {
yield task.done;
}
catch (error) {
console.error(error); // never called
}
finally {
console.error(task.error()); // logs the error properly
}
}
Note: same as scenario 3, but this time we call put
before throwing.
Excepted behavior: same as 3.
Actual behavior: a Task
is returned by fork
, but no error is thrown inside task.done
. Instead we need to use task.error()
in the finally
block. Also, note that an āuncaught at rootā error is logged by redux-saga
, which should not be the case (but reflects the actual behavior).
Scenario 5 Throw after a put in a forked generator in a forked generator
function* failingAfterPutFork () {
yield put({ type: 'AN_ACTION' });
throw new Error('failingAfterPutFork');
}
function* launcher () {
yield fork(failingAfterPutFork);
}
export default function* root () {
try {
yield fork(launcher);
}
catch (error) {
console.error('in catch', error); // logs the error properly
}
}
Note: same as scenario 4, but with an intermediate ālauncherā that fork
the throwing generator.
Behavior: same as scenario 3.
Scenario 6 Throw in forked generator after a call in a forked generator
function* immediatelyFailingForkGenerator () {
throw new Error('immediatelyFailingForkGenerator');
}
function* launcher () {
yield call(() => new Promise(resolve => {
resolve();
}));
yield fork(immediatelyFailingForkGenerator);
}
export default function* root () {
const task = yield fork(launcher);
try {
yield task.done;
}
catch (error) {
console.error('in catch', error); // never called
}
finally {
console.error('in finally', task.error()); // logs the error properly
}
}
Note: this seems to be a variation on scenario 4 (edited, was 3, my mistake), as we see the same behavior.
It seems there are a three types of behavior (not counting scenarii 1 and 2) that can be encountered just by fork
ing in some ways. Some might be bug, some might be related to āsynchronousā error throwing. I didnāt have the time to look into the code more than a few minutes (and wonāt have the time to, sadly).
Anyway, please let me know what I can do, even if as said, weāre on a tight pre-release schedule, so time is scarce š
Issue Analytics
- State:
- Created 7 years ago
- Reactions:6
- Comments:8 (4 by maintainers)
Top GitHub Comments
Iāll look at this more closely, I think itās due to an incorrect handling in the lib of the sync forks. For example in Scenario 1, itās clearly a bug, since the fork shouldnāt return the error but has to throw it (Actually in this place of the code we need to put
cb(err, true)
to indicate that this is an error).But first le me indicate what the expected behavior should be in both sync and async cases (Iām planning to add this in the docs in a āredux-saga fork modelā section).
When using a
call
a generator should be able to catch errors from the called function/generatorUncaught errors should bubble up the call chain (ie to
call
s from parents) just like in normal JavaScript code.fork
effects has different semantics. Observe this code for ex.The reason you canāt catch errors from forks is that the parent is not blocked and waiting for the fork but continues its execution as soon as the fork is performed; when a fork throws some error the parent Generator could have moved past the try catch block
So the rule of thumb is you canāt catch errors from forks, but they still bubble up. So how do Error propagation from forked tasks work?
In the pre 0.10 versions all errors from forked tasks were swallowed. Just like in raw Promises and async functions now. The 0.10 release introduced a new concept of attached fork see release notes : a parent can fork one or multiple tasks, say for example we have a saga which at some time forked 2 tasks, so now we have 3 tasks evolving in parallel
You can view those tasks as 3 parallel tasks belonging to the same execution context or execution tree. Each one of the 3 tasks can catch its own errors, but canā t catch errors from its siblings. When an uncaught error is raised inside one of the 3 tasks, the whole execution tree is aborted: the errored task is of course aborted, and the sibling tasks, if they are still running, are cancelled (including all their respective subtasks, effectively killing all the nested execution trees).
After an execution tree is aborted, the error bubbles up to the parent of the main task. If this parent was calling the main task using
call
(orjoin
) then it should be able to catch that error, but if it was usingfork
the same mechanism happens: the whole execution tree corresponding to that parent (its own task and all forked tasks) is cancelled and the error is raised again up to the root.To summarize:
call
orjoin
).So in relation to the above scenarios, it seems (I have still to look more closely) that this is caused by an inconsistent handing of the synchronous forks (ie which terminates synchronously). in the actual code the sync errors from forks are reported to the main task, but they should behave like the async forks and cause the whole execution tree to abort. And since the fork aborts synchronously, we canāt expect the fork call to return a task but immediately aborts the whole execution tree including the main task.
OTOH, detached forks , created using the
spawn
Effect behave like the pre 0.10 forks; errors raised from spawned tasks are lost unless you handle them explicitly (catching in thedone
promise of the task, ofjoin
ing a spawned task). This is pretty much the behavior of the raw promises and async functions: the behavior of error propagations depend on whether and when you attached the error handler to a promise chain (before or after the error was raised).The structured model of attached forks, may sound more restrictive and less flexible, but Itās this structure and the rules above which makes things like Server Side Rendering possible with the same Saga code running both on the client and the server.
sorry for the delay. The above should not work. since you canāt catch errors from forked tasks.
BTW, instead of
yield task.done
, itās better to useyield join(task)
.FYI, Iāve already implemented the fixes and also added initial docs for the fork behavior. To be uploaded shortly with a new release