Skip to content
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

Closed
fawazahmed0 opened this issue Jun 21, 2024 · 22 comments
Closed

Undici.Request and AbortController doesn't work well #3353

fawazahmed0 opened this issue Jun 21, 2024 · 22 comments
Labels
bug Something isn't working

Comments

@fawazahmed0
Copy link
Contributor

fawazahmed0 commented Jun 21, 2024

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:

const undici = require('undici')

async function testUndici() {
    const controller = new AbortController()
    const {statusCode,headers,trailers,body} = await undici.request("https://github.com/fawazahmed0/tiger/releases/download/v2/pipetocsv.7z", { signal: controller.signal }).catch(console.error)
    setTimeout(()=>controller.abort(), 500)
}
testUndici()

Expected Behavior

Undici.Request and AbortController should work

Logs & Screenshots

node:events:498
      throw er; // Unhandled 'error' event
      ^
DOMException [AbortError]: This operation was aborted
    at new DOMException (node:internal/per_context/domexception:53:5)
    at AbortController.abort (node:internal/abort_controller:395:18)
    at Timeout._onTimeout (T:\xx\other scripts\undici-debug.js:7:31)
    at listOnTimeout (node:internal/timers:573:17)
    at process.processTimers (node:internal/timers:514:7)
Emitted 'error' event on BodyReadable instance at:
    at emitErrorNT (node:internal/streams/destroy:170:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21)

Node.js v22.3.0

Environment

Ubuntu 24.04 & Windows 11
Node v22.3.0
Undici 6.19.1

@fawazahmed0 fawazahmed0 added the bug Something isn't working label Jun 21, 2024
@Gigioliva
Copy link
Contributor

I am having the same problem. The bug was introduced in 6.16.0. I think the problem is in this #3209. In case of onComplete we don't remove the listener from the signal

@Gigioliva
Copy link
Contributor

Adding this in api-request.js fixes the issue

Screenshot 2024-06-21 alle 21 38 51

@mcollina
Copy link
Member

@Gigioliva Would you like to send a Pull Request to address this issue? Remember to add unit tests.

@Gigioliva
Copy link
Contributor

👍🏼 I will do

@Gigioliva
Copy link
Contributor

@mcollina #3354

@ronag
Copy link
Member

ronag commented Jun 22, 2024

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 ronag closed this as completed Jun 22, 2024
@Gigioliva
Copy link
Contributor

@ronag In my case I was consuming the response 🤔 await result.body.text()

@ronag
Copy link
Member

ronag commented Jun 22, 2024

Not between iterations of the while loop

@Gigioliva
Copy link
Contributor

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;
  }
}

@ronag
Copy link
Member

ronag commented Jun 22, 2024

Do you have a repro then? Because your other repro was incorrect. Without a repro we cannot help.

@ronag ronag reopened this Jun 22, 2024
@Gigioliva
Copy link
Contributor

Yup. I can reproduce it in my tool tests. I will try to extract the test and share with you

@fawazahmed0
Copy link
Contributor Author

fawazahmed0 commented Jun 22, 2024

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 fetch from repro

@ronag
Copy link
Member

ronag commented Jun 22, 2024

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?

@Gigioliva
Copy link
Contributor

Gigioliva commented Jun 22, 2024

@ronag looking better at the code, for a specific status code (204) I don't consume the body. This however in my opinion is a bug. Why should I necessarily consume the body?

Note: adding a "useless" await res.body.text() I cannot reproduce the bug

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?

@fawazahmed0
Copy link
Contributor Author

I agree with @Gigioliva

@ronag
Copy link
Member

ronag commented Jun 22, 2024

That's how it was designed and is clearly documented.

We can discuss that in a separate issue if you wish.

@ronag ronag closed this as completed Jun 22, 2024
@fawazahmed0
Copy link
Contributor Author

fawazahmed0 commented Jun 23, 2024

That's how it was designed and is clearly documented.

@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 RequestAbortedError. In my case the error never gets caught by try catch, thus terminating whole node process.

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)

@ronag
Copy link
Member

ronag commented Jun 23, 2024

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)

@Gigioliva
Copy link
Contributor

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
}

@ronag
Copy link
Member

ronag commented Jun 23, 2024

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
}

@titanism
Copy link
Contributor

Any update yet on a proper fix for this?

@ronag
Copy link
Member

ronag commented Jun 24, 2024

There is nothing to fix. It works as designed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

5 participants