Repository: nock/nock (language: javascript)

Issue:
nock.back fails with `TypeError: Body is unusable: Body has already been read` when making parallel fetch requests
### Please avoid duplicates

- [x] I checked [all open bugs](https://github.com/nock/nock/issues?q=is%3Aissue+is%3Aopen+label%3Abug) and none of them matched my problem.

### Reproducible test case

(Using jest.)

https://gist.github.com/marcello3d/4b49544899633d04c1b5db9b69b0954f

### Nock Version

14.0.0

### Node Version

20.18.1

### TypeScript Version

_No response_

### What happened?

This took a while to track down, but it turns out when I have parallel calls to fetch happening in a Promise.all, nock breaks with `TypeError: Body is unusable: Body has already been read`:

```typescript
        nockBack.fixtures = path.join(__dirname, '__nocks__');
        const { nockDone } = await nockBack(`test.json`);
        await Promise.all([fetch('https://api.github.com'), fetch('https://api.github.com')]);
        nockDone();
```

One other thing, if I run in `NODE_BACK_MODE=wild` it works.

### Would you be interested in contributing a fix?

- [x] yes


Discussion / hints:
Can you please provide a full example?
```js
const { back: nockBack } = require('nock');
const path = require('path');

(async () => {
  nockBack.fixtures = 'tests/fixtures';
  const { nockDone } = await nockBack(`good_request.json`);
  await Promise.all([fetch('http://www.example.test'), fetch('http://www.example.test')]);
  nockDone();
})();
```
This is working for me.

You're right! When I try to pull out the test as a separate jest project from scratch it seems to work fine… but the exact same test in our codebase fails in that very peculiar way which I've spent hours trying to debug. :-(

I guess I need to figure out what's special about our jest setup.
okay was able to reproduce in a separate repo… I'm not sure what's different from what you tried other than maybe running with `NOCK_BACK_MODE=update`:

https://github.com/marcello3d/nock-back-bug-2025-02
~@mikicho oh dang, I can't reopen the issue it looks like :(~

Edit: Now it does!
okay that's the issue, if I run your earlier sample with `NOCK_BACK_MODE=update` it fails with the same error. 

If you leave it off it looks like it defaults to `dryrun` mode
save this to script.js (with nock 14.0.0 npm installed)
```js
const { back: nockBack } = require("nock");
const path = require("path");

(async () => {
  nockBack.fixtures = "tests/fixtures";
  const { nockDone } = await nockBack(`good_request.json`);
  await Promise.all([
    fetch("https://api.descript.com"),
    fetch("https://api.descript.com"),
  ]);
  nockDone();
})();
```

If you run this with `NOCK_BACK_MODE=record node script.js`, it will fail with:
```
 % NOCK_BACK_MODE=update node script.js
node:internal/deps/undici/undici:5656
        throw new TypeError("Body is unusable: Body has already been read");
              ^

TypeError: Body is unusable: Body has already been read
    at consumeBody (node:internal/deps/undici/undici:5656:15)
    at _Response.arrayBuffer (node:internal/deps/undici/undici:5601:18)
    at _Emitter.<anonymous> (/Users/marcello/github/nock-back-bug/node_modules/nock/lib/recorder.js:265:35)
    at _Emitter.<anonymous> (/Users/marcello/github/nock-back-bug/node_modules/strict-event-emitter/lib/index.js:69:23)
    at emitAsync (/Users/marcello/github/nock-back-bug/node_modules/@mswjs/interceptors/lib/node/chunk-6L3PFBGT.js:68:20)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async /Users/marcello/github/nock-back-bug/node_modules/@mswjs/interceptors/lib/node/chunk-LEA3MUU3.js:277:11
    at async Promise.all (index 0)
    at async /Users/marcello/github/nock-back-bug/script.js:7:3

Node.js v20.18.1
```


Fix patch:
diff --git a/.nycrc.yml b/.nycrc.yml
index 704c10d8c..6485ea011 100644
--- a/.nycrc.yml
+++ b/.nycrc.yml
@@ -1,8 +1,8 @@
 all: true
 check-coverage: true
 
-branches: 94.26
-lines: 96.72
+branches: 94.25
+lines: 96.69
 
 include:
   - lib
diff --git a/lib/recorder.js b/lib/recorder.js
index 4d70ac3bb..460e36571 100644
--- a/lib/recorder.js
+++ b/lib/recorder.js
@@ -6,7 +6,6 @@ const { inspect } = require('util')
 
 const common = require('./common')
 const { restoreOverriddenClientRequest } = require('./intercept')
-const { EventEmitter } = require('stream')
 const { gzipSync, brotliCompressSync, deflateSync } = require('zlib')
 const {
   default: nodeInterceptors,
@@ -225,134 +224,106 @@ function record(recOptions) {
   //  We override the requests so that we can save information on them before executing.
   clientRequestInterceptor.apply()
   fetchRequestInterceptor.apply()
-  clientRequestInterceptor.on('request', async function ({ request }) {
-    await recordRequest(request)
-  })
-  fetchRequestInterceptor.on('request', async function ({ request }) {
-    await recordRequest(request)
-  })
+  clientRequestInterceptor.on(
+    'response',
+    async function ({ request, response }) {
+      await recordResponse(request, response)
+    },
+  )
+  fetchRequestInterceptor.on(
+    'response',
+    async function ({ request, response }) {
+      // fetch decompresses the body automatically, so we need to recompress it
+      const codings =
+        response.headers
+          .get('content-encoding')
+          ?.toLowerCase()
+          .split(',')
+          .map(c => c.trim()) || []
+
+      let body = await response.arrayBuffer()
+      for (const coding of codings) {
+        if (coding === 'gzip') {
+          body = gzipSync(body)
+        } else if (coding === 'deflate') {
+          body = deflateSync(body)
+        } else if (coding === 'br') {
+          body = brotliCompressSync(body)
+        }
+      }
 
-  async function recordRequest(mswRequest) {
+      await recordResponse(request, new Response(body, response))
+    },
+  )
+
+  async function recordResponse(mswRequest, mswResponse) {
     const request = mswRequest.clone()
+    const response = mswResponse.clone()
     const { options } = common.normalizeClientRequestArgs(request.url)
     options.method = request.method
     const proto = options.protocol.slice(0, -1)
+    if (proto === 'https') {
+      options.proto = 'https'
+    }
+    debug(thisRecordingId, proto, 'intercepted request ended')
+
+    let reqheaders
+    // Ignore request headers completely unless it was explicitly enabled by the user (see README)
+    if (enableReqHeadersRecording) {
+      // We never record user-agent headers as they are worse than useless -
+      // they actually make testing more difficult without providing any benefit (see README)
+      reqheaders = Object.fromEntries(request.headers.entries())
+      common.deleteHeadersField(reqheaders, 'user-agent')
+    }
 
-    // Node 0.11 https.request calls http.request -- don't want to record things
-    // twice.
-    /* istanbul ignore if */
-    if (options._recording) {
-      return
+    const headers = Object.fromEntries(response.headers.entries())
+    const res = {
+      statusCode: response.status,
+      headers,
+      rawHeaders: headers,
     }
-    options._recording = true
-
-    const req = new EventEmitter()
-    req.on('response', function () {
-      debug(thisRecordingId, 'intercepting', proto, 'request to record')
-
-      clientRequestInterceptor.once('response', async function ({ response }) {
-        await recordResponse(response)
-      })
-      fetchRequestInterceptor.once('response', async function ({ response }) {
-        // fetch decompresses the body automatically, so we need to recompress it
-        const codings =
-          response.headers
-            .get('content-encoding')
-            ?.toLowerCase()
-            .split(',')
-            .map(c => c.trim()) || []
-
-        let body = await response.arrayBuffer()
-        for (const coding of codings) {
-          if (coding === 'gzip') {
-            body = gzipSync(body)
-          } else if (coding === 'deflate') {
-            body = deflateSync(body)
-          } else if (coding === 'br') {
-            body = brotliCompressSync(body)
-          }
-        }
 
-        await recordResponse(new Response(body, response))
-      })
-
-      // Intercept "res.once('end', ...)"-like event
-      async function recordResponse(mswResponse) {
-        const response = mswResponse.clone()
-        debug(thisRecordingId, proto, 'intercepted request ended')
-
-        let reqheaders
-        // Ignore request headers completely unless it was explicitly enabled by the user (see README)
-        if (enableReqHeadersRecording) {
-          // We never record user-agent headers as they are worse than useless -
-          // they actually make testing more difficult without providing any benefit (see README)
-          reqheaders = Object.fromEntries(request.headers.entries())
-          common.deleteHeadersField(reqheaders, 'user-agent')
-        }
+    const generateFn = outputObjects
+      ? generateRequestAndResponseObject
+      : generateRequestAndResponse
+    let out = generateFn({
+      req: options,
+      bodyChunks: [Buffer.from(await request.arrayBuffer())],
+      options,
+      res,
+      dataChunks: [Buffer.from(await response.arrayBuffer())],
+      reqheaders,
+    })
 
-        const headers = Object.fromEntries(response.headers.entries())
-        const res = {
-          statusCode: response.status,
-          headers,
-          rawHeaders: headers,
-        }
+    debug('out:', out)
+
+    //  Check that the request was made during the current recording.
+    //  If it hasn't then skip it. There is no other simple way to handle
+    //  this as it depends on the timing of requests and responses. Throwing
+    //  will make some recordings/unit tests fail randomly depending on how
+    //  fast/slow the response arrived.
+    //  If you are seeing this error then you need to make sure that all
+    //  the requests made during a single recording session finish before
+    //  ending the same recording session.
+    if (thisRecordingId !== currentRecordingId) {
+      debug('skipping recording of an out-of-order request', out)
+      return
+    }
 
-        const generateFn = outputObjects
-          ? generateRequestAndResponseObject
-          : generateRequestAndResponse
-        let out = generateFn({
-          req: options,
-          bodyChunks: [Buffer.from(await request.arrayBuffer())],
-          options,
-          res,
-          dataChunks: [Buffer.from(await response.arrayBuffer())],
-          reqheaders,
-        })
-
-        debug('out:', out)
-
-        //  Check that the request was made during the current recording.
-        //  If it hasn't then skip it. There is no other simple way to handle
-        //  this as it depends on the timing of requests and responses. Throwing
-        //  will make some recordings/unit tests fail randomly depending on how
-        //  fast/slow the response arrived.
-        //  If you are seeing this error then you need to make sure that all
-        //  the requests made during a single recording session finish before
-        //  ending the same recording session.
-        if (thisRecordingId !== currentRecordingId) {
-          debug('skipping recording of an out-of-order request', out)
-          return
-        }
+    outputs.push(out)
 
-        outputs.push(out)
-
-        if (!dontPrint) {
-          if (useSeparator) {
-            if (typeof out !== 'string') {
-              out = JSON.stringify(out, null, 2)
-            }
-            logging(SEPARATOR + out + SEPARATOR)
-          } else {
-            logging(out)
-          }
+    if (!dontPrint) {
+      if (useSeparator) {
+        if (typeof out !== 'string') {
+          out = JSON.stringify(out, null, 2)
         }
+        logging(SEPARATOR + out + SEPARATOR)
+      } else {
+        logging(out)
       }
+    }
 
-      debug('finished setting up intercepting')
-
-      // We override both the http and the https modules; when we are
-      // serializing the request, we need to know which was called.
-      // By stuffing the state, we can make sure that nock records
-      // the intended protocol.
-      if (proto === 'https') {
-        options.proto = 'https'
-      }
-    })
-
-    // This is a massive change, we are trying to change minimum code, so we emit end event here
-    // because mswjs take care for these events
-    // TODO: refactor the recorder, we no longer need all the listeners and can just record the request we get from MSW
-    req.emit('response')
+    debug('finished setting up intercepting')
   }
 }
 


Test patch:
diff --git a/tests/got/test_recorder.js b/tests/got/test_recorder.js
index acf1f7845..aabcc6916 100644
--- a/tests/got/test_recorder.js
+++ b/tests/got/test_recorder.js
@@ -119,6 +119,31 @@ describe('Recorder', () => {
     ).to.be.true()
   })
 
+  it('records parallel requests', async () => {
+    const gotRequest = sinon.spy()
+
+    nock.restore()
+    nock.recorder.clear()
+    expect(nock.recorder.play()).to.be.empty()
+
+    const { origin } = await servers.startHttpServer((request, response) => {
+      gotRequest()
+      response.writeHead(200)
+      response.end()
+    })
+
+    nock.recorder.rec(true)
+
+    await Promise.all([got.post(origin), got.post(origin)])
+
+    expect(gotRequest).to.have.been.calledTwice()
+
+    nock.restore()
+
+    const recorded = nock.recorder.play()
+    expect(recorded).to.have.lengthOf(2)
+  })
+
   it('records objects', async () => {
     const gotRequest = sinon.spy()