AngularJS - Promises rethrow caught exceptions
Asked Answered
S

5

18

In the following code, an exception is caught by the catch function of the $q promise:

// Fiddle - http://jsfiddle.net/EFpn8/6/
f1().then(function(data) {
        console.log("success 1: "+data)
        return f2();
    })
    .then(function(data) {console.log("success 2: "+data)})
    .catch(function(data) {console.log("error: "+data)});

function f1() {
    var deferred = $q.defer();
    // An exception thrown here is not caught in catch
    // throw "err";
    deferred.resolve("done f1");        
    return deferred.promise;
}

function f2() {
    var deferred = $q.defer();
    // An exception thrown here is handled properly
    throw "err";
    deferred.resolve("done f2");        
    return deferred.promise;
}  

However when I look in the console log output I see the following:

enter image description here

The exception was caught in Angular, but was also caught by the error handling of the browser. This behavior does reproduce with Q library.

Is it a bug? How can I truly catch an exception with $q?

Shareeshareholder answered 27/4, 2014 at 15:1 Comment(0)
O
5

Fixed with AngularJS version 1.6

The reasoning for this behavior was that an uncaught error is different than a regular rejection, as it can be caused by a programming error, for example. In practice, this turned out to be confusing or undesirable for users, since neither native promises nor any other popular promise library distinguishes thrown errors from regular rejections. (Note: While this behavior does not go against the Promises/A+ spec, it is not prescribed either.)

$q:

Due to e13eea, an error thrown from a promise's onFulfilled or onRejection handlers is treated exactly the same as a regular rejection. Previously, it would also be passed to the $exceptionHandler() (in addition to rejecting the promise with the error as reason).

The new behavior applies to all services/controllers/filters etc that rely on $q (including built-in services, such as $http and $route). For example, $http's transformRequest/Response functions or a route's redirectTo function as well as functions specified in a route's resolve object, will no longer result in a call to $exceptionHandler() if they throw an error. Other than that, everything will continue to behave in the same way; i.e. the promises will be rejected, route transition will be cancelled, $routeChangeError events will be broadcasted etc.

-- AngularJS Developer Guide - Migrating from V1.5 to V1.6 - $q

Otis answered 15/2, 2017 at 13:31 Comment(1)
Awesome news! Thanks for posting an updated answer. Does Angular now fire unhandledrejection and rejectionhandled events?Steerage
S
16

Angular's $q uses a convention where thrown errors are logged regardless of being caught. Instead, if you want to signal a rejection you need to return $q.reject(... as such:

function f2() {
    var deferred = $q.defer();
    // An exception thrown here is handled properly
    return $q.reject(new Error("err"));//throw "err";
    deferred.resolve("done f2");        
    return deferred.promise;
}  

This is to distinguish rejections from errors like SyntaxError. Personally, it's a design choice I disagree with but it's understandable since $q is tiny so you can't really build in a reliable unhandled rejection detection mechanism. In stronger libraries like Bluebird, this sort of thing is not required.

As a side note - never, ever throw strings : you miss on stack traces that way.

Steerage answered 27/4, 2014 at 15:24 Comment(5)
Are you serious? The code after the return of a new rejected promise gets never called. And you do not need the deferred here because you creating a new promise by using $q.reject()...Clodhopper
@Clodhopper obviously code doesn't get called after a throw or return, all the involved parties are well aware of that. The code in this answer is replicated from the question - OP asked how to signal errors, this is how you do it (see how in this answer return $q.reject replaces the throw "foo" in the exact same surrounding code in OP's question. Please consider taking another look at OP's code.Steerage
I know that, but think of someone who doesn't know that fact. I prefer to not copy paste false code if I see that there is a potential bug in it. You gave the advice to not throw strings, also. This is something I would also think VitalyB is aware of if he writes such sleazy code ;-).Clodhopper
@Clodhopper I prefer people do not copy/paste code I post here blindly and actually attempt to read and understand it and then apply the principles I discuss in it themselves.Steerage
This example only confused me even more. It contains multiple returns and confusing comments. "An exception thrown here...". What exception? The only "throw" statement in this code is commented out.Acnode
B
6

Is it a bug?

No. Looking in the source for $q reveals that a deliberate try / catch block is created to respond to exceptions thrown in the callback by

  1. Rejecting the promise, as through you had called deferred.reject
  2. Calling the registered Angular exception hander. As can be seen in the $exceptionHandler docs, the default behaviour of this is to log it to the browser console as an error, which is what you have observed.

... was also caught by the error handling of the browser

To clarify, the exception isn't handled directly by the browser, but appears as an error because Angular has called console.error

How can I truly catch an exception with $q?

The callbacks are executed some time later, when the current call stack has cleared, so you won't be able to wrap the outer function in try / catch block. However, you have 2 options:

  • Put in try/catch block around the code that might throw the exception, within the callback:

    f1().then(function(data) {
      try {
        return f2();
      } catch(e) {
        // Might want convert exception to rejected promise
        return $q.reject(e);
      }
    })
    
  • Change how Angular's $exceptionHandler service behaves, like at How to override $exceptionHandler implementation . You could potentially change it to do absolutely nothing, so there would never be anything in the console's error log, but I don't think I would recommend that.

Baese answered 27/4, 2014 at 16:0 Comment(0)
O
5

Fixed with AngularJS version 1.6

The reasoning for this behavior was that an uncaught error is different than a regular rejection, as it can be caused by a programming error, for example. In practice, this turned out to be confusing or undesirable for users, since neither native promises nor any other popular promise library distinguishes thrown errors from regular rejections. (Note: While this behavior does not go against the Promises/A+ spec, it is not prescribed either.)

$q:

Due to e13eea, an error thrown from a promise's onFulfilled or onRejection handlers is treated exactly the same as a regular rejection. Previously, it would also be passed to the $exceptionHandler() (in addition to rejecting the promise with the error as reason).

The new behavior applies to all services/controllers/filters etc that rely on $q (including built-in services, such as $http and $route). For example, $http's transformRequest/Response functions or a route's redirectTo function as well as functions specified in a route's resolve object, will no longer result in a call to $exceptionHandler() if they throw an error. Other than that, everything will continue to behave in the same way; i.e. the promises will be rejected, route transition will be cancelled, $routeChangeError events will be broadcasted etc.

-- AngularJS Developer Guide - Migrating from V1.5 to V1.6 - $q

Otis answered 15/2, 2017 at 13:31 Comment(1)
Awesome news! Thanks for posting an updated answer. Does Angular now fire unhandledrejection and rejectionhandled events?Steerage
D
4

The deferred is an outdated and a really terrible way of constructing promises, using the constructor solves this problem and more:

// This function is guaranteed to fulfill the promise contract
// of never throwing a synchronous exception, using deferreds manually
// this is virtually impossible to get right
function f1() {
    return new Promise(function(resolve, reject) {
        // code
    });
}

I don't know if angular promises support the above, if not, you can do this:

function createPromise(fn) {
    var d = $q.defer();
    try {
        fn(d.resolve.bind(d), d.reject.bind(d));
    }
    catch (e) {
        d.reject(e);
    }
    return d.promise;
}

Usage is same as promise constructor:

function f1() {
    return createPromise(function(resolve, reject){
        // code
    });
}
Darwen answered 27/4, 2014 at 15:25 Comment(4)
"deferred is deprecated". Do you have a link that explains more about that?Baese
Indeed. This is the first time I hear about that. Angular $q documentation specified deferred...Shareeshareholder
I didn't mean literally deprecated by angular - is there some synonym I can useDarwen
Outdated? Superseded by the promise constructor?Steerage
D
1

Here is an sample test that shows the new $q construction function, use of .finally(), rejections, and promise chain propagations:

iit('test',inject(function($q, $timeout){
    var finallyCalled = false;
    var failValue;

    var promise1 = $q.when(true)
          .then(function(){
            return $q(function(resolve,reject){
              // Reject promise1
              reject("failed");
            });
          })
          .finally(function(){
            // Always called...
            finallyCalled = true;

            // This will be ignored
            return $q.when('passed');
          });

    var promise2 = $q.when(promise1)
          .catch(function(value){
            // Catch reject of promise1
            failValue = value;

            // Continue propagation as resolved
            return value+1;

            // Or continue propagation as rejected
            //return $q.reject(value+2);
          });

    var updateFailValue = function(val){ failValue = val; };

    $q.when(promise2)
      .then( updateFailValue )
      .catch(updateFailValue );

    $timeout.flush();

    expect( finallyCalled ).toBe(true);
    expect( failValue ).toBe('failed1');

}));
Deshabille answered 19/7, 2015 at 3:41 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.