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

fix($resource): fix interceptors and success/error callbacks #16446

Closed
wants to merge 4 commits into from

Conversation

gkalpak
Copy link
Member

@gkalpak gkalpak commented Feb 10, 2018

Previously, action-specific interceptors and success/error callbacks were executed in inconsistent relative orders and in a way that did not meet the general expectation for interceptor behavior (e.g. ability to recover from errors, performing asynchronous operations, etc).

This commit fixes the behavior to make it more consistent and expected. The main differences are that success/error callbacks will now be run after response/responseError interceptors complete (even if interceptors return a promise) and the correct callback will be called based on the result of the interceptor (e.g. if the responseError interceptor recovers from an error, the success callback will be
called).
See also #9334 (comment).

This commit also replaces the use of success/error callbacks in the docs with using the returned promise.

Fixes #6731
Fixes #9334
Closes #6865

BREAKING CHANGE:

If you are not using success or error callbacks with $resource, your app should not be affected by this change.

If you are using success or error callbacks (with or without response interceptors), one (subtle) difference is that throwing an error inside the callbacks will not propagate to the returned $promise. Therefore, you should try to use the promises whenever possible. E.g.:

// Avoid
User.query(function onSuccess(users) { throw new Error(); }).
  $promise.
  catch(function onError() { /* Will not be called. */ });

// Prefer
User.query().
  $promise.
  then(function onSuccess(users) { throw new Error(); }).
  catch(function onError() { /* Will be called. */ });

Finally, if you are using success or error callbacks with response interceptors, the callbacks will now always run after the interceptors (and wait for them to resolve in case they return a promise).
Previously, the error callback was called before the responseError interceptor and the success callback was synchronously called after the response interceptor. E.g.:

var User = $resource('/api/users/:id', {id: '@id'}, {
  get: {
    method: 'get',
    interceptor: {
      response: function(response) {
        console.log('responseInterceptor-1');
        return $timeout(1000).then(function() {
          console.log('responseInterceptor-2');
          return response.resource;
        });
      },
      responseError: function(response) {
        console.log('responseErrorInterceptor-1');
        return $timeout(1000).then(function() {
          console.log('responseErrorInterceptor-2');
          return $q.reject('Ooops!');
        });
      }
    }
  }
});
var onSuccess = function(value) { console.log('successCallback', value); };
var onError = function(error) { console.log('errorCallback', error); };

// Assuming the following call is successful...
User.get({id: 1}, onSuccess, onError);
  // Old behavior:
  //   responseInterceptor-1
  //   successCallback, {/* Promise object */}
  //   responseInterceptor-2
  // New behavior:
  //   responseInterceptor-1
  //   responseInterceptor-2
  //   successCallback, {/* User object */}

// Assuming the following call returns an error...
User.get({id: 2}, onSuccess, onError);
  // Old behavior:
  //   errorCallback, {/* Response object */}
  //   responseErrorInterceptor-1
  //   responseErrorInterceptor-2
  // New behavior:
  //   responseErrorInterceptor-1
  //   responseErrorInterceptor-2
  //   errorCallback, Ooops!

@Narretz
Copy link
Contributor

Narretz commented Feb 16, 2018

If you are using success or error callbacks (with or without response interceptors), one (subtle) difference is that throwing an error inside the callbacks will not propagate to the returned $promise.

So that means if I do:

User.get({userId:123}, function successCallback() {
 throw new Error();
}).$promise.then(function(user) {
      $scope.user = user;
    }).catch(function(error) { )}

catch won't be called? I think the commit message should make this clearer. I also don't see any tests for this behavior, is this intentional?

You could also add a note to the (general) commit message that you've replaced use of callbacks in the docs with promise, which is a nice touch

@Narretz
Copy link
Contributor

Narretz commented Feb 16, 2018

Does this PR obsolete #6865?

@gkalpak
Copy link
Member Author

gkalpak commented Feb 16, 2018

So that means if I do ... catch won't be called?

Yes. It is up for debate, but I think it is reasonable.
I would like to deprecate and discourage the callbacks altogether, but unfortunately there is no (straight forward) alternative if you want to get access to some response metadata (e.g. status, headers, etc).

I think the commit message should make this clearer.

👍

I also don't see any tests for this behavior, is this intentional?

There weren't any tests for the exact behavior before, so I played along. If we agree that this is the behavior we want, I can add tests 😃

Does this PR obsolete #6865?

Yes! (I didn't know about that one. Thx for pointing it out 👍)

@Narretz
Copy link
Contributor

Narretz commented Feb 16, 2018

If we agree that this is the behavior we want, I can add tests

If find the new behavior very reasonable. A callback isn't (usually) chainable like a promise. So you have my 👍 for adding tests :D

@gkalpak
Copy link
Member Author

gkalpak commented Feb 16, 2018

Added a couple of tests and updated the commit message with some examples.
@Narretz, PTAL

Copy link
Contributor

@Narretz Narretz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great stuff @gkalpak. Just a few minor change requests. Per instance interceptors would also look good in $http, I wonder if we should implement them.

I think @petebacondarwin should also reviw this.

* global `$http` interceptors. Also, rejecting or throwing an error inside the `request`
* interceptor will result in calling the `responseError` interceptor.
The resource instance or collection is available on the `resource` property of the
* `http response` object passed to `response`/`responseError` interceptors.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you link here to the example below?

$q.reject;
var successCallback = onSuccess ? function(val) {
onSuccess(val, response.headers, response.status, response.statusText);
} : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't noop better than undefined here? (and in line 773)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is better for these to be undefined since there is different behaviour in the call to then(...) function if onError is a defined function. But this only would make a difference if we returned the promise that is created at https://github.com/angular/angular.js/pull/16446/files#diff-67ae87630c0e91cba343677ed730daafR861.

As it stands undefined and noop have the same result below.

}

describe('responseInterceptor', function() {
it('should allow per action response interceptor that gets full response', function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"full response" here means no headers or statusText, right? Because you don't assert for them. Could we add them if they are missing?

@@ -294,46 +301,54 @@ function shallowClearAndCopy(src, dst) {
*
* ### Credit card resource
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this example and the next one had names that tell what they are showing, e.g. for this one "Basic Usage" and for the second one maybe "Accessing the http response / Promises and Callbacks"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

* - **`timeout`** – `{number}` – timeout in milliseconds.<br />
* - **`cache`** – `{boolean|Cache}` – If true, a default `$http` cache will be used to cache the
* GET request, otherwise if a cache instance built with {@link ng.$cacheFactory $cacheFactory}
* is supplied, this cache will be used for caching.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what else would a "cache" be used for? 😛

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question ¯\_(ツ)_/¯ - I'm just formatting short lines here 😛
I copied the description from $http:

  • cache{boolean|Cache} – A boolean value or object created with
    {@link ng.$cacheFactory $cacheFactory} to enable or disable caching of the HTTP response.
    See {@link $http#caching $http Caching} for more information.

* response interceptors. Make sure you return an appropriate value and not the `response`
* object passed as input. For example, the default `response` interceptor (which gets applied
* if you don't specify a custom one) returns `response.resource`.
* - **`hasBody`** - `{boolean}` - Allows to specify if a request body should be included or not.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I prefer more explicit definitions:

- **`hasBody`** - `{boolean}` - If true, then the request will have a body.
  If not specified, then only POST, PUT and PATCH requrest will have a body.

* var User = $resource('/user/:userId', {userId:'@id'});
* var user = User.get({userId:123}, function() {
* var User = $resource('/user/:userId', {userId: '@id'});
* User.get({userId: 123}).$promise.then(function(user) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+100

} :
undefined);
// Run the `success`/`error` callbacks, but do not let them affect the returned promise.
promise.then(successCallback, errorCallback);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you explicitly choose for the success and error callbacks not to affect the returned promise, thus causing a breaking change.

Is this BC really necessary? Why not allow them to pass through to returned promise, given that this was the previous behaviour?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be clear, this commit will be a BC regardless if we propagate callback errors to $promise or not.

Previously, callbacks did not 100% affect $promise. E.g. their returned value was ignored. It just so happened that throwing was reflected on $promise as a rejection (since it happened inside a then() callback).

Changing the general behavior (e.g. forwarding the value returned by callbacks to $promise) would be unexpected and confusing imo. I am fine with both the current state of the PR as well as having thrown errors propagate to $promise as rejections. (I just chose to not preserve that particular aspect of the previous behavior, because it felt unintentional.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@@ -2026,6 +2143,41 @@ describe('handling rejections', function() {
}
);


it('should not propagate exceptions in success callback to the returned promise', function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is it a bad thing for this to happen?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo, the success callback should not interfere with the promise chain, since it's strictly just a callback. Error handling should only happen in the interceptors. Do you think this will affect many apps?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that in an ideal world this should be the behaviour - I am just wondering if it is so important that it should cause a BC.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gkalpak
Copy link
Member Author

gkalpak commented Feb 20, 2018

Added another commit. PTAL, @Narretz & @petebacondarwin ❤️

@Narretz
Copy link
Contributor

Narretz commented Feb 20, 2018

travis only failed because of the templaterequest errors. ;)

Previously, action-specific interceptors and `success`/`error` callbacks
were executed in inconsistent relative orders and in a way that did not
meet the general expectation for interceptor behavior (e.g. ability to
recover from errors, performing asynchronous operations, etc).

This commit fixes the behavior to make it more consistent and expected.
The main differences are that `success`/`error` callbacks will now be
run _after_ `response`/`responseError` interceptors complete (even if
interceptors return a promise) and the correct callback will be called
based on the result of the interceptor (e.g. if the `responseError`
interceptor recovers from an error, the `success` callback will be
called).
See also angular#9334 (comment).

This commit also replaces the use of `success`/`error` callbacks in the
docs with using the returned promise.

Fixes angular#6731
Fixes angular#9334
Closes angular#6865

BREAKING CHANGE:

If you are not using `success` or `error` callbacks with `$resource`,
your app should not be affected by this change.

If you are using `success` or `error` callbacks (with or without
response interceptors), one (subtle) difference is that throwing an
error inside the callbacks will not propagate to the returned
`$promise`. Therefore, you should try to use the promises whenever
possible. E.g.:

```js
// Avoid
User.query(function onSuccess(users) { throw new Error(); }).
  $promise.
  catch(function onError() { /* Will not be called. */ });

// Prefer
User.query().
  $promise.
  then(function onSuccess(users) { throw new Error(); }).
  catch(function onError() { /* Will be called. */ });
```

Finally, if you are using `success` or `error` callbacks with response
interceptors, the callbacks will now always run _after_ the interceptors
(and wait for them to resolve in case they return a promise).
Previously, the `error` callback was called before the `responseError`
interceptor and the `success` callback was synchronously called after
the `response` interceptor. E.g.:

```js
var User = $resource('/api/users/:id', {id: '@id'}, {
  get: {
    method: 'get',
    interceptor: {
      response: function(response) {
        console.log('responseInterceptor-1');
        return $timeout(1000).then(function() {
          console.log('responseInterceptor-2');
          return response.resource;
        });
      },
      responseError: function(response) {
        console.log('responseErrorInterceptor-1');
        return $timeout(1000).then(function() {
          console.log('responseErrorInterceptor-2');
          return $q.reject('Ooops!');
        });
      }
    }
  }
});
var onSuccess = function(value) { console.log('successCallback', value); };
var onError = function(error) { console.log('errorCallback', error); };

// Assuming the following call is successful...
User.get({id: 1}, onSuccess, onError);
  // Old behavior:
  //   responseInterceptor-1
  //   successCallback, {/* Promise object */}
  //   responseInterceptor-2
  // New behavior:
  //   responseInterceptor-1
  //   responseInterceptor-2
  //   successCallback, {/* User object */}

// Assuming the following call returns an error...
User.get({id: 2}, onSuccess, onError);
  // Old behavior:
  //   errorCallback, {/* Response object */}
  //   responseErrorInterceptor-1
  //   responseErrorInterceptor-2
  // New behavior:
  //   responseErrorInterceptor-1
  //   responseErrorInterceptor-2
  //   errorCallback, Ooops!
```
@gkalpak gkalpak closed this in ea05857 Feb 20, 2018
@gkalpak gkalpak deleted the fix-resource-interceptors branch February 20, 2018 17:07
Narretz added a commit to Narretz/angular.js that referenced this pull request Feb 23, 2018
The sitemap.xml might also prevent the indexing, as the partials are not
listed.

Related to angular#16432
Closes angular#16457

Closes angular#16446
Narretz pushed a commit to Narretz/angular.js that referenced this pull request Feb 23, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
4 participants