Last active
May 24, 2025 12:42
-
-
Save isocroft/0d435dc4e021b62dc1b9ddf31424c86f to your computer and use it in GitHub Desktop.
A basic (yet slightly coupled) setup for cancelling either of 2 concurrent tasks racing each other to completion (i.e. to a promise settlement)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class HttpResponseError extends Error { | |
constructor (message, response) { | |
super(message); | |
this.response = response | |
} | |
} | |
async function simpleFetch( | |
url /* @type( string | RequestInfo | URL ) */, | |
options /* @type( Omit<RequestInit, "signal"> && { controller: AbortController } ) */ | |
) { | |
const $controller = options.controller | |
if ($controller) { | |
delete options.controller | |
if ($controller instanceof AbortController) { | |
options.signal = $controller.signal | |
} | |
} | |
const result = await fetch(url, options); | |
if (!result.ok) { | |
if ($controller) { | |
setTimeout(() => { | |
if ($controller instanceof AbortController) { | |
$controller.signal.dispatchEvent(new CustomEvent('cleanup')) | |
} | |
}, 0); | |
} | |
throw new HttpResponseError('http server error', result) | |
} | |
if ($controller) { | |
setTimeout(() => { | |
if ($controller instanceof AbortController) { | |
$controller.signal.dispatchEvent(new CustomEvent('cleanup')) | |
} | |
}, 0); | |
} | |
return result; | |
} | |
async function extractDataFromResponse (result /* @type( Response ) */) { | |
let data = null /* @type( ResponseDataType ) */; | |
/* @HINT: We need to ensure that we can correctly retrieve | |
the response data irrespective of the content-type | |
of the response */ | |
if (result.type === "basic" || result.headers.has('Content-Type')) { | |
const mimeType = result.headers.get('Content-Type') || ''; | |
if (mimeType.endsWith('json')) { | |
data = await result.json() /* @type( ResponseDataType ) */; | |
} else if ( | |
mimeType.endsWith('csv') | |
|| mimeType.startsWith('text') | |
|| mimeType.endsWith('ms-excel') | |
|| mimeType.endsWith('-separated-values') | |
) { | |
data = (await result.text()) /* @type( ResponseDataType ) */; | |
} else if ( | |
mimeType.endsWith('pdf') | |
|| mimeType.startsWith('image') | |
|| mimeType.endsWith('octet-stream') | |
) { | |
data = (await result.blob()) /* @type( ResponseDataType ) */; | |
} | |
} | |
return data; | |
} | |
async function customFetch ( | |
url /* @type( string | RequestInfo | URL ) */, | |
options /* @type( Omit<RequestInit, "signal"> && { controller: AbortController } ) */ | |
) { | |
let result = null /* @type( Response ) */; | |
try { | |
result = await simpleFetch( | |
url, | |
options | |
); | |
return extractDataFromResponse(result); | |
} catch (error) { | |
if (error instanceof Error) { | |
console.error(error.message); | |
if (error.name === "AbortError") { | |
return Promise.reject(error); | |
} | |
} | |
throw error; | |
} | |
} | |
function sleep ( | |
delayInMilliseconds /* @type( number ) */, | |
{ controller } /* @type( { controller: AbortController } ) */ | |
) { | |
let combinedSignal = AbortSignal.any([ | |
controller.signal, | |
// ๐๐พ๐๐พ: In case the event loop delays execution of `setTimeout` callback by additional 500 milliseconds | |
AbortSignal.timeout(delayInMilliseconds + 500), | |
]) /* @type( AbortSignal ) */; | |
return new Promise((resolve, reject) => { | |
let markFinished = false | |
let markAborted = false | |
const timeoutId = setTimeout(() => { | |
try { | |
combinedSignal.throwIfAborted(); | |
controller.signal.throwIfAborted(); | |
console.log("Timeout elapsed!"); | |
markAborted = true | |
controller.abort(); | |
return resolve(true); | |
} catch (err) { | |
return reject(err); | |
} finally { | |
combinedSignal = null | |
} | |
}, delayInMilliseconds); | |
combinedSignal.addEventListener('abort', () => { | |
if (markFinished) { | |
return resolve(false); | |
} | |
if (markAborted) { | |
return; | |
} | |
console.log("Is aborting...."); | |
if (timeoutId) { | |
console.log("Timeout id cancellation..."); | |
clearTimeout(timeoutId); | |
} | |
reject(new Error('Aborted')); | |
}); | |
controller.signal.addEventListener('cleanup', () => { | |
markFinished = true | |
console.log("Is aborting...."); | |
if (timeoutId) { | |
console.log("Timeout id cancellation..."); | |
clearTimeout(timeoutId); | |
} | |
}); | |
try { | |
combinedSignal.throwIfAborted(); | |
} catch (error) { | |
reject(error); | |
} | |
}); | |
} | |
const controller = new AbortController(); | |
if (typeof AbortSignal === "undefined") { | |
window.AbortSignal = {}; | |
} | |
if (window.AbortSignal.timeout !== "function") { | |
AbortSignal.timeout = function timeout (durationInMillis = 0) { | |
const ctrl = new AbortController() | |
setTimeout(() => ctrl.abort(), durationInMillis); | |
return ctrl.signal; | |
} | |
} | |
if (window.AbortSignal.any !== "function") { | |
AbortSignal.any = function any (arrayOfSignals = []) { | |
if (Array.isArray(arrayOfSignals)) { | |
const ctrl = new AbortController(); | |
for (signal of arrayOfSignals) { | |
if (typeof signal['throwIfAborted'] !== "function" | |
&& signal['addEventListener'] !== "function") { | |
continue; | |
} | |
signal.addEventListener('abort', () => ctrl.abort()); | |
} | |
return ctrl.signal; | |
} | |
} | |
} | |
/* @USAGE: Timing out a `window.fetch` request */ | |
/* @CHEK: https://stackoverflow.com/a/49857905 */ | |
const fetchPromise = customFetch('https://api.github.com/search/issues', { controller }); | |
const sleepPromise = sleep(3000, { controller }); | |
Promise.race([fetchPromise, sleepPromise]) | |
.then(result => { | |
console.log('Result:', result); | |
// If `fetchPromise` wins, `sleepPromise` will be truncated (not aborted). | |
// If `sleepPromise` wins, `fetchPromise` will be aborted (if it is still pending but not resolved/rejected). | |
}) | |
.catch(error => { | |
console.error('Error:', error); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment