Repository: nodejs/undici (JavaScript)

Issue:
fetch fails with RequestContentLengthMismatchError when following redirect
## Bug Description

`fetch` fails with a `RequestContentLengthMismatchError` error when following a redirect under the following conditions:

* The request has a body.
* The redirect preserves the body, e.g. 307 temporary redirects.
* The redirect response doesn't end immediately after sending the headers.

## Reproducible By

```js
import { createServer } from 'http';
import { once } from 'events';
import { fetch } from 'undici';

async function main() {
  const server = createServer((req, res) => {
    if (req.url === '/redirect') {
      res.writeHead(307, { location: '/target' });
      res.write('flush headers');
      setTimeout(() => res.end(), 1000);
    } else {
      res.end();
    }
  }).listen(0).unref();

  await once(server, 'listening');

  await fetch(`http://localhost:${server.address().port}/redirect`, {
    method: 'POST',
    body: 'body',
  });
}

main();
```

`fetch` fails with the following error:

```
TypeError: fetch failed
    at fetch (/node_modules/undici/index.js:115:13)
    at async main (file://index.mjs:18:3) {
  cause: RequestContentLengthMismatchError: Request body length does not match content-length header
      at AsyncWriter.end (/node_modules/undici/lib/client.js:2257:15)
      at writeIterable (/node_modules/undici/lib/client.js:2140:12) {
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
  }
}
```

## Environment

* Node: 20.9.0
* Undici: 6.2.1

### Additional context

This happens because the connection is destroyed after a redirect is received: https://github.com/nodejs/undici/blob/d56cd7d5707ca92ec390055c59dc3d957c9eeeb4/lib/fetch/index.js#L1209

This causes the fetch to be aborted: https://github.com/nodejs/undici/blob/d56cd7d5707ca92ec390055c59dc3d957c9eeeb4/lib/fetch/index.js#L1721-L1726

Afterwards, when the redirect is followed, the body is not written because the fetch was cancelled previously: https://github.com/nodejs/undici/blob/d56cd7d5707ca92ec390055c59dc3d957c9eeeb4/lib/fetch/index.js#L1833-L1835

This results in an error because the content-length and the written bytes do not match: https://github.com/nodejs/undici/blob/871baa74ec326518a3de3ee4591459e25617402e/lib/client.js#L2255-L2261

This doesn't happen when the redirect response ends immediately after sending the headers because the request is marked as completed before it's aborted, so the body is written as expected when the redirect is followed.

I was able to fix this by passing a flag to `fetchParams.controller.connection.destroy()` to destroy the connection without aborting the entire fetch, but I don't know if this is the right approach. If it is, I can send a PR.


Discussion:
cc @KhafraDev 
PR would be appreciated.
PR sent @KhafraDev: https://github.com/nodejs/undici/pull/2545



Patch:
diff --git a/lib/fetch/index.js b/lib/fetch/index.js
index 54dc6f35b18..f16ce4b4536 100644
--- a/lib/fetch/index.js
+++ b/lib/fetch/index.js
@@ -1206,7 +1206,7 @@ async function httpFetch (fetchParams) {
     // encouraged to, transmit an RST_STREAM frame.
     // See, https://github.com/whatwg/fetch/issues/1288
     if (request.redirect !== 'manual') {
-      fetchParams.controller.connection.destroy()
+      fetchParams.controller.connection.destroy(undefined, false)
     }
 
     // 2. Switch on request’s redirect mode:
@@ -1718,10 +1718,12 @@ async function httpNetworkFetch (
   fetchParams.controller.connection = {
     abort: null,
     destroyed: false,
-    destroy (err) {
+    destroy (err, abort = true) {
       if (!this.destroyed) {
         this.destroyed = true
-        this.abort?.(err ?? new DOMException('The operation was aborted.', 'AbortError'))
+        if (abort) {
+          this.abort?.(err ?? new DOMException('The operation was aborted.', 'AbortError'))
+        }
       }
     }
   }


Test patch:
diff --git a/test/fetch/redirect.js b/test/fetch/redirect.js
index 7e3681b3d23..dd90418880c 100644
--- a/test/fetch/redirect.js
+++ b/test/fetch/redirect.js
@@ -48,3 +48,29 @@ test('Redirecting with an empty body does not throw an error - #2027', async (t)
   t.equal(await resp.text(), '/redirect/')
   t.ok(resp.redirected)
 })
+
+test('Redirecting with a body does not fail to write body - #2543', async (t) => {
+  const server = createServer((req, res) => {
+    if (req.url === '/redirect') {
+      res.writeHead(307, { location: '/target' })
+      res.write('<a href="/redirect/">Moved Permanently</a>')
+      setTimeout(() => res.end(), 500)
+    } else {
+      let body = ''
+      req.on('data', (chunk) => { body += chunk })
+      req.on('end', () => t.equals(body, 'body'))
+      res.write('ok')
+      res.end()
+    }
+  }).listen(0)
+
+  t.teardown(server.close.bind(server))
+  await once(server, 'listening')
+
+  const resp = await fetch(`http://localhost:${server.address().port}/redirect`, {
+    method: 'POST',
+    body: 'body'
+  })
+  t.equal(await resp.text(), 'ok')
+  t.ok(resp.redirected)
+})