4

Let's say I have a search function to make an HTTP call. Every call can take a different amount of time. So I need to cancel the last HTTP request and wait only for the last call

async function search(timeout){

   const data = await promise(timeout)
   return data;

}
// the promise function is only for visualizing an http call
function promise(timeout){
   return new Promise(resolve,reject){
       setTimeout(function(){      
           resolve()
       },timeout) 
   }
}
search(200)
.then(function(){console.log('search1 resolved')})
.catch(function() {console.log('search1 rejected')})
search(2000)
.then(function(){console.log('search2 resolved')})
.catch(function(){console.log('search2 rejected')})
search(1000)
.then(function(){console.log('search3 resolved')})
.catch(function(){console.log('search3 rejected')})

Need to see "search1 resolved" "search2 rejected" "search3 resolved"

How can I achieve this scenario?

trincot
  • 317,000
  • 35
  • 244
  • 286
Eran Abir
  • 1,077
  • 5
  • 19
  • 34
  • If you have multiple promises chained together- the chain will stop when one of them is rejected. You don't have them chained so they will all execute every time. – chevybow Sep 18 '19 at 21:19
  • Aksi bit rekated ti question but you should move to async await its much simplier – Loki Sep 18 '19 at 21:20
  • this is not the scenario I want. I need to cancel only the last call of promise function if not yet resolved the promise function just visualize an async call like an API i want only the last promise to be resolve or any other promise that resolved before 1000 ms – Eran Abir Sep 18 '19 at 21:20
  • Your `Promise` construction should be `new Promise((resolve, reject) => ...)`, also, what's the problem logging `promise2 resolved`? – Washington Guedes Sep 18 '19 at 21:21
  • just edit my question – Eran Abir Sep 18 '19 at 21:27
  • What about the throttle approach? – Washington Guedes Sep 18 '19 at 21:27
  • I don't understand the question. What is the problem when you just ignore the resolution of a promise you don't need anymore? – trincot Sep 18 '19 at 21:29
  • But ok, I got your point, maybe the easiest would be to have a `lastSearch` variable with a `new Date` value, and only update your `data` with the last returned `data` if the `search` function is later than `lastSearch`, then also update your variable. – Washington Guedes Sep 18 '19 at 21:30
  • @trincot I just want to cancel my last HTTP request (if not already resolved) if there is an new request – Eran Abir Sep 18 '19 at 21:33
  • I don't think You can cancel a already called promise. `await` will wait forever(until the request is fulfilled or rejected) – TheMaster Sep 18 '19 at 21:34
  • Actually just handling one promise at a time should work :/ – Washington Guedes Sep 18 '19 at 21:36
  • @Washington If op was handling one at a time, how will he know which is the slowest(the call taking 2000ms)? It seems he wants to race them all and kill the last promise horse manually without giving it a chance to die on it's own or finish the race. – TheMaster Sep 18 '19 at 21:39
  • @TheMaster exactly – Eran Abir Sep 18 '19 at 21:48
  • you need to think about user clicking on a search button 10 times every click will make an HTTP request I want to show the user only resolved request or the last request – Eran Abir Sep 18 '19 at 21:49
  • @TheMaster You are right, I wasn't able to code it handling one promise after another. – Washington Guedes Sep 18 '19 at 21:57
  • You can try `Promise.race` – TheMaster Sep 18 '19 at 22:08
  • 2
    Your question is ambiguous. If really the last promise should be cancelled when a new request comes in, your code example should produce "search1 rejected", not "search1 resolved", since the second call of `search` should identify the previous promise as not resolved. If however, you want to give precedence to which-ever promise resolves first, then the output should be "search1 resolved", and the other two rejected. Please clarify the logic in your question, and make the code consistent with that explanation. – trincot Sep 19 '19 at 12:42

3 Answers3

3

Promises aren't cancelable as such, but are cancelled in a limited sense by causing them to be rejected.

With that in mind, cancellation can be achieved with a small amount of elaboration around Promise.race() and the promise-returning function you wish to be cancelable.

function makeCancellable(fn) {
    var reject_; // cache for the latest `reject` executable
    return function(...params) {
        if(reject_) reject_(new Error('_cancelled_')); // If previous reject_ exists, cancel it.
                                                       // Note, this has an effect only if the previous race is still pending.
        let canceller = new Promise((resolve, reject) => { // create canceller promise
            reject_ = reject; // cache the canceller's `reject` executable
        });
        return Promise.race([canceller, fn.apply(null, params)]); // now race the promise of interest against the canceller
    }
}

Assuming your http call function is named httpRequest (promise is confusing):

const search = makeCancellable(httpRequest);

Now, each time search() is called, the cached reject executable is called to "cancel" the preceding search (if it exists and its race has not already fulfilled).

// Search 1: straightforward - nothing to cancel - httpRequest(200) is called
search(200)
.then(function() { console.log('search1 resolved') })
.catch(function(err) { console.log('search3 rejected', err) });

// Search 2: search 1 is cancelled and its catch callback fires - httpRequest(2000) is called
search(2000)
.then(function() { console.log('search2 resolved') })
.catch(function(err) { console.log('search3 rejected', err) });

// Search 3: search 2 is cancelled and its catch callback fires - httpRequest(1000) is called
search(1000)
.then(function() { console.log('search3 resolved') })
.catch(function(err) { console.log('search3 rejected', err) });

If necessary, the catch callbacks can test err.message === '_cancelled_' in order to distinguish between cancellation and other causes of rejection.

Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
Roamer-1888
  • 19,138
  • 5
  • 33
  • 44
  • Your code does not produce the requested behavior. With your function, `search(2000)` causes `search(200)` to be _cancelled immediately_ when `search(2000)` is called. The requested behavior was to cancel _only if the second promise settles before the first_. Note the request was for `search(200)` to resolve, not reject, because it settles before `search(2000)` does. – Patrick Roberts Sep 19 '19 at 02:11
  • I don't know how you reached that understanding. It makes no sense. – Roamer-1888 Sep 19 '19 at 08:47
  • Okay, well let's just look at what was explicitly stated then. They requested `to see search1 resolved search2 rejected search3 resolved`. Your solution does not do that. – Patrick Roberts Sep 19 '19 at 09:45
  • I agree, but it does answer the headline question "How to cancel last Promise if not resolve[d]?" – Roamer-1888 Sep 19 '19 at 11:14
  • I don't understand why this is marked as accepted. It does not check whether the pending promise would still resolve before the newer one (as noted also by Patrick). It might well lead to a variant of [starvation](https://en.wikipedia.org/wiki/Starvation_(computer_science)). As a consequence, it not even looks at tackling the situation where you have *two or more* pending (racing) requests, and a next one resolves before those, which would need more than one previous promise to be cancelled. – trincot Sep 19 '19 at 12:27
  • @trincot, the question is ambiguous. This is a solution to the headline question and the opening paragraph. The OP's "need to see search1 resolved search2 rejected search3 resolved" is inconsistent with both of those. He needs to clarify. If I'm wrong, I will happily delete. – Roamer-1888 Sep 19 '19 at 12:33
  • 2
    Indeed, it is ambiguous. – trincot Sep 19 '19 at 12:43
  • @trincot, thank you for your call for clarification. Spot on. – Roamer-1888 Sep 19 '19 at 12:49
  • FYI, you unfortunately can't delete an accepted answer. All you can do is edit yours to be correct, unless OP is willing to accept a different answer. – Patrick Roberts Sep 19 '19 at 14:43
  • Thanks for the reminder @PatrickRoberts, I'll cross that bridge if/when necessary. – Roamer-1888 Sep 19 '19 at 15:05
  • Simple .. if the first search request takes 1000ms and i did another search request that will take 2000ms so the first search will be resolved and also the second one but if the second one will take 500ms the first one will be cancelded/rejected and only the second request will be resolved – Eran Abir Sep 20 '19 at 14:30
  • Sorry @Eran Abir, more confused than ever. A few points ... (1) The rule set around this is still not completely clear; (2) Whatever the rule set, it seems to be somewhat zany - what are you actually trying to achieve? (3) I'm pretty certain that my answer doesn't conform to the desired rules - you might like to take another look at the other answers. – Roamer-1888 Sep 20 '19 at 20:56
  • On the other hand ... does the OP intend that `search(200)`, `search(2000)`, `search(1000)` each eminates from a different event thread, separated by say 1 second? If so, then this solution gives the requested outome of "resolved", "rejected", "resolved" - https://jsfiddle.net/tmc25fne/ – Roamer-1888 Sep 24 '19 at 17:16
  • I edited your answer because for some reason it contained the same text duplicated. Let me know if I accidentally made a mistake a removed something that wasn't duplicated because I didn't read the whole block of the removed part, just the first and last paragraph. – Patrick Roberts Sep 26 '19 at 14:22
  • Glitch in the Matrix? – Roamer-1888 Sep 26 '19 at 18:18
  • The more I think about it, the more convinced I am that the scenarion in my comment above starting "On the other hand ... " is correct. It's the only thing that makes any sense. – Roamer-1888 Sep 26 '19 at 18:20
2

You can define a factory function to encapsulate your search() method with the requested cancellation behavior. Note that while Promise constructors are normally considered an anti-pattern, it is necessary in this case to keep a reference to each reject() function in the pending set in order to implement the early cancellation.

function cancellable(fn) {
  const pending = new Set();

  return function() {
    return new Promise(async (resolve, reject) => {
      let settle;
      let result;

      try {
        pending.add(reject);
        settle = resolve;
        result = await Promise.resolve(fn.apply(this, arguments));
      } catch (error) {
        settle = reject;
        result = error;
      }

      // if this promise has not been cancelled
      if (pending.has(reject)) {
        // cancel the pending promises from calls made before this
        for (const cancel of pending) {
          pending.delete(cancel);

          if (cancel !== reject) {
            cancel();
          } else {
            break;
          }
        }

        settle(result);
      }
    });
  };
}

// internal API function
function searchImpl(timeout) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, timeout);
  });
}

// pass your internal API function to cancellable()
// and use the return value as the external API function
const search = cancellable(searchImpl);

search(200).then(() => {
  console.log('search1 resolved');
}, () => {
  console.log('search1 rejected');
});

search(2000).then(() => {
  console.log('search2 resolved');
}, () => {
  console.log('search2 rejected');
});

search(1000).then(() => {
  console.log('search3 resolved');
}, () => {
  console.log('search3 rejected');
});

search(500).then(function() {
  console.log('search4 resolved');
}, () => {
  console.log('search4 rejected');
});

This factory function utilizes the insertion-order iteration of Set to cancel only the pending promises returned by calls made before the call returning the promise that has just settled.


Note that cancelling the promise using reject() does not terminate any underlying asynchronous process that the creation of the promise has initiated. Each HTTP request will continue to completion, as well as any of the other internal handlers that are called within search() before the promise is settled.

All cancellation() does is cause the internal state of the returned promise to transition from pending to rejected instead of fulfilled if a later promise settles first so that the appropriate handler(s) for promise resolution will be called by the consuming code.

Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
1

Similar to the answer of PatrickRoberts, I would suggest to use a Map to maintain a list of pending promises.

I would however not maintain a reference to the reject callback outside of the promise constructor. I would suggest to abandon the idea of rejecting an outdated promise. Instead, just ignore it. Wrap it in a promise that never resolves or rejects, but just remains a dead promise object that does not ever change state. In fact, that silent promise could be the same one for every case where you need it.

Here is how that could look:

const delay = (timeout, data) => new Promise(resolve => setTimeout(() => resolve(data), timeout));
const godot = new Promise(() => null);

const search = (function () { // closure...
    const requests = new Map; // ... so to have shared variables
    let id = 1;
    
    return async function search() {
        let duration = Math.floor(Math.random() * 2000);
        let request = delay(duration, "data" + id); // This would be an HTTP request
        requests.set(request, id++);
        let data = await request;
        if (!requests.has(request)) return godot; // Never resolve...
        for (let [pendingRequest, pendingId] of requests) {
            if (pendingRequest === request) break;
            requests.delete(pendingRequest);
            // Just for demo we output something. 
            // Not needed in a real scenario:
            console.log("ignoring search " + pendingId);
        }
        requests.delete(request);
        return data;
    }    
})();

const reportSuccess = data => console.log("search resolved with " + data);
const reportError = err => console.log('search rejected with ' + err);

// Generate a search at regular intervals.
// In a real scenario this could happen in response to key events.
// Each promise resolves with a random duration.
setInterval(() => search().then(reportSuccess).catch(reportError), 100);
trincot
  • 317,000
  • 35
  • 244
  • 286
  • I figured out what was bugging me about the premise of this. While I agree there are scenarios where you would prefer not to call `.then()` _or_ `.catch()`, there are cases where this would leak resources released during finalization via `.finally()` or `try { await ... } finally { ... }`. For that reason, I think promises should always settle, even if you have to differentiate between a rejection due to an exception or cancellation. – Patrick Roberts Sep 25 '19 at 21:36
  • @PatrickRoberts, of course, this is like saying *"if my code expects A to happen, I should make A happen"*. If your code needs promises to settle to do some clean-up, you should... settle them. But promises are just objects, so you can just deal with them like that. How you deal with releasing resources is essentially not a promise-related topic. But again, if you design your code in a way that assumes that promises are always settled, then you should be consistent with that choice. I am not debating that, of course. See also [this answer](https://stackoverflow.com/a/36735167/5459839). – trincot Sep 26 '19 at 04:49
  • Thanks for the insightful response. I took a look at the answer you linked, but I'm still a bit baffled by the notion that "how you deal with releasing resources is essentially not a promise-related topic". I might be inclined to agree with that were it not for the fact that promises are deeply integrated into the language with the sole intention of signaling completion for non-blocking operations. If you remove the signaling of completion from the equation, you remove the ability to clean up manually allocated resources unless you use a [`WeakRef`](https://github.com/tc39/proposal-weakrefs). – Patrick Roberts Sep 26 '19 at 14:31
  • By the way, I don't dislike your answer (in fact your upvote is from me). I just wanted to make sure I clearly understood the design choice to remain unsettled rather than reject for cancellation. – Patrick Roberts Sep 26 '19 at 14:36