39

I'm with Node.js and TypeScript and I'm using async/await. This is my test case:

async function doSomethingInSeries() {
    const res1 = await callApi();
    const res2 = await persistInDB(res1);
    const res3 = await doHeavyComputation(res1);
    return 'simle';
}

I'd like to set a timeout for the overall function. I.e. if res1 takes 2 seconds, res2 takes 0.5 seconds, res3 takes 5 seconds I'd like to have a timeout that after 3 seconds let me throw an error.

With a normal setTimeout call is a problem because the scope is lost:

async function doSomethingInSeries() {
    const timerId = setTimeout(function() {
        throw new Error('timeout');
    });

    const res1 = await callApi();
    const res2 = await persistInDB(res1);
    const res3 = await doHeavyComputation(res1);

    clearTimeout(timerId);

    return 'simle';
}

And I cannot catch it with normal Promise.catch:

doSomethingInSeries().catch(function(err) {
    // errors in res1, res2, res3 will be catched here
    // but the setTimeout thing is not!!
});

Any ideas on how to resolve?

nkint
  • 11,513
  • 31
  • 103
  • 174

3 Answers3

66

You can use Promise.race to make a timeout:

Promise.race([
    doSomethingInSeries(),
    new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 11.5e3))
]).catch(function(err) {
    // errors in res1, res2, res3 and the timeout will be caught here
})

You cannot use setTimeout without wrapping it in a promise.

Federkun
  • 36,084
  • 8
  • 78
  • 90
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • 1
    @nkint: We don't need to clear it because the rejection is not considered when `doSomethingInSeries()` settles first. We could clear it, but that would be rather complicated (optimally `race` would have a way to cancel the slower promises, but that's not possible with native promises). – Bergi May 09 '16 at 16:18
  • 1
    Maybe I have problem in testing async stuff but seems to me that even if `race` rejects, doSomethingInSeries keep on going its own stuffs. How to be sure to stop everything inside `doSomethingInSeries` if the timeout rejects? – nkint May 18 '16 at 09:39
  • 2
    @nkint: You cannot stop a promise - it only represents a result (disclaimer: unless you use a promise implementation that supports cancellation). So if there is anything you need to do about the ongoing stuff, you need to do it manually (in the same way you did it with `clearTimeout` in your answer below). – Bergi May 18 '16 at 09:51
  • `it only represents a result` - vary clear, thanks. It's a little bit messy. Some promise I'm `await`-ing in `doSomethingInSeries` is a call to `request` with a retry.. I need some other way to handle it, thank you anyway. – nkint May 18 '16 at 10:33
  • To have promise-wraped version of `setTimeout` / `clearTimeout` you may have a look on [await-timeout](https://github.com/vitalets/await-timeout) – vitalets Nov 01 '17 at 13:08
6

Ok I found this way:

async function _doSomethingInSeries() {
    const res1 = await callApi();
    const res2 = await persistInDB(res1);
    const res3 = await doHeavyComputation(res1);
    return 'simle';
}

async function doSomethingInSeries(): Promise<any> {
  let timeoutId;

  const delay = new Promise(function(resolve, reject){
    timeoutId = setTimeout(function(){
      reject(new Error('timeout'));
    }, 1000);
  });

  // overall timeout
  return Promise.race([delay, _doSomethingInSeries()])
    .then( (res) => {
      clearTimeout(timeoutId);
      return res;
    });

}

Anyone errors?

The things that smells a bit to me is that using Promises as asynchronicity strategy will send us to allocate too many object that some other strategy needs but this is off-topic.

nkint
  • 11,513
  • 31
  • 103
  • 174
  • 1
    You're not clearing the timeout if `_doSomethingInSeries()` fails. You should use `try { return await Promise.race(…); } finally { clearTimeout(timeoutId); }` – Bergi May 09 '16 at 16:22
2

Problem with @Bergi answer that doSomethingInSeries continues executing even if you already rejected the promise. It is much better to cancel it.

LATEST ANSWER

You can try use AbortController for that. Check the old answer to see how to use it - api is similar.

Keep in mind that task is not cancelled immediately, so continuation (awaiting, then or catch) is not called exactly after timeout.

To guarantee that you can combine this and @Bergi approach.

OLD ANSWER

This is how it should look like:

async const doSomethingInSeries = (cancellationToken) => {
  cancellationToken.throwIfCancelled();

  const res1 = await callApi();

  cancellationToken.throwIfCancelled();

  const res2 = await persistInDB(res1);

  cancellationToken.throwIfCancelled();

  const res3 = await doHeavyComputation(res1);

  cancellationToken.throwIfCancelled();

  return 'simle';
}

Here is simple implementation:

const makeCancellationToken = (tag) => {
  let cancelled = false;

  return {
    isCancelled: () => cancelled,
    cancel: () => {
      cancelled = true;
    },
    throwIfCancelled: () => {
      if (cancelled) {
        const error = new Error(`${tag ?? 'Task'} cancelled`);
        error.cancelled = true;
        throw error;
      }
    }
  }
}

And finally usage:

const cancellationToken = makeCancellationToken('doSomething')

setTimeout(cancellationToken.cancel, 5000);

try {
  await doSomethingInSeries(cancellationToken);
} catch (error) {
  if (error.cancelled) {
    // handle cancellation
  }
}

Keep in mind that task is not cancelled immediately, so continuation (awaiting, then or catch) is not called exactly after 5 secs.

To guarantee that you can combine this and @Bergi approach.

Alexander Danilov
  • 3,038
  • 1
  • 30
  • 35