Page MenuHomePhabricator

Chaining abortable API promises is very unpleasant – introduce abort signals to mw.Api
Open, Needs TriagePublic

Description

mw.Api methods all return abortable promises (that is, jQuery.Promise objects, extended with an additional #abort method, which cancels any in-flight HTTP request and rejects the promise). This is very convenient for common use cases where the caller no longer wants the result of a request they were waiting for, and aborts it and replaces it with a new one – for example, in an autocompletion search, when the user has typed a new search term.

// 1: Abortable with no extra effort
function getStuff1() {
	var api = new mw.Api();
	return api.get( { meta: 'userinfo' } );
}
var myPromise = getStuff1();
myPromise.abort();

However, it's very inconvenient when you need to chain such promises, because calling #then will return another promise that no longer has an #abort method, and you need to extend it yourself. If you just need to post-process the result, and keep the option to abort the request, the required code is still bearable compared to the naive version that loses abortability:

// 2A: Not abortable
function getStuff2A() {
	var api = new mw.Api();
	return api.get( { meta: 'userinfo' } ).then( function ( resp ) {
		return resp.query.userinfo.name;
	} );
}

// 2B: Abortable
function getStuff2B() {
	var api = new mw.Api();
	var abortable = api.get( { meta: 'userinfo' } );
	return abortable.then( function ( resp ) {
		return resp.query.userinfo.name;
	} ).promise( { abort: abortable.abort } );
}

However, if you need to chain multiple API requests, it takes an absurd amount of bookkeeping to make sure that #abort actually cancels whichever HTTP request is currently in progress, and rejects the final promise, especially if you compare it to how simple the naive version is:

// 3A: Not abortable
function getStuff3A() {
	var api = new mw.Api();
	return api.get( { meta: 'userinfo' } ).then( function ( resp ) {
		var name = resp.query.userinfo.name;
		return api.get( { list: 'usercontribs', ucuser: name } );
	} ).then( function ( resp ) {
		return resp.query.usercontribs.length;
	} );
}

// 3B: Abortable
// (if you're willing to cut some corners, and fail to reject in edge cases, this can be a little simpler)
function getStuff3B() {
	var api = new mw.Api();
	var abortedPromise = $.Deferred().reject( 'http', { textStatus: 'abort', exception: 'abort' } );
	var abortable;
	var aborted;
	abortable = api.get( { meta: 'userinfo' } );
	return abortable.then( function ( resp ) {
		if ( aborted ) {
			return abortedPromise;
		}
		var name = resp.query.userinfo.name;
		abortable = api.get( { list: 'usercontribs', ucuser: name } );
		return abortable;
	} ).then( function ( resp ) {
		if ( aborted ) {
			return abortedPromise;
		}
		return resp.query.usercontribs.length;
	} ).promise( {
		abort: function () {
			aborted = true;
			if ( abortable ) {
				abortable.abort();
			}
		}
	} );
}

I was so peeved by this after writing https://gerrit.wikimedia.org/r/c/mediawiki/extensions/VisualEditor/+/938958 recently that I went on a quest for a better method. It turns out that the Fetch API has a cancellation mechanism that works much better for this use case, and that is easy to "backport" into mw.Api. (There are other benefits and drawbacks, which are boring, you can probably find blog posts about them somewhere.)

One notable drawback is that it's a bit more verbose for the simplest cases, so I think we should support it in addition to abortable promises, and we should not deprecate them.

// 2C: Abortable with signals – a bit more verbose than version 2B
function getStuff2C() {
	var api = new mw.Api();
	var abort = new AbortController();
	var ajaxOptions = { signal: abort.signal };
	return api.get( { meta: 'userinfo' }, ajaxOptions ).then( function ( resp ) {
		return resp.query.userinfo.name;
	} ).promise( abort );
}

// 3C: Abortable with signals – much less verbose than version 3B
function getStuff3C() {
	var api = new mw.Api();
	var abort = new AbortController();
	var ajaxOptions = { signal: abort.signal };
	return api.get( { meta: 'userinfo' }, ajaxOptions ).then( function ( resp ) {
		var name = resp.query.userinfo.name;
		return api.get( { list: 'usercontribs', ucuser: name }, ajaxOptions );
	} ).then( function ( resp ) {
		return resp.query.usercontribs.length;
	} ).promise( abort );
}

One notable benefit is that this mechanism also works in code using async/await instead of explicit #then, while abortable promises don't (because they are subsumed by native promises, and there's no way to extend those with an #abort method).

// 4C: Abortable with signals and async-await
async function getStuff4C( ajaxOptions ) {
	const api = new mw.Api();
	const resp = await api.get( { meta: 'userinfo' }, ajaxOptions );
	const name = resp.query.userinfo.name;
	const resp2 = await api.get( { list: 'usercontribs', ucuser: name }, ajaxOptions );
	return resp2.query.usercontribs.length;
}
let abort = new AbortController();
let myPromise = getStuff4C( { signal: abort.signal } );
abort.abort();
await myPromise;

Event Timeline

Change 959350 had a related patch set uploaded (by Bartosz Dziewoński; author: Bartosz Dziewoński):

[mediawiki/core@master] [WIP] mw.Api: Introduce abort signals

https://gerrit.wikimedia.org/r/959350

Since a few of you seem to be interested, an update: I've got it working, and I've updated some code in extensions to demonstrate the improvements: https://gerrit.wikimedia.org/r/q/topic:AbortController. However, I'm having trouble figuring out the browser compatibility (there's conflicting information on the internet about which Safari versions actually support this), and also running into a mysterious test failure I can't reproduce locally. See comments on the patch for details. If anyone would like to help, it'd be appreciated.

The changes are finished now and ready for review.

However, I'm having trouble figuring out the browser compatibility (there's conflicting information on the internet about which Safari versions actually support this)

I found https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#browser_compatibility which looks like it mentions the browser types that support this and their corresponding versions.

From the look of things, it looks like it's supported by most browser vendors these days which looks like a green light for maybe promoting this :)

AbortSignal even comes with a reason property (https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason) which can let the user/developer know why the operation was aborted, I seem to like that one.