Skip to content

Instantly share code, notes, and snippets.

@patrickhulce
Created November 1, 2023 15:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save patrickhulce/fc5b140c8231dfbd6e335f592d61550f to your computer and use it in GitHub Desktop.
Save patrickhulce/fc5b140c8231dfbd6e335f592d61550f to your computer and use it in GitHub Desktop.
How node request cancellation works.
/* eslint-disable */
const fetch_ = require('node-fetch');
const fetchWithTimeout = async (url, options, timeout) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(id);
return response;
};
const readStream = (stream, timeout = 5000) => {
let str = '';
let timer;
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
str += chunk.toString();
});
stream.on('end', () => {
clearTimeout(timer);
resolve(str);
});
stream.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
timer = setTimeout(() => {
stream.destroy(new Error('Stream timed out'));
}, timeout);
});
};
const readStreamWithReader = async (stream, timeout = 5000) => {
let str = '';
const reader = stream.getReader();
let timer;
try {
timer = setTimeout(() => {
reader.cancel(new Error('Stream timed out'));
}, timeout);
let result;
while (!(result = await reader.read()).done) {
str += result.value.toString();
}
console.log('last result was', result, reader.);
return str;
} catch (error) {
console.error('Error:', error.message);
} finally {
clearTimeout(timer);
reader.releaseLock();
}
};
const testFetch = async () => {
let response, result, start;
console.log('Scenario 1:');
start = Date.now();
response = await fetchWithTimeout('http://localhost:3003', {}, 15000);
console.log('Response!', response.status);
result = await readStream(response.body, 15000);
// result = await response.text();
console.log('read', result, 'in', Date.now() - start, 'ms');
console.log('Scenario 2:');
start = Date.now();
try {
response = await fetchWithTimeout('http://localhost:3003', {}, 500);
console.log('Response!', response.status);
result = await readStreamWithReader(response.body, 500);
// result = await response.text();
} catch (error) {
console.log('Request cancelled before 1 second');
}
console.log('finished in', Date.now() - start, 'ms');
console.log('Scenario 3:');
start = Date.now();
try {
response = await fetchWithTimeout('http://localhost:3003', {}, 5000);
console.log('Response!', response.status);
result = await readStreamWithReader(response.body, 5000);
// result = await response.text();
} catch (error) {
console.log('Request cancelled after 1 second but before 30 seconds', error);
}
console.log('finished in', Date.now() - start, 'ms');
};
testFetch();

GC

node version 18.16

  • adding a listener to abortcontroller signal in isolation gets GC'd without removing, no leak
  • both node-fetch and global.fetch do not appear to retain request bodies
  • both node-fetch and global.fetch do not appear to retain AbortController's associated with signals at request time
  • node-fetch does not appear to retain the signal associated with a request beyond the lifecycle of the request
  • global.fetch DOES maintain a reference to the signal beyond request time (memory leak)
  • global.fetch but invoking .abort() does not appear to retain a reference to signal, even if invoked after a response is received

Cancellation

HTTP Request cancellation behaves differently depending on the lifecycle of request, the specific library used, and the stream read methods employed.

Before Response

node-fetch global.fetch
AbortController ✅ Client ✅ Server ✅ Client ✅ Server

After Response

node-fetch global.fetch
AbortController 🤡^1 Client ❌ Server ❌ Client ❌ Server
stream.destroy 🤡^1 Client ❌ Server N/A
request.destroy() (monkeypatch) ✅ Client ✅ Server N/A
stream.getReader().cancel N/A ✅^2 Client ✅ Server

^1 = access to the response errors / stops but the underlying request continues to download the response ^2 = cancel(reason) does not throw an error, it resolves reader.read() with {value: undefined, done: true}

const http = require('http');
const words = 'test\n'.repeat(10).split('\n');
const server = http.createServer((req, res) => {
let i = 0;
console.log('Request received.');
const intervalId = setInterval(() => {
if (req.closed) {
console.log('Request closed');
clearInterval(intervalId);
} else if (req.destroyed) {
console.log('Request destroyed');
clearInterval(intervalId);
} else {
console.log('writing word...');
if (i < words.length) {
res.write(words[i] + '\n');
i++;
} else {
clearInterval(intervalId);
res.end();
}
}
}, 1000);
req.on('close', () => {
console.log('request closed by client');
clearInterval(intervalId);
});
});
server.listen(3003, () => {
console.log('Server listening on port 3003...');
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment