feat($http): allow differentiation between XHR completion, error, abort, timeout

Previously, it wasn't possible to tell if an `$http`-initiated XMLHttpRequest
was completed normally or with an error or it was aborted or timed out.
This commit adds a new property on the `response` object (`xhrStatus`) which
allows to defferentiate between the possible statuses.

Fixes #15924

Closes #15847
This commit is contained in:
Frederik Prijck
2017-02-21 23:42:16 +01:00
committed by Georgios Kalpakas
parent 122d89b240
commit e872f0ed36
6 changed files with 154 additions and 40 deletions
+10 -8
View File
@@ -453,6 +453,7 @@ function $HttpProvider() {
* - **headers** `{function([headerName])}` Header getter function.
* - **config** `{Object}` The configuration object that was used to generate the request.
* - **statusText** `{string}` HTTP status text of the response.
* - **xhrStatus** `{string}` Status of the XMLHttpRequest (`complete`, `error`, `timeout` or `abort`).
*
* A response status code between 200 and 299 is considered a success status and will result in
* the success callback being called. Any response status code outside of that range is
@@ -1294,9 +1295,9 @@ function $HttpProvider() {
} else {
// serving from cache
if (isArray(cachedResp)) {
resolvePromise(cachedResp[1], cachedResp[0], shallowCopy(cachedResp[2]), cachedResp[3]);
resolvePromise(cachedResp[1], cachedResp[0], shallowCopy(cachedResp[2]), cachedResp[3], cachedResp[4]);
} else {
resolvePromise(cachedResp, 200, {}, 'OK');
resolvePromise(cachedResp, 200, {}, 'OK', 'complete');
}
}
} else {
@@ -1353,10 +1354,10 @@ function $HttpProvider() {
* - resolves the raw $http promise
* - calls $apply
*/
function done(status, response, headersString, statusText) {
function done(status, response, headersString, statusText, xhrStatus) {
if (cache) {
if (isSuccess(status)) {
cache.put(url, [status, response, parseHeaders(headersString), statusText]);
cache.put(url, [status, response, parseHeaders(headersString), statusText, xhrStatus]);
} else {
// remove promise from the cache
cache.remove(url);
@@ -1364,7 +1365,7 @@ function $HttpProvider() {
}
function resolveHttpPromise() {
resolvePromise(response, status, headersString, statusText);
resolvePromise(response, status, headersString, statusText, xhrStatus);
}
if (useApplyAsync) {
@@ -1379,7 +1380,7 @@ function $HttpProvider() {
/**
* Resolves the raw $http promise.
*/
function resolvePromise(response, status, headers, statusText) {
function resolvePromise(response, status, headers, statusText, xhrStatus) {
//status: HTTP response status code, 0, -1 (aborted by timeout / promise)
status = status >= -1 ? status : 0;
@@ -1388,12 +1389,13 @@ function $HttpProvider() {
status: status,
headers: headersGetter(headers),
config: config,
statusText: statusText
statusText: statusText,
xhrStatus: xhrStatus
});
}
function resolvePromiseWithResult(result) {
resolvePromise(result.data, result.status, shallowCopy(result.headers()), result.statusText);
resolvePromise(result.data, result.status, shallowCopy(result.headers()), result.statusText, result.xhrStatus);
}
function removePendingReq() {
+18 -7
View File
@@ -64,7 +64,7 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc
var jsonpDone = jsonpReq(url, callbackPath, function(status, text) {
// jsonpReq only ever sets status to 200 (OK), 404 (ERROR) or -1 (WAITING)
var response = (status === 200) && callbacks.getResponse(callbackPath);
completeRequest(callback, status, response, '', text);
completeRequest(callback, status, response, '', text, 'complete');
callbacks.removeCallback(callbackPath);
});
} else {
@@ -99,18 +99,29 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc
status,
response,
xhr.getAllResponseHeaders(),
statusText);
statusText,
'complete');
};
var requestError = function() {
// The response is always empty
// See https://xhr.spec.whatwg.org/#request-error-steps and https://fetch.spec.whatwg.org/#concept-network-error
completeRequest(callback, -1, null, null, '');
completeRequest(callback, -1, null, null, '', 'error');
};
var requestAborted = function() {
completeRequest(callback, -1, null, null, '', 'abort');
};
var requestTimeout = function() {
// The response is always empty
// See https://xhr.spec.whatwg.org/#request-error-steps and https://fetch.spec.whatwg.org/#concept-network-error
completeRequest(callback, -1, null, null, '', 'timeout');
};
xhr.onerror = requestError;
xhr.onabort = requestError;
xhr.ontimeout = requestError;
xhr.onabort = requestAborted;
xhr.ontimeout = requestTimeout;
forEach(eventHandlers, function(value, key) {
xhr.addEventListener(key, value);
@@ -160,14 +171,14 @@ function createHttpBackend($browser, createXhr, $browserDefer, callbacks, rawDoc
}
}
function completeRequest(callback, status, response, headersString, statusText) {
function completeRequest(callback, status, response, headersString, statusText, xhrStatus) {
// cancel timeout and subsequent timeout promise resolution
if (isDefined(timeoutId)) {
$browserDefer.cancel(timeoutId);
}
jsonpDone = xhr = null;
callback(status, response, headersString, statusText);
callback(status, response, headersString, statusText, xhrStatus);
}
};
+4 -4
View File
@@ -1354,8 +1354,8 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) {
return function() {
return angular.isNumber(status)
? [status, data, headers, statusText]
: [200, status, data, headers];
? [status, data, headers, statusText, 'complete']
: [200, status, data, headers, 'complete'];
};
}
@@ -1391,14 +1391,14 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) {
var response = wrapped.response(method, url, data, headers, wrapped.params(url));
xhr.$$respHeaders = response[2];
callback(copy(response[0]), copy(response[1]), xhr.getAllResponseHeaders(),
copy(response[3] || ''));
copy(response[3] || ''), copy(response[4]));
}
function handleTimeout() {
for (var i = 0, ii = responses.length; i < ii; i++) {
if (responses[i] === handleResponse) {
responses.splice(i, 1);
callback(-1, undefined, '');
callback(-1, undefined, '', undefined, 'timeout');
break;
}
}
+56 -1
View File
@@ -174,11 +174,12 @@ describe('$httpBackend', function() {
});
it('should complete the request on timeout', function() {
callback.and.callFake(function(status, response, headers, statusText) {
callback.and.callFake(function(status, response, headers, statusText, xhrStatus) {
expect(status).toBe(-1);
expect(response).toBe(null);
expect(headers).toBe(null);
expect(statusText).toBe('');
expect(xhrStatus).toBe('timeout');
});
$backend('GET', '/url', null, callback, {});
xhr = MockXhr.$$lastInstance;
@@ -189,6 +190,60 @@ describe('$httpBackend', function() {
expect(callback).toHaveBeenCalledOnce();
});
it('should complete the request on abort', function() {
callback.and.callFake(function(status, response, headers, statusText, xhrStatus) {
expect(status).toBe(-1);
expect(response).toBe(null);
expect(headers).toBe(null);
expect(statusText).toBe('');
expect(xhrStatus).toBe('abort');
});
$backend('GET', '/url', null, callback, {});
xhr = MockXhr.$$lastInstance;
expect(callback).not.toHaveBeenCalled();
xhr.onabort();
expect(callback).toHaveBeenCalledOnce();
});
it('should complete the request on error', function() {
callback.and.callFake(function(status, response, headers, statusText, xhrStatus) {
expect(status).toBe(-1);
expect(response).toBe(null);
expect(headers).toBe(null);
expect(statusText).toBe('');
expect(xhrStatus).toBe('error');
});
$backend('GET', '/url', null, callback, {});
xhr = MockXhr.$$lastInstance;
expect(callback).not.toHaveBeenCalled();
xhr.onerror();
expect(callback).toHaveBeenCalledOnce();
});
it('should complete the request on success', function() {
callback.and.callFake(function(status, response, headers, statusText, xhrStatus) {
expect(status).toBe(200);
expect(response).toBe('response');
expect(headers).toBe('');
expect(statusText).toBe('');
expect(xhrStatus).toBe('complete');
});
$backend('GET', '/url', null, callback, {});
xhr = MockXhr.$$lastInstance;
expect(callback).not.toHaveBeenCalled();
xhr.statusText = '';
xhr.response = 'response';
xhr.status = 200;
xhr.onload();
expect(callback).toHaveBeenCalledOnce();
});
it('should abort request on timeout', function() {
callback.and.callFake(function(status, response) {
expect(status).toBe(-1);
+34
View File
@@ -448,6 +448,28 @@ describe('$http', function() {
expect(callback).toHaveBeenCalledOnce();
});
it('should pass xhrStatus in response object when a request is successful', function() {
$httpBackend.expect('GET', '/url').respond(200, 'SUCCESS', {}, 'OK');
$http({url: '/url', method: 'GET'}).then(function(response) {
expect(response.xhrStatus).toBe('complete');
callback();
});
$httpBackend.flush();
expect(callback).toHaveBeenCalledOnce();
});
it('should pass xhrStatus in response object when a request fails', function() {
$httpBackend.expect('GET', '/url').respond(404, 'ERROR', {}, 'Not Found');
$http({url: '/url', method: 'GET'}).then(null, function(response) {
expect(response.xhrStatus).toBe('complete');
callback();
});
$httpBackend.flush();
expect(callback).toHaveBeenCalledOnce();
});
it('should pass in the response object when a request failed', function() {
$httpBackend.expect('GET', '/url').respond(543, 'bad error', {'request-id': '123'});
@@ -1623,6 +1645,17 @@ describe('$http', function() {
expect(callback).toHaveBeenCalledOnce();
}));
it('should cache xhrStatus as well', inject(function($rootScope) {
doFirstCacheRequest('GET', 201, null);
callback.and.callFake(function(response) {
expect(response.xhrStatus).toBe('complete');
});
$http({method: 'get', url: '/url', cache: cache}).then(callback);
$rootScope.$digest();
expect(callback).toHaveBeenCalledOnce();
}));
it('should use cache even if second request was made before the first returned', function() {
$httpBackend.expect('GET', '/url').respond(201, 'fake-response');
@@ -1788,6 +1821,7 @@ describe('$http', function() {
function(response) {
expect(response.data).toBeUndefined();
expect(response.status).toBe(-1);
expect(response.xhrStatus).toBe('timeout');
expect(response.headers()).toEqual(Object.create(null));
expect(response.config.url).toBe('/some');
callback();
+32 -20
View File
@@ -1343,8 +1343,8 @@ describe('ngMock', function() {
hb.flush();
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.calls.argsFor(0)).toEqual([201, 'second', '', '']);
expect(callback.calls.argsFor(1)).toEqual([200, 'first', '', '']);
expect(callback.calls.argsFor(0)).toEqual([201, 'second', '', '', 'complete']);
expect(callback.calls.argsFor(1)).toEqual([200, 'first', '', '', 'complete']);
});
@@ -1354,7 +1354,7 @@ describe('ngMock', function() {
hb('GET', '/url1', undefined, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK');
expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK', 'complete');
});
it('should default status code to 200', function() {
@@ -1377,7 +1377,19 @@ describe('ngMock', function() {
hb('GET', '/url1', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK');
expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK', 'complete');
});
it('should default xhrStatus to complete', function() {
callback.and.callFake(function(status, response, headers, x, xhrStatus) {
expect(xhrStatus).toBe('complete');
});
hb.expect('GET', '/url1').respond('some-data');
hb('GET', '/url1', null, callback);
hb.flush();
expect(callback).toHaveBeenCalled();
});
it('should take function', function() {
@@ -1388,7 +1400,7 @@ describe('ngMock', function() {
hb('GET', '/some?q=s', 'data', callback, {a: 'b'});
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(301, 'GET/some?q=s;data;a=b;q=s', 'Connection: keep-alive', 'Moved Permanently');
expect(callback).toHaveBeenCalledOnceWith(301, 'GET/some?q=s;data;a=b;q=s', 'Connection: keep-alive', 'Moved Permanently', undefined);
});
it('should decode query parameters in respond() function', function() {
@@ -1400,7 +1412,7 @@ describe('ngMock', function() {
hb('GET', '/url?query=l%E2%80%A2ng%20string%20w%2F%20spec%5Eal%20char%24&id=1234&orderBy=-name', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'id=1234;orderBy=-name;query=l•ng string w/ spec^al char$', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'id=1234;orderBy=-name;query=l•ng string w/ spec^al char$', '', '', undefined);
});
it('should include regex captures in respond() params when keys provided', function() {
@@ -1412,7 +1424,7 @@ describe('ngMock', function() {
hb('GET', '/1234/article/cool-angular-article', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'id=1234;name=cool-angular-article', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'id=1234;name=cool-angular-article', '', '', undefined);
});
it('should default response headers to ""', function() {
@@ -1425,8 +1437,8 @@ describe('ngMock', function() {
hb.flush();
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.calls.argsFor(0)).toEqual([200, 'first', '', '']);
expect(callback.calls.argsFor(1)).toEqual([200, 'second', '', '']);
expect(callback.calls.argsFor(0)).toEqual([200, 'first', '', '', 'complete']);
expect(callback.calls.argsFor(1)).toEqual([200, 'second', '', '', 'complete']);
});
it('should be able to override response of expect definition', function() {
@@ -1436,7 +1448,7 @@ describe('ngMock', function() {
hb('GET', '/url1', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete');
});
it('should be able to override response of when definition', function() {
@@ -1446,7 +1458,7 @@ describe('ngMock', function() {
hb('GET', '/url1', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete');
});
it('should be able to override response of expect definition with chaining', function() {
@@ -1455,7 +1467,7 @@ describe('ngMock', function() {
hb('GET', '/url1', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete');
});
it('should be able to override response of when definition with chaining', function() {
@@ -1464,7 +1476,7 @@ describe('ngMock', function() {
hb('GET', '/url1', null, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete');
});
});
@@ -1657,7 +1669,7 @@ describe('ngMock', function() {
canceler(); // simulate promise resolution
expect(callback).toHaveBeenCalledWith(-1, undefined, '');
expect(callback).toHaveBeenCalledWith(-1, undefined, '', undefined, 'timeout');
hb.verifyNoOutstandingExpectation();
hb.verifyNoOutstandingRequest();
});
@@ -1669,7 +1681,7 @@ describe('ngMock', function() {
hb('GET', '/url1', null, callback, null, 200);
$timeout.flush(300);
expect(callback).toHaveBeenCalledWith(-1, undefined, '');
expect(callback).toHaveBeenCalledWith(-1, undefined, '', undefined, 'timeout');
hb.verifyNoOutstandingExpectation();
hb.verifyNoOutstandingRequest();
}));
@@ -1831,7 +1843,7 @@ describe('ngMock', function() {
hb[shortcut]('/foo').respond('bar');
hb(method, '/foo', undefined, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'bar', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'bar', '', '', 'complete');
});
});
});
@@ -1846,7 +1858,7 @@ describe('ngMock', function() {
hb[routeShortcut](this, '/route').respond('path');
hb(this, '/route', undefined, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '', 'complete');
}
);
they('should match colon delimited parameters in ' + routeShortcut + ' $prop method', methods,
@@ -1854,7 +1866,7 @@ describe('ngMock', function() {
hb[routeShortcut](this, '/route/:id/path/:s_id').respond('path');
hb(this, '/route/123/path/456', undefined, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '', 'complete');
}
);
they('should ignore query param when matching in ' + routeShortcut + ' $prop method', methods,
@@ -1862,7 +1874,7 @@ describe('ngMock', function() {
hb[routeShortcut](this, '/route/:id').respond('path');
hb(this, '/route/123?q=str&foo=bar', undefined, callback);
hb.flush();
expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '', 'complete');
}
);
});
@@ -2462,7 +2474,7 @@ describe('ngMockE2E', function() {
$browser.defer.flush();
expect(realHttpBackend).not.toHaveBeenCalled();
expect(callback).toHaveBeenCalledOnceWith(200, 'passThrough override', '', '');
expect(callback).toHaveBeenCalledOnceWith(200, 'passThrough override', '', '', 'complete');
}));
it('should pass through to an httpBackend that uses the same $browser service', inject(function($browser) {