Skip to content

Commit

Permalink
Merge pull request #452 from nickuraltsev/cancel
Browse files Browse the repository at this point in the history
Adding support for request cancellation
  • Loading branch information
mzabriskie committed Oct 10, 2016
2 parents d982cf9 + 8f30490 commit 4882ce5
Show file tree
Hide file tree
Showing 18 changed files with 454 additions and 13 deletions.
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;
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) {
this.message = message;
}

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;
});
});
});
});

0 comments on commit 4882ce5

Please sign in to comment.