Skip to content

Aborting a fetch #27

Closed
Closed
@annevk

Description

@annevk
Member

Goal

Provide developers with a method to abort something initiated with fetch() in a way that is not overly complicated.

Previous discussion

Viable solutions

We have two contenders. Either fetch() returns an object that is more than a promise going forward or fetch() is passed something, either an object or a callback that gets handed an object.

A promise-subclass

In order to not clash with cancelable promises (if they ever materialize) we should pick a somewhat unique method name for abortion. I think terminate() would fit that bill.

var f = fetch(url)
f.terminate()

Note: the Twitter-sphere seemed somewhat confused about the capabilities of this method. It would most certainly terminate any ongoing stream activity as well. It's not limited to the "lifetime" of the promise.

A controller

The limited discussion on es-discuss https://esdiscuss.org/topic/cancelable-promises seemed to favor a controller. There are two flavors that keep coming back. Upfront construction:

var c = new FetchController
fetch(url, {controller: c})
c.abort()

Revealing constructor pattern:

fetch(url, {controller: c => c.abort()})

Open issues

  • What is the effect on the promise? Both forever-pending and explicit rejection have reasonable arguments. We could offer the choice to the developer, but what should be the default?
  • What is the effect on the stream? I suspect the Streams Standard is already conclusive on this.
  • What syntax of the above two-three solutions do we favor?

Activity

WebReflection

WebReflection commented on Mar 26, 2015

@WebReflection

Thanks for the effort folks, I'm chiming in to follow up and with a couple of questions:

  • wouldn't be wise to put this resolution on hold until there is a definitive take on Promise-land and cancel-ability?
  • in this post you named abort and cancel and you think that terminate would pay the naming bill. Wouldn't be wise to use similar XHR intent developers already know instead of introducing terminate for the fetch and abort for the controller?

Best Regards

annevk

annevk commented on Mar 26, 2015

@annevk
MemberAuthor

This is only somewhat-related to promises being cancelable. This is about cancelling a fetch. It does matter somewhat for one of the open issues and yes, we might end up having to wait or decide shipping is more important, we'll see.

And we won't have a promise-subclass and a controller. Either will do. The subclass uses terminate() to avoid conflicting with cancelable-promises which might want to use either cancel() and/or abort() (as mentioned btw).

jakearchibald

jakearchibald commented on Mar 26, 2015

@jakearchibald
Collaborator

The controller approach is certainly the quickest way we'll solve this, but it's pretty ugly, I'd like to treat it as a last resort & try for the cancellable promises approach.

Cancellation based on ref-counting

I'm still a fan of the ref counting approach, and from the thread on es-discuss it seems that libraries take a similar approach.

var rootFetchP = fetch(url).then(r => r.json());

var childFetchP1 = rootFetchP.then(data => fetch(data[0]));
var childFetchP2 = rootFetchP.then(data => fetch(data[1]));
var childP = Promise.resolve(rootFetchP).then(r => r.text());

childFetchP1.abort();
// …aborts fetch(data[0]), or waits until it hits that point in the chain, then aborts.
// fetch(url) continues

childFetchP2.abort();
// …aborts fetch(data[1]), or waits until it hits that point in the chain, then aborts.
// fetch(url) aborts also, if not already complete. Out of refs.
// childP hangs as a result

rootFetchP.then(data => console.log(data));
// …would hang because the fetch has aborted (unless it completed before abortion)

Cancelling a promise that hadn't already settled would cancel all its child CancellablePromises.

Observing cancellation

If a promise is cancelled, it needs to be observable. Yes, you don't want to do the same as "catch", but you often want to do "finally", as in stop spinners and other such UI. Say we had:

var cancellablePromise = new CancellablePromise(function(resolve, reject) {
  // Business as usual
}, {
  onCancel() {
    // Called when this promise is explicitly cancelled,
    // or when all child cancellable promises are cancelled,
    // or when the parent promise is cancelled.
  }
});

// as a shortcut:
CancellablePromise.resolve().then(onResolve, onReject, onCancel)
// …attaches the onCancel callback to the returned promise

// maybe also:
cancellablePromise.onCancel(func);
// as a shortcut for .then(undefined, undefined, func)

Usage in fetch

Fetch would return a CancellablePromise that would terminate the request onCancel. The stream reading methods response.text() etc would return their own CancellablePromise that would terminate the stream.

If you're doing your own stream work, you're in charge, and should return your own CancellablePromise:

var p = fetch(url).then(r => r.json());
p.abort(); // cancels either the stream, or the request, or neither (depending on which is in progress)

var p2 = fetch(url).then(response => {
  return new CancellablePromise(resolve => {
    drainStream(
      response.body.pipeThrough(new StreamingDOMDecoder())
    ).then(resolve);
  }, { onCancel: _ => response.body.cancel() });
});
jakearchibald

jakearchibald commented on Mar 26, 2015

@jakearchibald
Collaborator

Clearing up a question from IRC:

var fetchPromise = fetch(url).then(response => {
  // noooope:
  fetchPromise.abort();
  var jsonPromise = response.json().then(data => console.log(data));
});

In the above, fetchPromise.abort() does nothing as the promise has already settled. The correct way to write this would be:

var jsonPromise = fetch(url).then(r => r.json());

Now jsonPromise.abort() would cancel either the request, or the response, whichever is in progress.

mariusGundersen

mariusGundersen commented on Mar 26, 2015

@mariusGundersen

Since calling abort might not abort the fetch (request), I don't think the method should be called abort (or cancel, as it is in some other specificatins), but rather ignore, since that is all it can guarantee to do. For example,

var request = fetch(url);
var json = request.then(r => r.json);
var text = request.then(r => r.text);
text.ignore(); //doesn't abort the fetch, only ignores the result.

This would also work well with promise implementations that don't support cancellations (like the current spec), since calling abort on it might not abort a promise early in the chain, but calling ignore will always ignore the result. For example:

//doSomething does not return a cancellablePromise, so calling abort won't abort what is
//happening inside doSomething. ignore makes it clear that only the result will be ignored,
//any data done can't be guaranteed to be aborted.
doSomething().then(url => fetch(url)).then(r => r.json).ignore()
jakearchibald

jakearchibald commented on Mar 26, 2015

@jakearchibald
Collaborator

Since calling abort might not abort the fetch (request)

If you call it on the promise returned by fetch() it will abort the request, but it won't abort the response. Unless of course the request is complete.

Your example will fail because you have two consumers of the same stream, we reject in this case. It should be:

var requestPromise = fetch(url);
var jsonPromise = requestPromise.then(r => r.clone().json());
var textPromise = requestPromise.then(r => r.text());
textPromise.abort();

In this case, textPromise.abort() cancels the reading of the stream, but wouldn't abort the fetch because there are other uncancelled children. If for some reason the json completed earlier, the raw response would be buffered in memory to allow the other read. Aborting the text read would free up this buffer.

I don't think "ignore" is a great name for something that has these kind of consequences. Maybe there's a better name than abort though, I'm more interested in the behavior than the name, I just picked abort because of XHR, maybe cancel is a better fit.

jyasskin

jyasskin commented on Mar 26, 2015

@jyasskin
Member

@jakearchibald, in your proposal, it looks like p.then() increments the refcount, but Promise.resolve(p) doesn't? And that promises start out with a 0 refcount, so that you don't have to also abort() the initial fetch()? This seems odd to me, although it gets around the need to expose GC.

Does any of this flow through to the async/await syntax, or do you have to manipulate the promises directly to use cancellation?

I'm also worried about subsequent uses of the original promise hanging instead of rejecting.

jakearchibald

jakearchibald commented on Mar 26, 2015

@jakearchibald
Collaborator

@jyasskin the refcount would be increased by cancellable promises. So cancellablePromise.then() increases the refcount, as would CancellablePromise.resolve(cancellablePromise), Promise.resolve(cancellablePromise) would not.

If you use async/await you're opting into a sync-like flow, so yeah if you want the async stuff you need to use promises, or we decide that cancellation results in a rejection with an abort error.

onCancel could be passed (resolve, reject) so the promise vendor could decide what the sync equivalent should be.

I'm also worried about subsequent uses of the original promise hanging instead of rejecting.

Yeah, it should onCancel.

jyasskin

jyasskin commented on Mar 26, 2015

@jyasskin
Member

'k. Say we have a careless or cancellation-ignorant library author who writes:

function myTransform(yourPromise) {
  return yourPromise
    .then(value => transform(value))
    .then(value => transform2(value));

If we had myTransform(fetch(...)).cancel(), that intermediate, uncancelled .then() will prevent the fetch from ever aborting, right? (This would be fixed if GC contributed to cancellation, but there's a lot of resistance to making GC visible.)

On the other hand, in a CancellationToken approach like https://msdn.microsoft.com/en-us/library/dd997364%28v=vs.110%29.aspx, we'd write:

var cancellationSource = new CancellationTokenSource();
var result = myTransform(fetch(..., cancellationSource.token));
cancellationSource.cancel();

And the fetch would wind up rejecting despite the intermediate function's obliviousness to cancellation.

The "revealing constructor pattern" is bad for cancellation tokens because it requires special infrastructure to be able to cancel two fetches from one point. On the other side, cancellation tokens require special infrastructure to be able to use one fetch for multiple different purposes.

Either of Anne's solutions can, of course, be wrapped into something compatible with either CancellablePromise or CancellationToken if the goal here is to get something quickly instead of waiting for the long-term plan to emerge.

martinthomson

martinthomson commented on Mar 26, 2015

@martinthomson
Contributor

Another alternative:

let abortFetch;
let p = new Promise((resolve, reject) => { abortFetch = reject; });
fetch(url, { abort: p }).then(success, failure);
// on error
abortFetch();

That would make the activity on fetch dependent on a previous promise in a similar fashion (but more intimate) to this:

promiseYieldingThing().then(result => fetch(url)).then(success, failure);

I don't like the implicit nature of what @jakearchibald suggests here.

getify

getify commented on Mar 26, 2015

@getify

TL;DR

I would like to speak strongly in favor of the "controller" approach and strongly opposed to some notion of a cancelable promise (at least externally so).

Also, I believe it's a mistake to consider the cancelation of a promise as a kind of automatic "back pressure" to signal to the promise vendor that it should stop doing what it was trying to do. There are plenty of established notions for that kind of signal, but cancelable promises is the worst of all possible options.

Cancelable Promise

I would observe that it's more appropriate to recognize that promise (observation) and cancelation (control) are two separate classes of capabilities. It is a mistake to conflate those capabilities, exactly as it was (and still is) a mistake to conflate the promise with its other resolutions (resolve/reject).

A couple of years ago this argument played out in promise land with the initial ideas about deferreds. Even though we didn't end up with a separate deferred object, we did end up with the control capabilities belonging only to the promise creation (constructor). If there's a new subclass (or extension of existing) where cancelation is a new kind of control capability, it should be exposed in exactly the same way as resolve and reject:

new CancelablePromise(function(resolve,reject,cancel) {
   // ..
});

The notion that this cancelation capability would be exposed in a different way (like a method on the promise object itself) than resolve/reject is inconsistent/incoherent at best.

Moreover, making a single promise reference capable of canceling the promise violates a very important tenet in not only software design (avoiding "action at a distance") but specifically promises (that they are externally immutable once created).

If I vend a promise and hand a reference to it to 3 different parties for observation, two of them internal and one external, and that external one can unilaterally call abort(..) on it, and that affects my internal observation of the promise, then the promise has lost all of its trustability as an immutable value.

That notion of trustability is one of the foundational principles going back 6+'ish years to when promises were first being discussed for JS. It was so important back then that I was impressed that immutable trustability was at least as important a concept as anything about temporality (async future value). In the intervening years of experimentation and standardization, that principle seems to have lost a lot of its luster. But we'd be better served to go back and revisit those initial principles rather than ignore them.

Controller

If a cancelable promise exists, but the cancelation capability is fully self-contained within the promise creation context, then the vendor of the promise is the exclusive entity that can decide if it wants to extract these capabilities and make them publicly available. This has been a suggested pattern long before cancelation was under discussion:

var pResolve, pReject, p = new Promise(function(resolve,reject){
   pResolve = resolve; pReject = reject;
});

In fact, as I understand it, this is one of several important reasons why the promise constructor is synchronous, so that capability extraction can be immediate (if necessary). This capability extraction pattern is entirely appropriate to extend to the notion of cancelability, where you'd just extract pCancel as well.

Now, what do you, promise vendor, do with such extracted capabilities? If you want to provide them to some consumer along with the promise itself, you package these things up together and return them as a single value, like perhaps:

function vendP() {
   var pResolve, pReject, pCancel, promise = new CancelablePromise(function(resolve,reject,cancel){
      pResolve = resolve; pReject = reject; pCancel = cancel;
   });
   return { promise, pResolve, pReject, pCancel };
}

Now, you can share the promise around and it's read-only immutable and observable, and you can separately decide who gets the control capabilities. For example, I'd send only promise to some external consumer, but I might very well retain the pCancel internally for some usage.

Of course this return object should be thought of as the controller from the OP.

If we're going to conflate promise cancelation with back-pressure (I don't think we should -- see below!) to signal the fetch should abort, at least this is how we should do it.

Abort != Promise Cancelation... Abort == async Cancel

In addition to what I've observed about how promise cancelation should be designed, I don't think we should let the cancelation of a promise mean "abort the fetch". That's back-pressure, and there are other more appropriate ways to model that than promise cancelation.

In fact, it seems to me the only reason you would want to do so is merely for the convenience of having the fetch API return promises. Mere convenience should be way down the priority list of viable arguments for a certain design.

I would observe that the concern of what to do with aborting fetches is quite symmetric with the concern of how/if to make an ES7 async function cancelable.

In that thread, I suggested that an async function should return an object (ahem, controller) rather than a promise itself.

To do promise chaining from an async function call in that way, it's only slightly less graceful. The same would be true for a fetch API returning a controller.

async foo() { .. }

// ..

foo().promise.then(..);
fetch(..).promise.then(..);

But if you want to access and retain/use the control capabilities for the async function (like signaling it to early return/cancel, just as generators can be), the controller object would look like:

var control = foo();
// control.return();   // or whatever we bikeshed it to be called
control.promise.then(..);

I also drew up this crappy quick draft of a diagram for a cancelable async function via this controller concept:

That's basically identical to what I'm suggesting we should do with fetch.


PS: Is it mere coincidence that canceling a fetch and canceling an async function both ended up issue number 27 in their respective repos? I think surely not! :)

jhusain

jhusain commented on Mar 26, 2015

@jhusain

Related: Composition Function Proposal for ES2016

Might be interested in the toy (but instructive) definition of Task and how it is composed using async/await.

https://github.com/jhusain/compositional-functions

372 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

    Development

    No branches or pull requests

      Participants

      @jeffbski@eplawless@tschaub@martinthomson@appden

      Issue actions

        Aborting a fetch · Issue #27 · whatwg/fetch