question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. ItĀ collects links to all the places you might be looking at while hunting down a tough bug.

And, if youā€™re still stuck at the end, weā€™re happy to hop on a call to see how we can help out.

Weird (incorrect?) error handling behaviors

See original GitHub issue

Hello,

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 forked function, we throw in a forked 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 forking 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:closed
  • Created 7 years ago
  • Reactions:6
  • Comments:8 (4 by maintainers)

github_iconTop GitHub Comments

5reactions
yelouaficommented, Jul 19, 2016

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/generator

try {
  yield call(funcorGen, ...)
} catch(err) { ... }

Uncaught errors should bubble up the call chain (ie to calls from parents) just like in normal JavaScript code.

fork effects has different semantics. Observe this code for ex.

function* parent() {
  try {
    yield fork(funcorGen, ...)
  } catch(err) { ... } // Invalid, you can't expect to catch errors from forked tasks
}

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

function* parent() {
  try {
    yield fork(funcOrGen, ...)
  } catch(err) { ... } 
  // when `funcOrGen` task throws, the Generator could be already here
  yield fork(anotherFuncOrGen) 
}

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?

Note for all readers: If you dont want to dig deeper into this, you can just remember the rule above and skip all of this.

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

  • The ā€˜main taskā€™ is the parent who forked the other tasks
  • The 2 forked tasks

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 (or join) then it should be able to catch that error, but if it was using fork 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:

  • A Generator, along with all its forked tasks, form an execution tree (forked tasks can also have their own nested execution trees). The generator forking the other tasks is called the main task, the main task can catch errors only from generators/functions invoked using a blocking call (call or join).
  • The execution tree terminates when the main taskā€™s body terminates as well as all forked tasks (all nested execution trees should terminate)
  • Cancelling the main task will cancel the whole execution tree
  • Any uncaught error from a task (main tasks or one of the forks) in the execution tree causes the whole execution tree to abort. The error bubbles up to the parent of the main task (and the same rules applies to this parent).

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 the done promise of the task, of joining 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.

3reactions
yelouaficommented, Aug 26, 2016

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 use yield 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

Read more comments on GitHub >

github_iconTop Results From Across the Web

Error Messages: Examples, Best Practices & Common Mistakes
1. Ambiguity. Your error messages should clearly define the problem. Ever gotten a message like the following?
Read more >
Improper Error Handling - OWASP Foundation
The most common problem is when detailed internal error messages such as stack traces, database dumps, and error codes are displayed to the...
Read more >
Finding and Preventing Run-Time Error Handling Mistakes
We present a dataflow analysis for finding a certain class of error-handling mistakes: those that arise from a failure to release resources or...
Read more >
Strange error handling behavior in Delphi 10.3.3 [duplicate]
i'm upgrading from Delphi 2010 to Delphi 10.3.3. if i tried to free an uninitialized object on VCL Project, the application disappearĀ ...
Read more >
CWE-754: Improper Check for Unusual or Exceptional ...
If it does not exist, the program cannot perform the desired behavior so it doesn't matter whether I handle the error or simply...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found