Promise - is it possible to force cancel a promise

I use ES6 Promises to manage all of my network data retrieval and there are some situations where I need to force cancel them.

Basically the scenario is such that I have a type-ahead search on the UI where the request is delegated to the backend has to carry out the search based on the partial input. While this network request (#1) may take a little bit of time, user continues to type which eventually triggers another backend call (#2)

Here #2 naturally takes precedence over #1 so I would like to cancel the Promise wrapping request #1. I already have a cache of all Promises in the data layer so I can theoretically retrieve it as I am attempting to submit a Promise for #2.

But how do I cancel Promise #1 once I retrieve it from the cache?

Could anyone suggest an approach?

Answers:

Answer

No. We can't do that yet.

ES6 promises do not support cancellation yet. It's on its way, and its design is something a lot of people worked really hard on. Sound cancellation semantics are hard to get right and this is work in progress. There are interesting debates on the "fetch" repo, on esdiscuss and on several other repos on GH but I'd just be patient if I were you.

But, but, but.. cancellation is really important!

It is, the reality of the matter is cancellation is really an important scenario in client-side programming. The cases you describe like aborting web requests are important and they're everywhere.

So... the language screwed me!

Yeah, sorry about that. Promises had to get in first before further things were specified - so they went in without some useful stuff like .finally and .cancel - it's on its way though, to the spec through the DOM. Cancellation is not an afterthought it's just a time constraint and a more iterative approach to API design.

So what can I do?

You have several alternatives:

  • Use a third party library like bluebird who can move a lot faster than the spec and thus have cancellation as well as a bunch of other goodies - this is what large companies like WhatsApp do.
  • Pass a cancellation token.

Using a third party library is pretty obvious. As for a token, you can make your method take a function in and then call it, as such:

function getWithCancel(url, token) { // the token is for cancellation
   var xhr = new XMLHttpRequest;
   xhr.open("GET", url);
   return new Promise(function(resolve, reject) {
      xhr.onload = function() { resolve(xhr.responseText); });
      token.cancel = function() {  // SPECIFY CANCELLATION
          xhr.abort(); // abort request
          reject(new Error("Cancelled")); // reject the promise
      };
      xhr.onerror = reject;
   });
};

Which would let you do:

var token = {};
var promise = getWithCancel("/someUrl", token);

// later we want to abort the promise:
token.cancel();

Your actual use case - last

This isn't too hard with the token approach:

function last(fn) {
    var lastToken = { cancel: function(){} }; // start with no op
    return function() {
        lastToken.cancel();
        var args = Array.prototype.slice.call(arguments);
        args.push(lastToken);
        return fn.apply(this, args);
    };
}

Which would let you do:

var synced = last(getWithCancel);
synced("/url1?q=a"); // this will get canceled 
synced("/url1?q=ab"); // this will get canceled too
synced("/url1?q=abc");  // this will get canceled too
synced("/url1?q=abcd").then(function() {
    // only this will run
});

And no, libraries like Bacon and Rx don't "shine" here because they're observable libraries, they just have the same advantage user level promise libraries have by not being spec bound. I guess we'll wait to have and see in ES2016 when observables go native. They are nifty for typeahead though.

Answer

Standard proposals for cancellable promises have failed.

A promise is not a control surface for the async action fulfilling it; confuses owner with consumer. Instead, create asynchronous functions that can be cancelled through some passed-in token.

Another promise makes a fine token, making cancel easy to implement with Promise.race:

Example: Use Promise.race to cancel the effect of a previous chain:

let cancel = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancel();
  let p = new Promise(resolve => cancel = resolve);
  Promise.race([p, getSearchResults(term)]).then(results => {
    if (results) {
      console.log(`results for "${term}"`,results);
    }
  });
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search: <input id="input">

Here we're "cancelling" previous searches by injecting an undefined result and testing for it, but we could easily imagine rejecting with "CancelledError" instead.

Of course this doesn't actually cancel the network search, but that's a limitation of fetch. If fetch were to take a cancel promise as argument, then it could cancel the network activity.

I've proposed this "Cancel promise pattern" on es-discuss, exactly to suggest that fetch do this.

Answer

I have checked out Mozilla JS reference and found this:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race

Let's check it out:

var p1 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 500, "one"); 
});
var p2 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 100, "two"); 
});

Promise.race([p1, p2]).then(function(value) {
  console.log(value); // "two"
  // Both resolve, but p2 is faster
});

We have here p1, and p2 put in Promise.race(...) as arguments, this is actually creating new resolve promise, which is what you require.

Answer

For Node.js and Electron, I'd highly recommend using Promise Extensions for JavaScript (Prex). Its author Ron Buckton is one of the key TypeScript engineers and also is the guy behind the current TC39's ECMAScript Cancellation proposal. The library is well documented and chances are some of Prex will make to the standard.

On a personal note and coming from a heavy C# background, I like very much the fact that Prex is modelled upon the existing Cancellation in Managed Threads framework, i.e. based on the approach taken with CancellationTokenSource/CancellationToken .NET APIs. In my experience, those have been very handy to implement robust cancellation logic in managed apps.

I also verified it to work within a browser by bundling Prex using Browserify.

Here is an example of a delay with cancellation, using prex.CancellationTokenSource:

// https://stackoverflow.com/a/53093799

const prex = require('prex');

// delayWithCancellation
function delayWithCancellation(timeoutMs, token) {
  console.log(`delayWithCancellation: ${timeoutMs}`);

  return createCancellablePromise((resolve, reject, setCancelListener) => {
    token.throwIfCancellationRequested();
    const id = setTimeout(resolve, timeoutMs);
    setCancelListener(e => clearTimeout(id));
  }, token);
}

// main
async function main() {
  const tokenSource = new prex.CancellationTokenSource();
  setTimeout(() => tokenSource.cancel(), 2000); // cancel after 1500ms

  const token = tokenSource.token;

  await delayWithCancellation(1000, token);
  console.log("successfully delayed."); // we should reach here

  await delayWithCancellation(1500, token);
  console.log("successfully delayed."); // we should not reach here
}

// createCancellablePromise - create a Promise that gets rejected
// when cancellation is requested on the token source
async function createCancellablePromise(executor, token) {
  if (!token) {
    return await new Promise(executor);
  }

  // prex.Deferred is similar to TaskCompletionsource in .NET
  const d = new prex.Deferred();

  // function executor(resolve, reject, setCancelListener)
  // function oncancel(CancelError)
  executor(d.resolve, d.reject, oncancel =>
    d.oncancel = oncancel);

  const reg = token.register(() => {
    // the token cancellation callback is synchronous,
    // and so is the d.oncancel callback
    try {
      // capture the CancelError
      token.throwIfCancellationRequested();
    }
    catch (e) {
      try {
        d.oncancel && d.oncancel(e);
        // reject here if d.oncancel did not resolve/reject
        d.reject(e);
      }
      catch (e2) {
        d.reject(e2);
      }
    }
  });

  try {
    await d.promise;
  }
  finally {
    reg.unregister();
  }
}

main().catch(e => console.log(e));

Note that cancellation is a race. I.e., a promise may have been resolved successfully, but by the time you observe it (with await or then), the cancellation may have been triggered as well. It's up to you how you handle this race, but it doesn't hurts to call token.throwIfCancellationRequested() an extra time, like I do above.

Answer

I faced similar problem recently.

I had a promise based client (not a network one) and i wanted to always give the latest requested data to the user to keep the UI smooth.

After struggling with cancellation idea, Promise.race(...) and Promise.all(..) i just started remembering my last request id and when promise was fulfilled i was only rendering my data when it matched the id of a last request.

Hope it helps someone.

Answer

See https://www.npmjs.com/package/promise-abortable

$ npm install promise-abortable
Answer

You can make the promise reject before finishing:

// Our function to cancel promises receives a promise and return the same one and a cancel function
const cancellablePromise = (promiseToCancel) => {
  let cancel
  const promise = new Promise((resolve, reject) => {
    cancel = reject
    promiseToCancel
      .then(resolve)
      .catch(reject)
  })
  return {promise, cancel}
}

// A simple promise to exeute a function with a delay
const waitAndExecute = (time, functionToExecute) => new Promise((resolve, reject) => {
  timeInMs = time * 1000
  setTimeout(()=>{
    console.log(`Waited ${time} secs`)
    resolve(functionToExecute())
  }, timeInMs)
})

// The promise that we will cancel
const fetchURL = () => fetch('https://pokeapi.co/api/v2/pokemon/ditto/')

// Create a function that resolve in 1 seconds. (We will cancel it in 0.5 secs)
const {promise, cancel} = cancellablePromise(waitAndExecute(1, fetchURL))

promise
  .then((res) => {
    console.log('then', res) // This will executed in 1 second
  })
  .catch(() => {
    console.log('catch') // We will force the promise reject in 0.5 seconds
  })

waitAndExecute(0.5, cancel) // Cancel previous promise in 0.5 seconds, so it will be rejected before finishing. Commenting this line will make the promise resolve

Unfortunately the fetch call has already be done, so you will see the call resolving in the Network tab. Your code will just ignore it.

Answer

Because @jib reject my modify, so I post my answer here. It's just the modfify of @jib's anwser with some comments and using more understandable variable names.

Below I just show examples of two different method: one is resolve() the other is reject()

let cancelCallback = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by resolve()
        return resolve('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results == 'Canceled') {
      console.log("error(by resolve): ", results);
    } else {
      console.log(`results for "${term}"`, results);
    }
  });
}


input2.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by reject()
        return reject('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results !== 'Canceled') {
      console.log(`results for "${term}"`, results);
    }
  }).catch(error => {
    console.log("error(by reject): ", error);
  })
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search(use resolve): <input id="input">
<br> Search2(use reject and catch error): <input id="input2">

Tags

Recent Questions

Top Questions

Home Tags Terms of Service Privacy Policy DMCA Contact Us Javascript

©2020 All rights reserved.