Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active May 24, 2025 12:42
Show Gist options
  • Save isocroft/0d435dc4e021b62dc1b9ddf31424c86f to your computer and use it in GitHub Desktop.
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)
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