Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Chained promises broken when testing due to $exceptionHandler interference #3174

Closed
Thorn1089 opened this issue Jul 9, 2013 · 14 comments
Closed

Comments

@Thorn1089
Copy link

The Promises specification has the following to say about chaining promises:

then must return a promise

promise2 = promise1.then(onFulfilled, onRejected);

If either onFulfilled or onRejected returns a value that is not a promise, promise2 must be fulfilled with that value.

If either onFulfilled or onRejected throws an exception, promise2 must be rejected with the thrown exception as the reason.

However, when using Karma to test a module, I noticed that the following inside the method under test failed:

return $http.post(endpoint, data).then(function(response) {
  //return some data from the response
}, function(error) {
  var throwable;
  //grab some data from the response to create a meaningful rejection reason
  throw throwable;
});

When invoking the method as follows, with a mocked HTTP backend to force a failure:

service.do(args).then(function(unused) {
  expect(false).toBe(true);//In the absence of a fail() method, use a contradiction
}, function(error) {
  expect(error.code).toBe(FAILURE_VAL);
});

Instead of the expectation being met in the onRejected function, the exception propagates up the stack and fails the test. This is because apparently during tests, the default policy of $exceptionHandler is rethrow.

I don't understand what business $exceptionHandler has at all being invoked by $q, especially since this behavior violates the Promises/A specification. Exceptions being thrown is a normal and expected part of promise execution.

@FrigoEU
Copy link

FrigoEU commented Mar 11, 2014

I'm wondering if this has been looked at in the meantime? I'm having the same problem where my code is correctly throwing an error to reject a promise and then handle it in my .catch() handler, but angular is intercepting the error and immediately propagating it upward making karma think there was an uncaught error.

@rasmusvhansen
Copy link

+1

I also find this behaviour odd. I guess a workaround is to configure the exceptionhandler to log instead of rethrow exception.

I use this in the relevant tests:

beforeEach(module(function ($exceptionHandlerProvider) {
  $exceptionHandlerProvider.mode('log'); // See https://github.com/angular/angular.js/issues/3174
}));

@Narretz Narretz added this to the Backlog milestone Jul 4, 2014
@Narretz
Copy link
Contributor

Narretz commented Jul 4, 2014

$q is not promises/A compliant, IIRC. But it's definitely something to keep in mind for a future rewrite @caitp

@caitp
Copy link
Contributor

caitp commented Jul 4, 2014

It is actually aplus compliant, at least a few months ago it was, not sure how much aplus has changed since then =) Work is being done on refactoring the logging of exceptions (ITF: rejections in general), however @rasmusvhansen's solution is really the best you can do right now, short of monkey-patching ngMock

@n-rook
Copy link

n-rook commented Sep 27, 2016

Hey @alexeagle, how are you doing? You still like testing right? That means you should fix this bug ;)

Here's a jsfiddle demonstrating it: http://jsfiddle.net/tz8f7tvs/2/

@alexeagle
Copy link

Hi Nate, I do like testing. But I don't know anything about Angular 1. Do you want to spend an "escalation token" on this one? :)

@n-rook
Copy link

n-rook commented Sep 27, 2016

Oh, I didn't realize the teams were split, too! I saw Angular 2 just launched publicly, so congratulations on that.

As a Xoogler, I'm not sure I get escalation tokens 😳 If I do, though, I'd love to see this fixed, since I think it makes it really hard to write good tests in some cases.

@juliemr
Copy link
Member

juliemr commented Sep 27, 2016

Escalation token noted but placed on side board, with reason: "Travel and Conferences."

I can take a look at this one in the "next month" kinda timeframe.

@n-rook
Copy link

n-rook commented Sep 27, 2016

Cool, thanks for taking a look!

@gkalpak
Copy link
Member

gkalpak commented Sep 28, 2016

This is not a bug, it is a feature 😛

The exception is indeed turned into a rejection, but at the same time passed to the exceptionHandler. So, $q is still A+ compliant.
By default, the exceptionHandler will just log the error, but in tests it will rethrow it (as mentioned above).

@rasmusvhansen's solution is the way to go.

The reason why thrown exceptions are passed to the exceptionHandler (in addition to being converted to rejections), was that they might indicate a programming error, which would otherwise get possibly swallowed in an unhandled rejection.

Since this turned out to be inconvenient/confusing for people, we are planning to remove this "feature" in 1.6.0.
(The timing is good, because starting with 1.6.0 "Possibly Unhandled Rejections" will be reported as errors.)

@n-rook
Copy link

n-rook commented Sep 28, 2016

Oh, okay! Thanks for getting back to me. I think this behavior is pretty crazy, but then you're already going to remove it, so no need for me to convince you.

Please document this behavior in the $q documentation! I could also do this and send a PR, but I don't have deep knowledge of Angular so I might get something wrong.

Is there a doc somewhere saying that $q is supposed to be A+ compliant? I was hunting for one but couldn't find any source one way or another. This might also be good to document.

And again, thanks for getting back :)

gkalpak added a commit to gkalpak/angular.js that referenced this issue Oct 3, 2016
Previously, errors thrown in a promise's `onFulfilled` or `onRejected` handlers were treated in a
slightly different manner than regular rejections:
They were passed to the `$exceptionHandler()` (in addition to being converted to rejections).

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.)

This commit removes the distinction, by skipping the call to `$exceptionHandler()`, thus treating
thrown errors as regular rejections.

**Note:**
Unless explicitly turned off, possibly unhandled rejections will still be caught and passed to the
`$exceptionHandler()`, so errors thrown due to programming errors and not otherwise handled (with a
subsequent `onRejected` handler) will not go unnoticed.

BREAKING CHANGE:

Previously, throwing an error from a promise's `onFulfilled` or `onRejection` handlers, would result
in passing the error to the `$exceptionHandler()` (in addition to rejecting the promise with the
error as reason).

Now, a thrown error is treated exactly the same as a regular rejection. This 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.

Fixes angular#3174
Fixes angular#14745
@gkalpak
Copy link
Member

gkalpak commented Oct 4, 2016

Is there a doc somewhere saying that $q is supposed to be A+ compliant?

@n-rook, hey, we run $q against the Promises/A+ Compliance Test Suite on every build. We are compliant (as long as $exceptionHandler doesn't do weird things, suhc as re-throwing errors 😛).
I am concerned that this is a bit too low-level to add to the docs, but I have added a note that $q is Promises/A+ compliant in #15213 (which also removes the extra $exceptionHandler call).

@n-rook
Copy link

n-rook commented Oct 4, 2016

Alright, thanks @gkalpak! To be honest, I just wanted a canonical source to resolve my stackoverflow question ;) You could try to get Angular 1 listed on this page, but I have no clue how that page was set up or if anyone but me would ever have found it.

I think I recognize your username... if you see the TAP team anytime soon, say hi to them for me.

@gkalpak
Copy link
Member

gkalpak commented Oct 4, 2016

No idea what the TAP team is, but if I happen to see them I will say hi 😃

@gkalpak gkalpak closed this as completed in e13eeab Oct 5, 2016
ellimist pushed a commit to ellimist/angular.js that referenced this issue Mar 15, 2017
Previously, errors thrown in a promise's `onFulfilled` or `onRejected` handlers were treated in a
slightly different manner than regular rejections:
They were passed to the `$exceptionHandler()` (in addition to being converted to rejections).

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.)

This commit removes the distinction, by skipping the call to `$exceptionHandler()`, thus treating
thrown errors as regular rejections.

**Note:**
Unless explicitly turned off, possibly unhandled rejections will still be caught and passed to the
`$exceptionHandler()`, so errors thrown due to programming errors and not otherwise handled (with a
subsequent `onRejected` handler) will not go unnoticed.

Fixes angular#3174
Fixes angular#14745

Closes angular#15213

BREAKING CHANGE:

Previously, throwing an error from a promise's `onFulfilled` or `onRejection` handlers, would result
in passing the error to the `$exceptionHandler()` (in addition to rejecting the promise with the
error as reason).

Now, a thrown error is treated exactly the same as a regular rejection. This 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.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.