Day 16: Promise Time Limit
Given an asynchronous function fn
and a time t
in milliseconds, return a new time limited version of the input function. fn
takes arguments provided to the time limited function.
The time limited function should follow these rules:
- If the
fn
completes within the time limit oft
milliseconds, the time limited function should resolve with the result. - If the execution of the
fn
exceeds the time limit, the time limited function should reject with the string"Time Limit Exceeded"
.
Example 1:
Input:
fn = async (n) => {
await new Promise(res => setTimeout(res, 100));
return n * n;
}
inputs = [5]
t = 50
Output: {"rejected":"Time Limit Exceeded","time":50}
Explanation:
const limited = timeLimit(fn, t)
const start = performance.now()
let result;
try {
const res = await limited(...inputs)
result = {"resolved": res, "time": Math.floor(performance.now() - start)};
} catch (err) {
result = {"rejected": err, "time": Math.floor(performance.now() - start)};
}
console.log(result) // Output
The provided function is set to resolve after 100ms. However, the time limit is set to 50ms. It rejects at t=50ms because the time limit was reached.
Example 2:
Input:
fn = async (n) => {
await new Promise(res => setTimeout(res, 100));
return n * n;
}
inputs = [5]
t = 150
Output: {"resolved":25,"time":100}
Explanation:
The function resolved 5 * 5 = 25 at t=100ms. The time limit is never reached.
Example 3:
Input:
fn = async (a, b) => {
await new Promise(res => setTimeout(res, 120));
return a + b;
}
inputs = [5,10]
t = 150
Output: {"resolved":15,"time":120}
Explanation:
The function resolved 5 + 10 = 15 at t=120ms. The time limit is never reached.
Example 4:
Input:
fn = async () => {
throw "Error";
}
inputs = []
t = 1000
Output: {"rejected":"Error","time":0}
Explanation:
The function immediately throws an error.
Constraints:
0 <= inputs.length <= 10
0 <= t <= 1000
fn
returns a promise
Solution
/**
* @param {Function} fn
* @param {number} t
* @return {Function}
*/
var timeLimit = function(fn, t) {
return async function(...args) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject("Time Limit Exceeded");
}, t);
fn(...args).then(resolve).catch(reject);
})
}
};
/**
* const limited = timeLimit((t) => new Promise(res => setTimeout(res, t)), 100);
* limited(150).catch(console.log) // "Time Limit Exceeded" at t=100ms
*/
Overview
This question asks you to enhance an asynchronous function such that the promise it returns will automatically reject if the time limit elapses.
It is recommended you first read the Sleep Editorial as it covers topics on asynchronous programming not discussed here. You may also want to read up on the topic of functions that returns functions: Allow One Function Call Editorial and Memoize Editorial.
Use-cases for Time Limit
Long Running Processes
You may have code which repeats over and over again. A common example of this would be loading data into a cache and keeping it in sync with the data source.
async function repeatProcessIndefinitely() {
while (true) {
try {
await someProcess();
} catch (e) {
console.error(e);
}
}
}
If someProcess
were to ever never fulfill, the loop would get frozen and nothing would happen. Forcing someProcess
to throw an error would unfreeze the process.
An important consideration is that code in someProcess
can still continue executing even if the promise was rejected. So you might have multiple blocks of code executing in parallel. A better solution may be fix the underlying issue which caused the freeze or to implement proper cancellation. Consider solving Design Cancellable Function to implement true cancellation.
To force the promise someProcess()
returns to reject after an hour:
const ONE_HOUR_IN_MS = 3600 * 1000;
const timeLimitedProcess = timeLimit(someProcess, ONE_HOUR_IN_MS);
Notify Users of Failure
Imagine a user requested to download a file which you expect should take less than 10 seconds to download. If the download is taking too long, rather than let the user wait, it may be better to just give up and show the user an error message.
Similar to the first use-case, this really should only be done as a last resort. It's likely better to implement a loading indicator and/or fix the underlying issue causing the slowness.
Approach 1: Call Function Inside New Promise
We can create a new Promise that resolves as soon as the passed function resolves or rejects. This essentially emulates the passed function without effecting it at all. To satisfy the requirement, all we have to do is add a setTimeout
which can force the promise to reject prematurely.
var timeLimit = function(fn, t) {
return async function(...args) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject("Time Limit Exceeded");
}, t);
fn(...args).then(resolve).catch(reject);
})
}
};
Approach 2: Handle Clearing Timeout
In the above example, if the function's promise fulfills before the time limit elapses, the reject logic will still be triggered unnecessarily at some point in the future. This has no effect on the external behavior of the function because a promise can only resolve or reject once. Rejecting a resolved promise does nothing.
However, imagine if the time limit was set for a very long time. These blocks of code will have to be stored in memory for a long time until they are eventually executed by the JavaScript event loop. This could be considered a memory leak and a professional implementation should avoid this. However it isn't a requirement for this problem.
To achieve this, we can use the fact that setTimeout
returns an integer id which is effectively a reference to the block of code to be executed. You can then use the built-in clearTimeout(id)
to abort the execution of that code.
We can also take advantage of the Promise.finally
method. The callback passed to it will be executed when the promise resolves or rejects.
var timeLimit = function(fn, t) {
return async function(...args) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject("Time Limit Exceeded");
}, t);
fn(...args)
.then(resolve)
.catch(reject)
.finally(() => clearTimeout(timeout));
})
}
};
Approach 3: Promise Race
We can simplify the code by using the Promise.race
function. It accepts an array of promises and returns a new promise. The returned promise resolves or rejects with the first value one of the promises resolves or rejects with.
var timeLimit = function(fn, t) {
return async function(...args) {
const timeLimitPromise = new Promise((resolve, reject) => {
setTimeout(() => reject("Time Limit Exceeded"), t)
});
const returnedPromise = fn(...args);
return Promise.race([timeLimitPromise, returnedPromise]);
}
};
Approach 4: Async/Await + Clearing Timeout
We can modify approach 2 to use async/await syntax. You can only use the await
keyword inside an async
function, so we must add the async
keyword to the callback passed to new Promise
. Now the callback returns a promise, instead of undefined
. Note that this acceptable because the Promise
constructor doesn't look at what is returned by the callback. However, a common mistake is inappropriately making code async.
const filteredList = [1, 2, 3].filter(async n => n > 2);
console.log(filteredList); // [1, 2, 3]
You may expect the above code to evaluate the async function and return [3]
. However, all async functions return promises and all promises are truthy, therefore nothing is filtered from the list.
In the following code, we refactor the .then().catch().finally()
from approach 2 into a try catch
block.
var timeLimit = function(fn, t) {
return async function(...args) {
return new Promise(async (resolve, reject) => {
const timeout = setTimeout(() => {
reject("Time Limit Exceeded");
}, t);
try {
const result = await fn(...args);
resolve(result);
} catch(err) {
reject(err);
}
clearTimeout(timeout);
});
};
};