Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for request cancellation #452

Merged
merged 10 commits into from Oct 10, 2016
2 changes: 2 additions & 0 deletions .travis.yml
@@ -1,4 +1,6 @@
language: node_js
node_js:
- node
email:
on_failure: change
on_success: never
Expand Down
50 changes: 49 additions & 1 deletion README.md
Expand Up @@ -305,7 +305,12 @@ These are the available config options for making requests. Only the `url` is re
proxy: {
host: '127.0.0.1',
port: 9000
}
},

// `cancelToken` specifies a cancel token that can be used to cancel the request
// (see Cancellation section below for details)
cancelToken: new CancelToken(function (cancel) {
})
}
```

Expand Down Expand Up @@ -457,6 +462,49 @@ axios.get('/user/12345', {
})
```

## Cancellation

You can cancel a request using a *cancel token*.

> The axios cancel token API is based on the [cancelable promises proposal](https://github.com/tc39/proposal-cancelable-promises), which is currently at Stage 1.

You can create a cancel token using the `CancelToken.source` factory as shown below:

```js
var CancelToken = axios.CancelToken;
var source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token
}).catch(function(thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// handle error
}
});

// cancel the request (the message parameter is optional)
source.cancel('Operation canceled by the user.');
```

You can also create a cancel token by passing an executor function to the `CancelToken` constructor:

```js
var CancelToken = axios.CancelToken;
Copy link
Member

Choose a reason for hiding this comment

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

I think I'll end up using this version more frequently, because I find it clearer than the previous one (it's basically a deferred object). I'd put it before the CancelToken constructor version so people will find it more approachable.

Maybe it's just a personal preference and not everybody will see it that way.

Copy link
Member Author

Choose a reason for hiding this comment

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

@rubennorte Yeah, I agree. I've just updated README.md.

var cancel;

axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
// An executor function receives a cancel function as a parameter
cancel = c;
})
});

// cancel the request
cancel();
```

## Semver

Until axios reaches a `1.0` release, breaking changes will be released with a new minor version. For example `0.5.1`, and `0.5.4` will have the same API, but `0.6.0` will have breaking changes.
Expand Down
32 changes: 32 additions & 0 deletions axios.d.ts
Expand Up @@ -41,6 +41,7 @@ export interface AxiosRequestConfig {
httpAgent?: any;
httpsAgent?: any;
proxy?: AxiosProxyConfig;
cancelToken?: CancelToken;
}

export interface AxiosResponse {
Expand All @@ -66,6 +67,34 @@ export interface Promise<V> {
export interface AxiosPromise extends Promise<AxiosResponse> {
}

export interface CancelStatic {
new (message?: string): Cancel;
}

export interface Cancel {
message: string;
}

export interface Canceler {
(message?: string): void;
}

export interface CancelTokenStatic {
new (executor: (cancel: Canceler) => void): CancelToken;
source(): CancelTokenSource;
}

export interface CancelToken {
promise: Promise<Cancel>;
reason?: Cancel;
throwIfRequested(): void;
}

export interface CancelTokenSource {
token: CancelToken;
cancel: Canceler;
}

export interface AxiosInterceptorManager<V> {
use(onFulfilled: (value: V) => V | Promise<V>, onRejected?: (error: any) => any): number;
eject(id: number): void;
Expand All @@ -90,6 +119,9 @@ export interface AxiosStatic extends AxiosInstance {
(config: AxiosRequestConfig): AxiosPromise;
(url: string, config?: AxiosRequestConfig): AxiosPromise;
create(config?: AxiosRequestConfig): AxiosInstance;
Cancel: CancelStatic;
CancelToken: CancelTokenStatic;
isCancel(value: any): boolean;
all<T>(values: (T | Promise<T>)[]): Promise<T[]>;
spread<T, R>(callback: (...args: T[]) => R): (array: T[]) => R;
}
Expand Down
9 changes: 9 additions & 0 deletions lib/adapters/http.js
Expand Up @@ -185,6 +185,15 @@ module.exports = function httpAdapter(config) {
}, config.timeout);
}

if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
req.abort();
reject(cancel);
aborted = true;
});
}

// Send the request
if (utils.isStream(data)) {
data.pipe(req);
Expand Down
9 changes: 9 additions & 0 deletions lib/adapters/xhr.js
Expand Up @@ -153,6 +153,15 @@ module.exports = function xhrAdapter(config) {
request.upload.addEventListener('progress', config.onUploadProgress);
}

if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
request.abort();
reject(cancel);
// Clean up request
request = null;
});
}

if (requestData === undefined) {
requestData = null;
Expand Down
5 changes: 5 additions & 0 deletions lib/axios.js
Expand Up @@ -34,6 +34,11 @@ axios.create = function create(defaultConfig) {
return createInstance(defaultConfig);
};

// Expose Cancel & CancelToken
axios.Cancel = require('./cancel/Cancel');
axios.CancelToken = require('./cancel/CancelToken');
axios.isCancel = require('./cancel/isCancel');

// Expose all/spread
axios.all = function all(promises) {
return Promise.all(promises);
Expand Down
19 changes: 19 additions & 0 deletions lib/cancel/Cancel.js
@@ -0,0 +1,19 @@
'use strict';

/**
* A `Cancel` is an object that is thrown when an operation is canceled.
*
* @class
* @param {string=} message The message.
*/
function Cancel(message) {
Copy link
Member

Choose a reason for hiding this comment

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

Any reason why Cancel isn't an instance of Error?

Copy link
Member Author

Choose a reason for hiding this comment

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

Cancel must not derive from Error according the cancelable promises spec. Please see this for details.

this.message = message;
Copy link
Member

Choose a reason for hiding this comment

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

As I said in a previous comment, I'd do this.isCancellation = true; here

}

Cancel.prototype.toString = function toString() {
return 'Cancel' + (this.message ? ': ' + this.message : '');
};

Cancel.prototype.__CANCEL__ = true;

module.exports = Cancel;
57 changes: 57 additions & 0 deletions lib/cancel/CancelToken.js
@@ -0,0 +1,57 @@
'use strict';

var Cancel = require('./Cancel');

/**
* A `CancelToken` is an object that can be used to request cancellation of an operation.
*
* @class
* @param {Function} executor The executor function.
*/
function CancelToken(executor) {
if (typeof executor !== 'function') {
throw new TypeError('executor must be a function.');
}

var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});

var token = this;
executor(function cancel(message) {
if (token.reason) {
// Cancellation has already been requested
return;
}

token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}

/**
* Throws a `Cancel` if cancellation has been requested.
*/
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
if (this.reason) {
throw this.reason;
}
};

/**
* Returns an object that contains a new `CancelToken` and a function that, when called,
* cancels the `CancelToken`.
*/
CancelToken.source = function source() {
var cancel;
var token = new CancelToken(function executor(c) {
cancel = c;
});
return {
token: token,
cancel: cancel
};
};

module.exports = CancelToken;
5 changes: 5 additions & 0 deletions lib/cancel/isCancel.js
@@ -0,0 +1,5 @@
'use strict';

module.exports = function isCancel(value) {
return !!(value && value.__CANCEL__);
};
36 changes: 27 additions & 9 deletions lib/core/dispatchRequest.js
Expand Up @@ -2,15 +2,27 @@

var utils = require('./../utils');
var transformData = require('./transformData');
var isCancel = require('../cancel/isCancel');
var defaults = require('../defaults');

/**
* Throws a `Cancel` if cancellation has been requested.
*/
function throwIfCancellationRequested(config) {
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
}

/**
* Dispatch a request to the server using the configured adapter.
*
* @param {object} config The config that is to be used for the request
* @returns {Promise} The Promise to be fulfilled
*/
module.exports = function dispatchRequest(config) {
throwIfCancellationRequested(config);

// Ensure headers exist
config.headers = config.headers || {};

Expand Down Expand Up @@ -38,6 +50,8 @@ module.exports = function dispatchRequest(config) {
var adapter = config.adapter || defaults.adapter;

return adapter(config).then(function onAdapterResolution(response) {
throwIfCancellationRequested(config);

// Transform response data
response.data = transformData(
response.data,
Expand All @@ -46,16 +60,20 @@ module.exports = function dispatchRequest(config) {
);

return response;
}, function onAdapterRejection(error) {
// Transform response data
if (error && error.response) {
error.response.data = transformData(
error.response.data,
error.response.headers,
config.transformResponse
);
}, function onAdapterRejection(reason) {
if (!isCancel(reason)) {
throwIfCancellationRequested(config);

// Transform response data
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}

return Promise.reject(error);
return Promise.reject(reason);
});
};
6 changes: 6 additions & 0 deletions test/specs/api.spec.js
Expand Up @@ -34,6 +34,12 @@ describe('static api', function () {
it('should have factory method', function () {
expect(typeof axios.create).toEqual('function');
});

it('should have Cancel, CancelToken, and isCancel properties', function () {
expect(typeof axios.Cancel).toEqual('function');
expect(typeof axios.CancelToken).toEqual('function');
expect(typeof axios.isCancel).toEqual('function');
});
});

describe('instance api', function () {
Expand Down
66 changes: 66 additions & 0 deletions test/specs/cancel.spec.js
@@ -0,0 +1,66 @@
var Cancel = axios.Cancel;
var CancelToken = axios.CancelToken;

describe('cancel', function() {
beforeEach(function() {
jasmine.Ajax.install();
});

afterEach(function() {
jasmine.Ajax.uninstall();
});

describe('when called before sending request', function() {
it('rejects Promise with a Cancel object', function (done) {
var source = CancelToken.source();
source.cancel('Operation has been canceled.');
axios.get('/foo', {
cancelToken: source.token
}).catch(function (thrown) {
expect(thrown).toEqual(jasmine.any(Cancel));
expect(thrown.message).toBe('Operation has been canceled.');
done();
});
});
});

describe('when called after request has been sent', function() {
it('rejects Promise with a Cancel object', function (done) {
var source = CancelToken.source();
axios.get('/foo/bar', {
cancelToken: source.token
}).catch(function (thrown) {
expect(thrown).toEqual(jasmine.any(Cancel));
expect(thrown.message).toBe('Operation has been canceled.');
done();
});

getAjaxRequest().then(function (request) {
// call cancel() when the request has been sent, but a response has not been received
source.cancel('Operation has been canceled.');
request.respondWith({
status: 200,
responseText: 'OK'
});
});
});

it('calls abort on request object', function (done) {
var source = CancelToken.source();
var request;
axios.get('/foo/bar', {
cancelToken: source.token
}).catch(function() {
// jasmine-ajax sets statusText to 'abort' when request.abort() is called
expect(request.statusText).toBe('abort');
done();
});

getAjaxRequest().then(function (req) {
// call cancel() when the request has been sent, but a response has not been received
source.cancel();
request = req;
});
});
});
});