-
Notifications
You must be signed in to change notification settings - Fork 545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Undici.Request and AbortController doesn't work well #3353
Comments
I am having the same problem. The bug was introduced in |
@Gigioliva Would you like to send a Pull Request to address this issue? Remember to add unit tests. |
👍🏼 I will do |
The problem is that you are not cleaning up / destroying / consuming the body of returned response object which keep the signal handler referenced. while (!response?.ok) {
await response?.body.dump()
response = await undici.request(link,{maxRedirections: 30, signal: controller?.signal})
} |
@ronag In my case I was consuming the response 🤔 |
Not between iterations of the while loop |
Yes. within the loop. My code is something like: class MyClass {
readonly #pool: Agent;
constructor() {
this.#pool = new Agent({ connections: MAX_CONCURRENCY });
}
async runFetch(urls: string[]): Promise<string[]> {
return await Promise.all(urls.map((url) => this.doGet(url)));
}
private async doGet(url: string) {
const result = await this.#pool.request({
origin: url,
path: "/foo",
method: "GET",
signal: AbortSignal.timeout(1_000),
});
const text = await result.body.text();
console.log(text);
return text;
}
} |
Do you have a repro then? Because your other repro was incorrect. Without a repro we cannot help. |
Yup. I can reproduce it in my tool tests. I will try to extract the test and share with you |
Here is a repro: const undici = require('undici')
async function testUndici(link, controller) {
let undiciResponse;
while (!(undiciResponse?.statusCode >= 200 && undiciResponse?.statusCode < 300))
undiciResponse = await undici.request(link, { maxRedirections: 30, signal: controller?.signal })
sleep(500).then(() => controller.abort())
}
async function begin() {
const controller = new AbortController()
while (true)
await testUndici("https://github.com/fawazahmed0/tiger/releases/download/v2/pipetocsv.7z", controller).catch(console.error)
}
begin()
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
} Update: Removed unnecessary |
You are not consuming the body of the undici.request response object between loop iterations. Why are you including fetch? Is the problem with request or fetch? |
@ronag looking better at the code, for a specific status code ( Note: adding a "useless" I get the error even if I don't consume the body with a 40X... why I need to consume the body after an error? |
I agree with @Gigioliva |
That's how it was designed and is clearly documented. We can discuss that in a separate issue if you wish. |
@ronag Could you point me to the documentation which says that using abort() will kill the whole node process. I can find one example which says that using abort() will throw Another repro (example copied from doc): import { createServer } from 'http'
import { Client } from 'undici'
import { once } from 'events'
const server = createServer((request, response) => {
response.end('Hello, World!')
}).listen()
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
const abortController = new AbortController()
client.request({
path: '/',
method: 'GET',
signal: abortController.signal
}).catch(error=>{
console.log('Hi! Error Caught')
console.error(error) // should print an RequestAbortedError
client.close()
server.close()
})
setTimeout(()=>abortController.abort(),500) |
request returns a body: Stream which gets destroyed with an abort error and emits an error that you are not handling and therefore the process crashes with an unhandled exception. This is normal nodejs behavior. Feel free to open a PR if you think that needs to be explicitly documented by undici. client.request({
path: '/',
method: 'GET',
signal: abortController.signal
}).then(res=>{
console.log('Hi! Response received')
res.body.on('error', error=>{
console.log('Hi! Error Caught')
console.error(error) // prints a RequestAbortedError
client.close()
server.close()
})
}).catch(error=>{
console.log('Hi! Error Caught')
console.error(error) // should print an RequestAbortedError
client.close()
server.close()
})
setTimeout(()=>abortController.abort(),500) |
Is this considered "correct"? const pool = new Agent()
try {
const result = await this.#pool.request({
origin: url.origin,
path: url.pathname,
method: "GET",
signal: AbortSignal.timeout(1000),
});
if(result.statusCode === 204){
// log and DON'T consume the body
} else {
// call await result.body.text()
}
// Manually close the body to avoid this bug
result.body.emit("close");
} catch(err) {
// Handle connection errors
}
|
No that is not correct. You should not be emitting events yourself. const pool = new Agent()
try {
const result = await this.#pool.request({
origin: url.origin,
path: url.pathname,
method: "GET",
signal: AbortSignal.timeout(1000),
});
if(result.statusCode === 204){
await result.body.dump()
} else {
await result.body.text()
}
} catch(err) {
// Handle connection errors
} |
Any update yet on a proper fix for this? |
There is nothing to fix. It works as designed. |
This is quite odd, we are listening for the error but it is still throwing an uncaught exception. @ronag any idea why? |
Oh, we see the issue, we aren't calling |
No, that was not the case. This is definitely a breaking bug and should have been more carefully done with a major release. |
@ronag we're stumped and cannot figure out why we are getting uncaught DOMException errors thrown (causing our entire process to restart) here's the code <https://github.com/forwardemail/nodejs-dns-over-https-tangerine/blob/main/index.js |
Could it be that |
@ronag There is a call here which does nothing: Line 169 in 00dfd0b
Lines 286 to 290 in 00dfd0b
That function |
Nevermind, |
I believe we've fixed the issue per #3353. |
@titanism I'm still seeing this issue on nodejs 23.1.0 the code in question is https://github.com/webtorrent/bittorrent-tracker/blob/master/lib/client/http-tracker.js#L147 I'm getting the Any idea? Thanks (original issue: webtorrent/webtorrent#2874) |
Hi team, we got bitten by this issue in production like others in this thread. I think that while In our case, the issue manifested because we're holding onto a reference to This leads to a whole-program crash despite the whole sequence above being orchestrated in an The fix, in our case is the following, which I believe is indicative of the dangerous nature of this interaction: async function criticalPath() {
clientRes = await performUndiciRequest(signal);
// This is the fix.
clientRes.body.on('error', () => {
// Ignore the error so that we don't get uncaught exceptions and crash.
});
// ... do some async work
signal.throwIfAborted();
// ... consume the body
await pipeline([clientRes.body, targetStream], { signal });
} |
@ggoodman how do you safely do cleanup and destroy the stream after? otherwise you will encounter 100% cpu usage, e.g. https://www.reddit.com/r/node/comments/vcxx4n/comment/ideo7s4/ |
@kyrylkov can you elaborate on how you solved this via code snippet?
|
@titanism my understanding is that the problematic behaviour with CPU usage is limited to Is there evidence that |
I don't see a problem here. You are mostly mis-using the api... The |
I suppose we should use @ronag How do you properly destroy a fetch? See that Reddit link above; we have the exact same issue that @kyrylkov had, which is using PM2 and after ~20-30 mins time, |
Anonymous function #994 is this: queueMicrotask(() => {
stream[kConsume] = {
type,
stream,
resolve,
reject,
length: 0,
body: []
}
stream
.on('error', function (err) {
consumeFinish(this[kConsume], err)
})
.on('close', function () {
if (this[kConsume].body !== null) {
consumeFinish(this[kConsume], new RequestAbortedError())
}
})
consumeStart(stream[kConsume])
}) |
@titanism That does not seem relateed to this issue, please create a new one |
@ronag I think I see where you're coming from now. This appears to be the precedent for all Node.js Streams produced with a supplied signal (https://github.com/nodejs/node/blob/f17b9a4c7838f37022255f4dcee09a083686018e/lib/internal/streams/add-abort-signal.js#L41-L60). At least, that's the precedent for synchronous APIs returning streams. I think the surprising thing here is that this is a hybrid promise / stream API. Both the initial promise for getting the |
@mcollina wdyt? I think it's a reasonable opinion but not sure what to do with it. Would break everyone to change it now. |
Bug Description
Sometimes Aborting Undici.Request causes whole program to stop, even though everything is wrapped around try catch.
Reproducible By
I don't have minimal reproducible example, as my code is larger in size.But Undici.Fetch doesn't have this issue.Here is minimal reproducible example:
Expected Behavior
Undici.Request and AbortController should work
Logs & Screenshots
Environment
Ubuntu 24.04 & Windows 11
Node v22.3.0
Undici 6.19.1
The text was updated successfully, but these errors were encountered: