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

chore(deps): bump undici from 6.19.8 to 6.21.1 #180

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

dependabot[bot]
Copy link
Contributor

@dependabot dependabot bot commented on behalf of github Jan 21, 2025

Bumps undici from 6.19.8 to 6.21.1.

Release notes

Sourced from undici's releases.

v6.21.1

⚠️ Security Release ⚠️

Fixes CVE CVE-2025-22150 GHSA-c76h-2ccp-4975 (embargoed until 22-01-2025).

What's Changed

Full Changelog: nodejs/undici@v6.21.0...v6.21.1

v6.21.0

What's Changed

Full Changelog: nodejs/undici@v6.20.1...v6.21.0

v6.20.1

What's Changed

Full Changelog: nodejs/undici@v6.20.0...v6.20.1

v6.20.0

What's Changed

... (truncated)

Commits

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot merge will merge this PR after your CI passes on it
  • @dependabot squash and merge will squash and merge this PR after your CI passes on it
  • @dependabot cancel merge will cancel a previously requested merge and block automerging
  • @dependabot reopen will reopen this PR if it is closed
  • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    You can disable automated security fix PRs for this repo from the Security Alerts page.

Bumps [undici](https://github.com/nodejs/undici) from 6.19.8 to 6.21.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](nodejs/undici@v6.19.8...v6.21.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
@dependabot dependabot bot added the dependencies Pull requests that update a dependency file label Jan 21, 2025
Copy link

[puLL-Merge] - nodejs/[email protected]

Diff
diff --git .gitignore .gitignore
index 60aa663c838..7cba7df889f 100644
--- .gitignore
+++ .gitignore
@@ -84,3 +84,6 @@ undici-fetch.js
 .npmrc
 
 .tap
+
+# File generated by /test/request-timeout.js
+test/request-timeout.10mb.bin
diff --git .npmignore .npmignore
index 879c6669f03..461d334ef36 100644
--- .npmignore
+++ .npmignore
@@ -11,3 +11,6 @@ lib/llhttp/llhttp.wasm
 !index.d.ts
 !docs/docs/**/*
 !scripts/strip-comments.js
+
+# File generated by /test/request-timeout.js
+test/request-timeout.10mb.bin
diff --git README.md README.md
index 4336ef06836..2ac58b6695e 100644
--- README.md
+++ README.md
@@ -84,6 +84,7 @@ The `body` mixins are the most common way to format the request/response body. M
 
 - [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer)
 - [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob)
+- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)
 - [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
 - [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
 
diff --git a/benchmarks/timers/compare-timer-getters.mjs b/benchmarks/timers/compare-timer-getters.mjs
new file mode 100644
index 00000000000..aadff558f1f
--- /dev/null
+++ benchmarks/timers/compare-timer-getters.mjs
@@ -0,0 +1,18 @@
+import { bench, group, run } from 'mitata'
+
+group('timers', () => {
+  bench('Date.now()', () => {
+    Date.now()
+  })
+  bench('performance.now()', () => {
+    performance.now()
+  })
+  bench('Math.trunc(performance.now())', () => {
+    Math.trunc(performance.now())
+  })
+  bench('process.uptime()', () => {
+    process.uptime()
+  })
+})
+
+await run()
diff --git index.js index.js
index 7a68d04abb3..0c37ed4853b 100644
--- index.js
+++ index.js
@@ -41,7 +41,8 @@ module.exports.createRedirectInterceptor = createRedirectInterceptor
 module.exports.interceptors = {
   redirect: require('./lib/interceptor/redirect'),
   retry: require('./lib/interceptor/retry'),
-  dump: require('./lib/interceptor/dump')
+  dump: require('./lib/interceptor/dump'),
+  dns: require('./lib/interceptor/dns')
 }
 
 module.exports.buildConnector = buildConnector
diff --git lib/api/api-request.js lib/api/api-request.js
index ced5590d210..be17d628774 100644
--- lib/api/api-request.js
+++ lib/api/api-request.js
@@ -73,7 +73,7 @@ class RequestHandler extends AsyncResource {
         this.removeAbortListener = util.addAbortListener(this.signal, () => {
           this.reason = this.signal.reason ?? new RequestAbortedError()
           if (this.res) {
-            util.destroy(this.res, this.reason)
+            util.destroy(this.res.on('error', util.nop), this.reason)
           } else if (this.abort) {
             this.abort(this.reason)
           }
diff --git lib/api/api-upgrade.js lib/api/api-upgrade.js
index 019fe1efa2d..7effcf21049 100644
--- lib/api/api-upgrade.js
+++ lib/api/api-upgrade.js
@@ -50,9 +50,9 @@ class UpgradeHandler extends AsyncResource {
   }
 
   onUpgrade (statusCode, rawHeaders, socket) {
-    const { callback, opaque, context } = this
+    assert(statusCode === 101)
 
-    assert.strictEqual(statusCode, 101)
+    const { callback, opaque, context } = this
 
     removeSignal(this)
 
diff --git lib/api/readable.js lib/api/readable.js
index a65a7fcb557..47fbf3e0ef1 100644
--- lib/api/readable.js
+++ lib/api/readable.js
@@ -121,6 +121,11 @@ class BodyReadable extends Readable {
     return consume(this, 'blob')
   }
 
+  // https://fetch.spec.whatwg.org/#dom-body-bytes
+  async bytes () {
+    return consume(this, 'bytes')
+  }
+
   // https://fetch.spec.whatwg.org/#dom-body-arraybuffer
   async arrayBuffer () {
     return consume(this, 'arrayBuffer')
@@ -306,6 +311,31 @@ function chunksDecode (chunks, length) {
   return buffer.utf8Slice(start, bufferLength)
 }
 
+/**
+ * @param {Buffer[]} chunks
+ * @param {number} length
+ * @returns {Uint8Array}
+ */
+function chunksConcat (chunks, length) {
+  if (chunks.length === 0 || length === 0) {
+    return new Uint8Array(0)
+  }
+  if (chunks.length === 1) {
+    // fast-path
+    return new Uint8Array(chunks[0])
+  }
+  const buffer = new Uint8Array(Buffer.allocUnsafeSlow(length).buffer)
+
+  let offset = 0
+  for (let i = 0; i < chunks.length; ++i) {
+    const chunk = chunks[i]
+    buffer.set(chunk, offset)
+    offset += chunk.length
+  }
+
+  return buffer
+}
+
 function consumeEnd (consume) {
   const { type, body, resolve, stream, length } = consume
 
@@ -315,17 +345,11 @@ function consumeEnd (consume) {
     } else if (type === 'json') {
       resolve(JSON.parse(chunksDecode(body, length)))
     } else if (type === 'arrayBuffer') {
-      const dst = new Uint8Array(length)
-
-      let pos = 0
-      for (const buf of body) {
-        dst.set(buf, pos)
-        pos += buf.byteLength
-      }
-
-      resolve(dst.buffer)
+      resolve(chunksConcat(body, length).buffer)
     } else if (type === 'blob') {
       resolve(new Blob(body, { type: stream[kContentType] }))
+    } else if (type === 'bytes') {
+      resolve(chunksConcat(body, length))
     }
 
     consumeFinish(consume)
diff --git lib/core/connect.js lib/core/connect.js
index b388f022298..8cd8abccc54 100644
--- lib/core/connect.js
+++ lib/core/connect.js
@@ -4,6 +4,9 @@ const net = require('node:net')
 const assert = require('node:assert')
 const util = require('./util')
 const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
+const timers = require('../util/timers')
+
+function noop () {}
 
 let tls // include tls conditionally since it is not always available
 
@@ -91,9 +94,11 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
       servername = servername || options.servername || util.getServerName(host) || null
 
       const sessionKey = servername || hostname
+      assert(sessionKey)
+
       const session = customSession || sessionCache.get(sessionKey) || null
 
-      assert(sessionKey)
+      port = port || 443
 
       socket = tls.connect({
         highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
@@ -104,7 +109,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
         // TODO(HTTP/2): Add support for h2c
         ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'],
         socket: httpSocket, // upgrade socket connection
-        port: port || 443,
+        port,
         host: hostname
       })
 
@@ -115,11 +120,14 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
         })
     } else {
       assert(!httpSocket, 'httpSocket can only be sent on TLS update')
+
+      port = port || 80
+
       socket = net.connect({
         highWaterMark: 64 * 1024, // Same as nodejs fs streams.
         ...options,
         localAddress,
-        port: port || 80,
+        port,
         host: hostname
       })
     }
@@ -130,12 +138,12 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
       socket.setKeepAlive(true, keepAliveInitialDelay)
     }
 
-    const cancelTimeout = setupTimeout(() => onConnectTimeout(socket), timeout)
+    const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
 
     socket
       .setNoDelay(true)
       .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
-        cancelTimeout()
+        queueMicrotask(clearConnectTimeout)
 
         if (callback) {
           const cb = callback
@@ -144,7 +152,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
         }
       })
       .on('error', function (err) {
-        cancelTimeout()
+        queueMicrotask(clearConnectTimeout)
 
         if (callback) {
           const cb = callback
@@ -157,36 +165,75 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
   }
 }
 
-function setupTimeout (onConnectTimeout, timeout) {
-  if (!timeout) {
-    return () => {}
-  }
+/**
+ * @param {WeakRef<net.Socket>} socketWeakRef
+ * @param {object} opts
+ * @param {number} opts.timeout
+ * @param {string} opts.hostname
+ * @param {number} opts.port
+ * @returns {() => void}
+ */
+const setupConnectTimeout = process.platform === 'win32'
+  ? (socketWeakRef, opts) => {
+      if (!opts.timeout) {
+        return noop
+      }
 
-  let s1 = null
-  let s2 = null
-  const timeoutId = setTimeout(() => {
-    // setImmediate is added to make sure that we prioritize socket error events over timeouts
-    s1 = setImmediate(() => {
-      if (process.platform === 'win32') {
+      let s1 = null
+      let s2 = null
+      const fastTimer = timers.setFastTimeout(() => {
+      // setImmediate is added to make sure that we prioritize socket error events over timeouts
+        s1 = setImmediate(() => {
         // Windows needs an extra setImmediate probably due to implementation differences in the socket logic
-        s2 = setImmediate(() => onConnectTimeout())
-      } else {
-        onConnectTimeout()
+          s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts))
+        })
+      }, opts.timeout)
+      return () => {
+        timers.clearFastTimeout(fastTimer)
+        clearImmediate(s1)
+        clearImmediate(s2)
       }
-    })
-  }, timeout)
-  return () => {
-    clearTimeout(timeoutId)
-    clearImmediate(s1)
-    clearImmediate(s2)
+    }
+  : (socketWeakRef, opts) => {
+      if (!opts.timeout) {
+        return noop
+      }
+
+      let s1 = null
+      const fastTimer = timers.setFastTimeout(() => {
+      // setImmediate is added to make sure that we prioritize socket error events over timeouts
+        s1 = setImmediate(() => {
+          onConnectTimeout(socketWeakRef.deref(), opts)
+        })
+      }, opts.timeout)
+      return () => {
+        timers.clearFastTimeout(fastTimer)
+        clearImmediate(s1)
+      }
+    }
+
+/**
+ * @param {net.Socket} socket
+ * @param {object} opts
+ * @param {number} opts.timeout
+ * @param {string} opts.hostname
+ * @param {number} opts.port
+ */
+function onConnectTimeout (socket, opts) {
+  // The socket could be already garbage collected
+  if (socket == null) {
+    return
   }
-}
 
-function onConnectTimeout (socket) {
   let message = 'Connect Timeout Error'
   if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
-    message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')})`
+    message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
+  } else {
+    message += ` (attempted address: ${opts.hostname}:${opts.port},`
   }
+
+  message += ` timeout: ${opts.timeout}ms)`
+
   util.destroy(socket, new ConnectTimeoutError(message))
 }
 
diff --git lib/core/errors.js lib/core/errors.js
index 3d69fdbecba..9257875c1c3 100644
--- lib/core/errors.js
+++ lib/core/errors.js
@@ -195,6 +195,18 @@ class RequestRetryError extends UndiciError {
   }
 }
 
+class ResponseError extends UndiciError {
+  constructor (message, code, { headers, data }) {
+    super(message)
+    this.name = 'ResponseError'
+    this.message = message || 'Response error'
+    this.code = 'UND_ERR_RESPONSE'
+    this.statusCode = code
+    this.data = data
+    this.headers = headers
+  }
+}
+
 class SecureProxyConnectionError extends UndiciError {
   constructor (cause, message, options) {
     super(message, { cause, ...(options ?? {}) })
@@ -227,5 +239,6 @@ module.exports = {
   BalancedPoolMissingUpstreamError,
   ResponseExceededMaxSizeError,
   RequestRetryError,
+  ResponseError,
   SecureProxyConnectionError
 }
diff --git lib/core/util.js lib/core/util.js
index 00f8a9b200a..9ee7ec23c52 100644
--- lib/core/util.js
+++ lib/core/util.js
@@ -233,7 +233,7 @@ function getServerName (host) {
     return null
   }
 
-  assert.strictEqual(typeof host, 'string')
+  assert(typeof host === 'string')
 
   const servername = getHostname(host)
   if (net.isIP(servername)) {
diff --git lib/dispatcher/client-h1.js lib/dispatcher/client-h1.js
index 2f1c96724d3..2b8fa05da29 100644
--- lib/dispatcher/client-h1.js
+++ lib/dispatcher/client-h1.js
@@ -85,35 +85,35 @@ async function lazyllhttp () {
         return 0
       },
       wasm_on_status: (p, at, len) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         const start = at - currentBufferPtr + currentBufferRef.byteOffset
         return currentParser.onStatus(new FastBuffer(currentBufferRef.buffer, start, len)) || 0
       },
       wasm_on_message_begin: (p) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         return currentParser.onMessageBegin() || 0
       },
       wasm_on_header_field: (p, at, len) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         const start = at - currentBufferPtr + currentBufferRef.byteOffset
         return currentParser.onHeaderField(new FastBuffer(currentBufferRef.buffer, start, len)) || 0
       },
       wasm_on_header_value: (p, at, len) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         const start = at - currentBufferPtr + currentBufferRef.byteOffset
         return currentParser.onHeaderValue(new FastBuffer(currentBufferRef.buffer, start, len)) || 0
       },
       wasm_on_headers_complete: (p, statusCode, upgrade, shouldKeepAlive) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         return currentParser.onHeadersComplete(statusCode, Boolean(upgrade), Boolean(shouldKeepAlive)) || 0
       },
       wasm_on_body: (p, at, len) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         const start = at - currentBufferPtr + currentBufferRef.byteOffset
         return currentParser.onBody(new FastBuffer(currentBufferRef.buffer, start, len)) || 0
       },
       wasm_on_message_complete: (p) => {
-        assert.strictEqual(currentParser.ptr, p)
+        assert(currentParser.ptr === p)
         return currentParser.onMessageComplete() || 0
       }
 
@@ -131,9 +131,17 @@ let currentBufferRef = null
 let currentBufferSize = 0
 let currentBufferPtr = null
 
-const TIMEOUT_HEADERS = 1
-const TIMEOUT_BODY = 2
-const TIMEOUT_IDLE = 3
+const USE_NATIVE_TIMER = 0
+const USE_FAST_TIMER = 1
+
+// Use fast timers for headers and body to take eventual event loop
+// latency into account.
+const TIMEOUT_HEADERS = 2 | USE_FAST_TIMER
+const TIMEOUT_BODY = 4 | USE_FAST_TIMER
+
+// Use native timers to ignore event loop latency for keep-alive
+// handling.
+const TIMEOUT_KEEP_ALIVE = 8 | USE_NATIVE_TIMER
 
 class Parser {
   constructor (client, socket, { exports }) {
@@ -164,26 +172,39 @@ class Parser {
     this.maxResponseSize = client[kMaxResponseSize]
   }
 
-  setTimeout (value, type) {
-    this.timeoutType = type
-    if (value !== this.timeoutValue) {
-      timers.clearTimeout(this.timeout)
-      if (value) {
-        this.timeout = timers.setTimeout(onParserTimeout, value, this)
-        // istanbul ignore else: only for jest
-        if (this.timeout.unref) {
+  setTimeout (delay, type) {
+    // If the existing timer and the new timer are of different timer type
+    // (fast or native) or have different delay, we need to clear the existing
+    // timer and set a new one.
+    if (
+      delay !== this.timeoutValue ||
+      (type & USE_FAST_TIMER) ^ (this.timeoutType & USE_FAST_TIMER)
+    ) {
+      // If a timeout is already set, clear it with clearTimeout of the fast
+      // timer implementation, as it can clear fast and native timers.
+      if (this.timeout) {
+        timers.clearTimeout(this.timeout)
+        this.timeout = null
+      }
+
+      if (delay) {
+        if (type & USE_FAST_TIMER) {
+          this.timeout = timers.setFastTimeout(onParserTimeout, delay, new WeakRef(this))
+        } else {
+          this.timeout = setTimeout(onParserTimeout, delay, new WeakRef(this))
           this.timeout.unref()
         }
-      } else {
-        this.timeout = null
       }
-      this.timeoutValue = value
+
+      this.timeoutValue = delay
     } else if (this.timeout) {
       // istanbul ignore else: only for jest
       if (this.timeout.refresh) {
         this.timeout.refresh()
       }
     }
+
+    this.timeoutType = type
   }
 
   resume () {
@@ -288,7 +309,7 @@ class Parser {
     this.llhttp.llhttp_free(this.ptr)
     this.ptr = null
 
-    timers.clearTimeout(this.timeout)
+    this.timeout && timers.clearTimeout(this.timeout)
     this.timeout = null
     this.timeoutValue = null
     this.timeoutType = null
@@ -363,20 +384,19 @@ class Parser {
     const { upgrade, client, socket, headers, statusCode } = this
 
     assert(upgrade)
+    assert(client[kSocket] === socket)
+    assert(!socket.destroyed)
+    assert(!this.paused)
+    assert((headers.length & 1) === 0)
 
     const request = client[kQueue][client[kRunningIdx]]
     assert(request)
-
-    assert(!socket.destroyed)
-    assert(socket === client[kSocket])
-    assert(!this.paused)
     assert(request.upgrade || request.method === 'CONNECT')
 
     this.statusCode = null
     this.statusText = ''
     this.shouldKeepAlive = null
 
-    assert(this.headers.length % 2 === 0)
     this.headers = []
     this.headersSize = 0
 
@@ -433,7 +453,7 @@ class Parser {
       return -1
     }
 
-    assert.strictEqual(this.timeoutType, TIMEOUT_HEADERS)
+    assert(this.timeoutType === TIMEOUT_HEADERS)
 
     this.statusCode = statusCode
     this.shouldKeepAlive = (
@@ -466,7 +486,7 @@ class Parser {
       return 2
     }
 
-    assert(this.headers.length % 2 === 0)
+    assert((this.headers.length & 1) === 0)
     this.headers = []
     this.headersSize = 0
 
@@ -523,7 +543,7 @@ class Parser {
     const request = client[kQueue][client[kRunningIdx]]
     assert(request)
 
-    assert.strictEqual(this.timeoutType, TIMEOUT_BODY)
+    assert(this.timeoutType === TIMEOUT_BODY)
     if (this.timeout) {
       // istanbul ignore else: only for jest
       if (this.timeout.refresh) {
@@ -556,11 +576,12 @@ class Parser {
       return
     }
 
+    assert(statusCode >= 100)
+    assert((this.headers.length & 1) === 0)
+
     const request = client[kQueue][client[kRunningIdx]]
     assert(request)
 
-    assert(statusCode >= 100)
-
     this.statusCode = null
     this.statusText = ''
     this.bytesRead = 0
@@ -568,7 +589,6 @@ class Parser {
     this.keepAlive = ''
     this.connection = ''
 
-    assert(this.headers.length % 2 === 0)
     this.headers = []
     this.headersSize = 0
 
@@ -587,7 +607,7 @@ class Parser {
     client[kQueue][client[kRunningIdx]++] = null
 
     if (socket[kWriting]) {
-      assert.strictEqual(client[kRunning], 0)
+      assert(client[kRunning] === 0)
       // Response completed before request.
       util.destroy(socket, new InformationalError('reset'))
       return constants.ERROR.PAUSED
@@ -613,19 +633,19 @@ class Parser {
 }
 
 function onParserTimeout (parser) {
-  const { socket, timeoutType, client } = parser
+  const { socket, timeoutType, client, paused } = parser.deref()
 
   /* istanbul ignore else */
   if (timeoutType === TIMEOUT_HEADERS) {
     if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) {
-      assert(!parser.paused, 'cannot be paused while waiting for headers')
+      assert(!paused, 'cannot be paused while waiting for headers')
       util.destroy(socket, new HeadersTimeoutError())
     }
   } else if (timeoutType === TIMEOUT_BODY) {
-    if (!parser.paused) {
+    if (!paused) {
       util.destroy(socket, new BodyTimeoutError())
     }
-  } else if (timeoutType === TIMEOUT_IDLE) {
+  } else if (timeoutType === TIMEOUT_KEEP_ALIVE) {
     assert(client[kRunning] === 0 && client[kKeepAliveTimeoutValue])
     util.destroy(socket, new InformationalError('socket idle timeout'))
   }
@@ -646,10 +666,10 @@ async function connectH1 (client, socket) {
   socket[kParser] = new Parser(client, socket, llhttpInstance)
 
   addListener(socket, 'error', function (err) {
-    const parser = this[kParser]
-
     assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
 
+    const parser = this[kParser]
+
     // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
     // to the user.
     if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
@@ -803,8 +823,8 @@ function resumeH1 (client) {
     }
 
     if (client[kSize] === 0) {
-      if (socket[kParser].timeoutType !== TIMEOUT_IDLE) {
-        socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_IDLE)
+      if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
+        socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
       }
     } else if (client[kRunning] > 0 && socket[kParser].statusCode < 200) {
       if (socket[kParser].timeoutType !== TIMEOUT_HEADERS) {
@@ -840,7 +860,10 @@ function writeH1 (client, request) {
   const expectsPayload = (
     method === 'PUT' ||
     method === 'POST' ||
-    method === 'PATCH'
+    method === 'PATCH' ||
+    method === 'QUERY' ||
+    method === 'PROPFIND' ||
+    method === 'PROPPATCH'
   )
 
   if (util.isFormDataLike(body)) {
@@ -1119,7 +1142,7 @@ function writeBuffer (abort, body, client, request, socket, contentLength, heade
       socket.uncork()
       request.onBodySent(body)
 
-      if (!expectsPayload) {
+      if (!expectsPayload && request.reset !== false) {
         socket[kReset] = true
       }
     }
@@ -1149,7 +1172,7 @@ async function writeBlob (abort, body, client, request, socket, contentLength, h
     request.onBodySent(buffer)
     request.onRequestSent()
 
-    if (!expectsPayload) {
+    if (!expectsPayload && request.reset !== false) {
       socket[kReset] = true
     }
 
@@ -1250,7 +1273,7 @@ class AsyncWriter {
     socket.cork()
 
     if (bytesWritten === 0) {
-      if (!expectsPayload) {
+      if (!expectsPayload && request.reset !== false) {
         socket[kReset] = true
       }
 
diff --git lib/dispatcher/client-h2.js lib/dispatcher/client-h2.js
index 6c5155717d1..4a52effb1f3 100644
--- lib/dispatcher/client-h2.js
+++ lib/dispatcher/client-h2.js
@@ -24,11 +24,15 @@ const {
   kOnError,
   kMaxConcurrentStreams,
   kHTTP2Session,
-  kResume
+  kResume,
+  kSize,
+  kHTTPContext
 } = require('../core/symbols.js')
 
 const kOpenStreams = Symbol('open streams')
 
+let extractBody
+
 // Experimental
 let h2ExperimentalWarned = false
 
@@ -160,11 +164,10 @@ async function connectH2 (client, socket) {
     version: 'h2',
     defaultPipelining: Infinity,
     write (...args) {
-      // TODO (fix): return
-      writeH2(client, ...args)
+      return writeH2(client, ...args)
     },
     resume () {
-
+      resumeH2(client)
     },
     destroy (err, callback) {
       if (closed) {
@@ -183,6 +186,20 @@ async function connectH2 (client, socket) {
   }
 }
 
+function resumeH2 (client) {
+  const socket = client[kSocket]
+
+  if (socket?.destroyed === false) {
+    if (client[kSize] === 0 && client[kMaxConcurrentStreams] === 0) {
+      socket.unref()
+      client[kHTTP2Session].unref()
+    } else {
+      socket.ref()
+      client[kHTTP2Session].ref()
+    }
+  }
+}
+
 function onHttp2SessionError (err) {
   assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
 
@@ -210,17 +227,33 @@ function onHttp2SessionEnd () {
  * along with the socket right away
  */
 function onHTTP2GoAway (code) {
-  const err = new RequestAbortedError(`HTTP/2: "GOAWAY" frame received with code ${code}`)
+  // We cannot recover, so best to close the session and the socket
+  const err = this[kError] || new SocketError(`HTTP/2: "GOAWAY" frame received with code ${code}`, util.getSocketInfo(this))
+  const client = this[kClient]
 
-  // We need to trigger the close cycle right away
-  // We need to destroy the session and the socket
-  // Requests should be failed with the error after the current one is handled
-  this[kSocket][kError] = err
-  this[kClient][kOnError](err)
+  client[kSocket] = null
+  client[kHTTPContext] = null
 
-  this.unref()
+  if (this[kHTTP2Session] != null) {
+    this[kHTTP2Session].destroy(err)
+    this[kHTTP2Session] = null
+  }
 
   util.destroy(this[kSocket], err)
+
+  // Fail head of pipeline.
+  if (client[kRunningIdx] < client[kQueue].length) {
+    const request = client[kQueue][client[kRunningIdx]]
+    client[kQueue][client[kRunningIdx]++] = null
+    util.errorRequest(client, request, err)
+    client[kPendingIdx] = client[kRunningIdx]
+  }
+
+  assert(client[kRunning] === 0)
+
+  client.emit('disconnect', client[kUrl], [client], err)
+
+  client[kResume]()
 }
 
 // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2
@@ -230,17 +263,14 @@ function shouldSendContentLength (method) {
 
 function writeH2 (client, request) {
   const session = client[kHTTP2Session]
-  const { body, method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request
+  const { method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request
+  let { body } = request
 
   if (upgrade) {
     util.errorRequest(client, request, new Error('Upgrade not supported for H2'))
     return false
   }
 
-  if (request.aborted) {
-    return false
-  }
-
   const headers = {}
   for (let n = 0; n < reqHeaders.length; n += 2) {
     const key = reqHeaders[n + 0]
@@ -283,6 +313,8 @@ function writeH2 (client, request) {
     // We do not destroy the socket as we can continue using the session
     // the stream get's destroyed and the session remains to create new streams
     util.destroy(body, err)
+    client[kQueue][client[kRunningIdx]++] = null
+    client[kResume]()
   }
 
   try {
@@ -293,6 +325,10 @@ function writeH2 (client, request) {
     util.errorRequest(client, request, err)
   }
 
+  if (request.aborted) {
+    return false
+  }
+
   if (method === 'CONNECT') {
     session.ref()
     // We are already connected, streams are pending, first request
@@ -304,10 +340,12 @@ function writeH2 (client, request) {
     if (stream.id && !stream.pending) {
       request.onUpgrade(null, null, stream)
       ++session[kOpenStreams]
+      client[kQueue][client[kRunningIdx]++] = null
     } else {
       stream.once('ready', () => {
         request.onUpgrade(null, null, stream)
         ++session[kOpenStreams]
+        client[kQueue][client[kRunningIdx]++] = null
       })
     }
 
@@ -347,6 +385,16 @@ function writeH2 (client, request) {
 
   let contentLength = util.bodyLength(body)
 
+  if (util.isFormDataLike(body)) {
+    extractBody ??= require('../web/fetch/body.js').extractBody
+
+    const [bodyStream, contentType] = extractBody(body)
+    headers['content-type'] = contentType
+
+    body = bodyStream.stream
+    contentLength = bodyStream.length
+  }
+
   if (contentLength == null) {
     contentLength = request.contentLength
   }
@@ -428,17 +476,20 @@ function writeH2 (client, request) {
     // Present specially when using pipeline or stream
     if (stream.state?.state == null || stream.state.state < 6) {
       request.onComplete([])
-      return
     }
 
-    // Stream is closed or half-closed-remote (6), decrement counter and cleanup
-    // It does not have sense to continue working with the stream as we do not
-    // have yet RST_STREAM support on client-side
     if (session[kOpenStreams] === 0) {
+      // Stream is closed or half-closed-remote (6), decrement counter and cleanup
+      // It does not have sense to continue working with the stream as we do not
+      // have yet RST_STREAM support on client-side
+
       session.unref()
     }
 
     abort(new InformationalError('HTTP/2: stream half-closed (remote)'))
+    client[kQueue][client[kRunningIdx]++] = null
+    client[kPendingIdx] = client[kRunningIdx]
+    client[kResume]()
   })
 
   stream.once('close', () => {
diff --git lib/dispatcher/client.js lib/dispatcher/client.js
index cb61206b1ed..3dc356618ba 100644
--- lib/dispatcher/client.js
+++ lib/dispatcher/client.js
@@ -63,6 +63,8 @@ let deprecatedInterceptorWarned = false
 
 const kClosedResolve = Symbol('kClosedResolve')
 
+const noop = () => {}
+
 function getPipelining (client) {
   return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1
 }
@@ -385,6 +387,10 @@ function onError (client, err) {
   }
 }
 
+/**
+ * @param {Client} client
+ * @returns
+ */
 async function connect (client) {
   assert(!client[kConnecting])
   assert(!client[kHTTPContext])
@@ -438,7 +444,7 @@ async function connect (client) {
     })
 
     if (client.destroyed) {
-      util.destroy(socket.on('error', () => {}), new ClientDestroyedError())
+      util.destroy(socket.on('error', noop), new ClientDestroyedError())
       return
     }
 
@@ -449,7 +455,7 @@ async function connect (client) {
         ? await connectH2(client, socket)
         : await connectH1(client, socket)
     } catch (err) {
-      socket.destroy().on('error', () => {})
+      socket.destroy().on('error', noop)
       throw err
     }
 
diff --git lib/dispatcher/pool-base.js lib/dispatcher/pool-base.js
index ff3108a4da2..d0ba2c3c53a 100644
--- lib/dispatcher/pool-base.js
+++ lib/dispatcher/pool-base.js
@@ -113,9 +113,9 @@ class PoolBase extends DispatcherBase {
 
   async [kClose] () {
     if (this[kQueue].isEmpty()) {
-      return Promise.all(this[kClients].map(c => c.close()))
+      await Promise.all(this[kClients].map(c => c.close()))
     } else {
-      return new Promise((resolve) => {
+      await new Promise((resolve) => {
         this[kClosedResolve] = resolve
       })
     }
@@ -130,7 +130,7 @@ class PoolBase extends DispatcherBase {
       item.handler.onError(err)
     }
 
-    return Promise.all(this[kClients].map(c => c.destroy(err)))
+    await Promise.all(this[kClients].map(c => c.destroy(err)))
   }
 
   [kDispatch] (opts, handler) {
diff --git lib/dispatcher/proxy-agent.js lib/dispatcher/proxy-agent.js
index 226b67846da..c439d7cd555 100644
--- lib/dispatcher/proxy-agent.js
+++ lib/dispatcher/proxy-agent.js
@@ -23,6 +23,8 @@ function defaultFactory (origin, opts) {
   return new Pool(origin, opts)
 }
 
+const noop = () => {}
+
 class ProxyAgent extends DispatcherBase {
   constructor (opts) {
     super()
@@ -81,7 +83,7 @@ class ProxyAgent extends DispatcherBase {
             servername: this[kProxyTls]?.servername || proxyHostname
           })
           if (statusCode !== 200) {
-            socket.on('error', () => {}).destroy()
+            socket.on('error', noop).destroy()
             callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`))
           }
           if (opts.protocol !== 'https:') {
diff --git lib/handler/retry-handler.js lib/handler/retry-handler.js
index f7dedfa4bac..5d1ccf00538 100644
--- lib/handler/retry-handler.js
+++ lib/handler/retry-handler.js
@@ -192,8 +192,18 @@ class RetryHandler {
     if (this.resume != null) {
       this.resume = null
 
-      if (statusCode !== 206) {
-        return true
+      // Only Partial Content 206 supposed to provide Content-Range,
+      // any other status code that partially consumed the payload
+      // should not be retry because it would result in downstream
+      // wrongly concatanete multiple responses.
+      if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
+        this.abort(
+          new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
+            headers,
+            data: { count: this.retryCount }
+          })
+        )
+        return false
       }
 
       const contentRange = parseRangeHeader(headers['content-range'])
@@ -219,7 +229,7 @@ class RetryHandler {
         return false
       }
 
-      const { start, size, end = size } = contentRange
+      const { start, size, end = size - 1 } = contentRange
 
       assert(this.start === start, 'content-range mismatch')
       assert(this.end == null || this.end === end, 'content-range mismatch')
@@ -242,7 +252,7 @@ class RetryHandler {
           )
         }
 
-        const { start, size, end = size } = range
+        const { start, size, end = size - 1 } = range
         assert(
           start != null && Number.isFinite(start),
           'content-range mismatch'
@@ -256,7 +266,7 @@ class RetryHandler {
       // We make our best to checkpoint the body for further range headers
       if (this.end == null) {
         const contentLength = headers['content-length']
-        this.end = contentLength != null ? Number(contentLength) : null
+        this.end = contentLength != null ? Number(contentLength) - 1 : null
       }
 
       assert(Number.isFinite(this.start))
diff --git a/lib/interceptor/dns.js b/lib/interceptor/dns.js
new file mode 100644
index 00000000000..917732646e6
--- /dev/null
+++ lib/interceptor/dns.js
@@ -0,0 +1,375 @@
+'use strict'
+const { isIP } = require('node:net')
+const { lookup } = require('node:dns')
+const DecoratorHandler = require('../handler/decorator-handler')
+const { InvalidArgumentError, InformationalError } = require('../core/errors')
+const maxInt = Math.pow(2, 31) - 1
+
+class DNSInstance {
+  #maxTTL = 0
+  #maxItems = 0
+  #records = new Map()
+  dualStack = true
+  affinity = null
+  lookup = null
+  pick = null
+
+  constructor (opts) {
+    this.#maxTTL = opts.maxTTL
+    this.#maxItems = opts.maxItems
+    this.dualStack = opts.dualStack
+    this.affinity = opts.affinity
+    this.lookup = opts.lookup ?? this.#defaultLookup
+    this.pick = opts.pick ?? this.#defaultPick
+  }
+
+  get full () {
+    return this.#records.size === this.#maxItems
+  }
+
+  runLookup (origin, opts, cb) {
+    const ips = this.#records.get(origin.hostname)
+
+    // If full, we just return the origin
+    if (ips == null && this.full) {
+      cb(null, origin.origin)
+      return
+    }
+
+    const newOpts = {
+      affinity: this.affinity,
+      dualStack: this.dualStack,
+      lookup: this.lookup,
+      pick: this.pick,
+      ...opts.dns,
+      maxTTL: this.#maxTTL,
+      maxItems: this.#maxItems
+    }
+
+    // If no IPs we lookup
+    if (ips == null) {
+      this.lookup(origin, newOpts, (err, addresses) => {
+        if (err || addresses == null || addresses.length === 0) {
+          cb(err ?? new InformationalError('No DNS entries found'))
+          return
+        }
+
+        this.setRecords(origin, addresses)
+        const records = this.#records.get(origin.hostname)
+
+        const ip = this.pick(
+          origin,
+          records,
+          newOpts.affinity
+        )
+
+        let port
+        if (typeof ip.port === 'number') {
+          port = `:${ip.port}`
+        } else if (origin.port !== '') {
+          port = `:${origin.port}`
+        } else {
+          port = ''
+        }
+
+        cb(
+          null,
+          `${origin.protocol}//${
+            ip.family === 6 ? `[${ip.address}]` : ip.address
+          }${port}`
+        )
+      })
+    } else {
+      // If there's IPs we pick
+      const ip = this.pick(
+        origin,
+        ips,
+        newOpts.affinity
+      )
+
+      // If no IPs we lookup - deleting old records
+      if (ip == null) {
+        this.#records.delete(origin.hostname)
+        this.runLookup(origin, opts, cb)
+        return
+      }
+
+      let port
+      if (typeof ip.port === 'number') {
+        port = `:${ip.port}`
+      } else if (origin.port !== '') {
+        port = `:${origin.port}`
+      } else {
+        port = ''
+      }
+
+      cb(
+        null,
+        `${origin.protocol}//${
+          ip.family === 6 ? `[${ip.address}]` : ip.address
+        }${port}`
+      )
+    }
+  }
+
+  #defaultLookup (origin, opts, cb) {
+    lookup(
+      origin.hostname,
+      {
+        all: true,
+        family: this.dualStack === false ? this.affinity : 0,
+        order: 'ipv4first'
+      },
+      (err, addresses) => {
+        if (err) {
+          return cb(err)
+        }
+
+        const results = new Map()
+
+        for (const addr of addresses) {
+          // On linux we found duplicates, we attempt to remove them with
+          // the latest record
+          results.set(`${addr.address}:${addr.family}`, addr)
+        }
+
+        cb(null, results.values())
+      }
+    )
+  }
+
+  #defaultPick (origin, hostnameRecords, affinity) {
+    let ip = null
+    const { records, offset } = hostnameRecords
+
+    let family
+    if (this.dualStack) {
+      if (affinity == null) {
+        // Balance between ip families
+        if (offset == null || offset === maxInt) {
+          hostnameRecords.offset = 0
+          affinity = 4
+        } else {
+          hostnameRecords.offset++
+          affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4
+        }
+      }
+
+      if (records[affinity] != null && records[affinity].ips.length > 0) {
+        family = records[affinity]
+      } else {
+        family = records[affinity === 4 ? 6 : 4]
+      }
+    } else {
+      family = records[affinity]
+    }
+
+    // If no IPs we return null
+    if (family == null || family.ips.length === 0) {
+      return ip
+    }
+
+    if (family.offset == null || family.offset === maxInt) {
+      family.offset = 0
+    } else {
+      family.offset++
+    }
+
+    const position = family.offset % family.ips.length
+    ip = family.ips[position] ?? null
+
+    if (ip == null) {
+      return ip
+    }
+
+    if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
+      // We delete expired records
+      // It is possible that they have different TTL, so we manage them individually
+      family.ips.splice(position, 1)
+      return this.pick(origin, hostnameRecords, affinity)
+    }
+
+    return ip
+  }
+
+  setRecords (origin, addresses) {
+    const timestamp = Date.now()
+    const records = { records: { 4: null, 6: null } }
+    for (const record of addresses) {
+      record.timestamp = timestamp
+      if (typeof record.ttl === 'number') {
+        // The record TTL is expected to be in ms
+        record.ttl = Math.min(record.ttl, this.#maxTTL)
+      } else {
+        record.ttl = this.#maxTTL
+      }
+
+      const familyRecords = records.records[record.family] ?? { ips: [] }
+
+      familyRecords.ips.push(record)
+      records.records[record.family] = familyRecords
+    }
+
+    this.#records.set(origin.hostname, records)
+  }
+
+  getHandler (meta, opts) {
+    return new DNSDispatchHandler(this, meta, opts)
+  }
+}
+
+class DNSDispatchHandler extends DecoratorHandler {
+  #state = null
+  #opts = null
+  #dispatch = null
+  #handler = null
+  #origin = null
+
+  constructor (state, { origin, handler, dispatch }, opts) {
+    super(handler)
+    this.#origin = origin
+    this.#handler = handler
+    this.#opts = { ...opts }
+    this.#state = state
+    this.#dispatch = dispatch
+  }
+
+  onError (err) {
+    switch (err.code) {
+      case 'ETIMEDOUT':
+      case 'ECONNREFUSED': {
+        if (this.#state.dualStack) {
+          // We delete the record and retry
+          this.#state.runLookup(this.#origin, this.#opts, (err, newOrigin) => {
+            if (err) {
+              return this.#handler.onError(err)
+            }
+
+            const dispatchOpts = {
+              ...this.#opts,
+              origin: newOrigin
+            }
+
+            this.#dispatch(dispatchOpts, this)
+          })
+
+          // if dual-stack disabled, we error out
+          return
+        }
+
+        this.#handler.onError(err)
+        return
+      }
+      case 'ENOTFOUND':
+        this.#state.deleteRecord(this.#origin)
+      // eslint-disable-next-line no-fallthrough
+      default:
+        this.#handler.onError(err)
+        break
+    }
+  }
+}
+
+module.exports = interceptorOpts => {
+  if (
+    interceptorOpts?.maxTTL != null &&
+    (typeof interceptorOpts?.maxTTL !== 'number' || interceptorOpts?.maxTTL < 0)
+  ) {
+    throw new InvalidArgumentError('Invalid maxTTL. Must be a positive number')
+  }
+
+  if (
+    interceptorOpts?.maxItems != null &&
+    (typeof interceptorOpts?.maxItems !== 'number' ||
+      interceptorOpts?.maxItems < 1)
+  ) {
+    throw new InvalidArgumentError(
+      'Invalid maxItems. Must be a positive number and greater than zero'
+    )
+  }
+
+  if (
+    interceptorOpts?.affinity != null &&
+    interceptorOpts?.affinity !== 4 &&
+    interceptorOpts?.affinity !== 6
+  ) {
+    throw new InvalidArgumentError('Invalid affinity. Must be either 4 or 6')
+  }
+
+  if (
+    interceptorOpts?.dualStack != null &&
+    typeof interceptorOpts?.dualStack !== 'boolean'
+  ) {
+    throw new InvalidArgumentError('Invalid dualStack. Must be a boolean')
+  }
+
+  if (
+    interceptorOpts?.lookup != null &&
+    typeof interceptorOpts?.lookup !== 'function'
+  ) {
+    throw new InvalidArgumentError('Invalid lookup. Must be a function')
+  }
+
+  if (
+    interceptorOpts?.pick != null &&
+    typeof interceptorOpts?.pick !== 'function'
+  ) {
+    throw new InvalidArgumentError('Invalid pick. Must be a function')
+  }
+
+  const dualStack = interceptorOpts?.dualStack ?? true
+  let affinity
+  if (dualStack) {
+    affinity = interceptorOpts?.affinity ?? null
+  } else {
+    affinity = interceptorOpts?.affinity ?? 4
+  }
+
+  const opts = {
+    maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms
+    lookup: interceptorOpts?.lookup ?? null,
+    pick: interceptorOpts?.pick ?? null,
+    dualStack,
+    affinity,
+    maxItems: interceptorOpts?.maxItems ?? Infinity
+  }
+
+  const instance = new DNSInstance(opts)
+
+  return dispatch => {
+    return function dnsInterceptor (origDispatchOpts, handler) {
+      const origin =
+        origDispatchOpts.origin.constructor === URL
+          ? origDispatchOpts.origin
+          : new URL(origDispatchOpts.origin)
+
+      if (isIP(origin.hostname) !== 0) {
+        return dispatch(origDispatchOpts, handler)
+      }
+
+      instance.runLookup(origin, origDispatchOpts, (err, newOrigin) => {
+        if (err) {
+          return handler.onError(err)
+        }
+
+        let dispatchOpts = null
+        dispatchOpts = {
+          ...origDispatchOpts,
+          servername: origin.hostname, // For SNI on TLS
+          origin: newOrigin,
+          headers: {
+            host: origin.hostname,
+            ...origDispatchOpts.headers
+          }
+        }
+
+        dispatch(
+          dispatchOpts,
+          instance.getHandler({ origin, dispatch, handler }, origDispatchOpts)
+        )
+      })
+
+      return true
+    }
+  }
+}
diff --git a/lib/interceptor/response-error.js b/lib/interceptor/response-error.js
new file mode 100644
index 00000000000..3ded9c87fb7
--- /dev/null
+++ lib/interceptor/response-error.js
@@ -0,0 +1,86 @@
+'use strict'
+
+const { parseHeaders } = require('../core/util')
+const DecoratorHandler = require('../handler/decorator-handler')
+const { ResponseError } = require('../core/errors')
+
+class Handler extends DecoratorHandler {
+  #handler
+  #statusCode
+  #contentType
+  #decoder
+  #headers
+  #body
+
+  constructor (opts, { handler }) {
+    super(handler)
+    this.#handler = handler
+  }
+
+  onConnect (abort) {
+    this.#statusCode = 0
+    this.#contentType = null
+    this.#decoder = null
+    this.#headers = null
+    this.#body = ''
+
+    return this.#handler.onConnect(abort)
+  }
+
+  onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
+    this.#statusCode = statusCode
+    this.#headers = headers
+    this.#contentType = headers['content-type']
+
+    if (this.#statusCode < 400) {
+      return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
+    }
+
+    if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
+      this.#decoder = new TextDecoder('utf-8')
+    }
+  }
+
+  onData (chunk) {
+    if (this.#statusCode < 400) {
+      return this.#handler.onData(chunk)
+    }
+
+    this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? ''
+  }
+
+  onComplete (rawTrailers) {
+    if (this.#statusCode >= 400) {
+      this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? ''
+
+      if (this.#contentType === 'application/json') {
+        try {
+          this.#body = JSON.parse(this.#body)
+        } catch {
+          // Do nothing...
+        }
+      }
+
+      let err
+      const stackTraceLimit = Error.stackTraceLimit
+      Error.stackTraceLimit = 0
+      try {
+        err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body)
+      } finally {
+        Error.stackTraceLimit = stackTraceLimit
+      }
+
+      this.#handler.onError(err)
+    } else {
+      this.#handler.onComplete(rawTrailers)
+    }
+  }
+
+  onError (err) {
+    this.#handler.onError(err)
+  }
+}
+
+module.exports = (dispatch) => (opts, handler) => opts.throwOnError
+  ? dispatch(opts, new Handler(opts, { handler }))
+  : dispatch(opts, handler)
diff --git lib/mock/mock-utils.js lib/mock/mock-utils.js
index f3c284d7891..8f18db31ee2 100644
--- lib/mock/mock-utils.js
+++ lib/mock/mock-utils.js
@@ -118,6 +118,10 @@ function matchKey (mockDispatch, { path, method, body, headers }) {
 function getResponseData (data) {
   if (Buffer.isBuffer(data)) {
     return data
+  } else if (data instanceof Uint8Array) {
+    return data
+  } else if (data instanceof ArrayBuffer) {
+    return data
   } else if (typeof data === 'object') {
     return JSON.stringify(data)
   } else {
diff --git lib/util/timers.js lib/util/timers.js
index d0091cc15f7..c15bbc37aa1 100644
--- lib/util/timers.js
+++ lib/util/timers.js
@@ -1,99 +1,423 @@
 'use strict'
 
-const TICK_MS = 499
+/**
+ * This module offers an optimized timer implementation designed for scenarios
+ * where high precision is not critical.
+ *
+ * The timer achieves faster performance by using a low-resolution approach,
+ * with an accuracy target of within 500ms. This makes it particularly useful
+ * for timers with delays of 1 second or more, where exact timing is less
+ * crucial.
+ *
+ * It's important to note that Node.js timers are inherently imprecise, as
+ * delays can occur due to the event loop being blocked by other operations.
+ * Consequently, timers may trigger later than their scheduled time.
+ */
 
-let fastNow = Date.now()
+/**
+ * The fastNow variable contains the internal fast timer clock value.
+ *
+ * @type {number}
+ */
+let fastNow = 0
+
+/**
+ * RESOLUTION_MS represents the target resolution time in milliseconds.
+ *
+ * @type {number}
+ * @default 1000
+ */
+const RESOLUTION_MS = 1e3
+
+/**
+ * TICK_MS defines the desired interval in milliseconds between each tick.
+ * The target value is set to half the resolution time, minus 1 ms, to account
+ * for potential event loop overhead.
+ *
+ * @type {number}
+ * @default 499
+ */
+const TICK_MS = (RESOLUTION_MS >> 1) - 1
+
+/**
+ * fastNowTimeout is a Node.js timer used to manage and process
+ * the FastTimers stored in the `fastTimers` array.
+ *
+ * @type {NodeJS.Timeout}
+ */
 let fastNowTimeout
 
+/**
+ * The kFastTimer symbol is used to identify FastTimer instances.
+ *
+ * @type {Symbol}
+ */
+const kFastTimer = Symbol('kFastTimer')
+
+/**
+ * The fastTimers array contains all active FastTimers.
+ *
+ * @type {FastTimer[]}
+ */
 const fastTimers = []
 
-function onTimeout () {
-  fastNow = Date.now()
+/**
+ * These constants represent the various states of a FastTimer.
+ */
 
-  let len = fastTimers.length
+/**
+ * The `NOT_IN_LIST` constant indicates that the FastTimer is not included
+ * in the `fastTimers` array. Timers with this status will not be processed
+ * during the next tick by the `onTick` function.
+ *
+ * A FastTimer can be re-added to the `fastTimers` array by invoking the
+ * `refresh` method on the FastTimer instance.
+ *
+ * @type {-2}
+ */
+const NOT_IN_LIST = -2
+
+/**
+ * The `TO_BE_CLEARED` constant indicates that the FastTimer is scheduled
+ * for removal from the `fastTimers` array. A FastTimer in this state will
+ * be removed in the next tick by the `onTick` function and will no longer
+ * be processed.
+ *
+ * This status is also set when the `clear` method is called on the FastTimer instance.
+ *
+ * @type {-1}
+ */
+const TO_BE_CLEARED = -1
+
+/**
+ * The `PENDING` constant signifies that the FastTimer is awaiting processing
+ * in the next tick by the `onTick` function. Timers with this status will have
+ * their `_idleStart` value set and their status updated to `ACTIVE` in the next tick.
+ *
+ * @type {0}
+ */
+const PENDING = 0
+
+/**
+ * The `ACTIVE` constant indicates that the FastTimer is active and waiting
+ * for its timer to expire. During the next tick, the `onTick` function will
+ * check if the timer has expired, and if so, it will execute the associated callback.
+ *
+ * @type {1}
+ */
+const ACTIVE = 1
+
+/**
+ * The onTick function processes the fastTimers array.
+ *
+ * @returns {void}
+ */
+function onTick () {
+  /**
+   * Increment the fastNow value by the TICK_MS value, despite the actual time
+   * that has passed since the last tick. This approach ensures independence
+   * from the system clock and delays caused by a blocked event loop.
+   *
+   * @type {number}
+   */
+  fastNow += TICK_MS
+
+  /**
+   * The `idx` variable is used to iterate over the `fastTimers` array.
+   * Expired timers are removed by replacing them with the last element in the array.
+   * Consequently, `idx` is only incremented when the current element is not removed.
+   *
+   * @type {number}
+   */
   let idx = 0
+
+  /**
+   * The len variable will contain the length of the fastTimers array
+   * and will be decremented when a FastTimer should be removed from the
+   * fastTimers array.
+   *
+   * @type {number}
+   */
+  let len = fastTimers.length
+
   while (idx < len) {
+    /**
+     * @type {FastTimer}
+     */
     const timer = fastTimers[idx]
 
-    if (timer.state === 0) {
-      timer.state = fastNow + timer.delay - TICK_MS
-    } else if (timer.state > 0 && fastNow >= timer.state) {
-      timer.state = -1
-      timer.callback(timer.opaque)
+    // If the timer is in the ACTIVE state and the timer has expired, it will
+    // be processed in the next tick.
+    if (timer._state === PENDING) {
+      // Set the _idleStart value to the fastNow value minus the TICK_MS value
+      // to account for the time the timer was in the PENDING state.
+      timer._idleStart = fastNow - TICK_MS
+      timer._state = ACTIVE
+    } else if (
+      timer._state === ACTIVE &&
+      fastNow >= timer._idleStart + timer._idleTimeout
+    ) {
+      timer._state = TO_BE_CLEARED
+      timer._idleStart = -1
+      timer._onTimeout(timer._timerArg)
     }
 
-    if (timer.state === -1) {
-      timer.state = -2
-      if (idx !== len - 1) {
-        fastTimers[idx] = fastTimers.pop()
-      } else {
-        fastTimers.pop()
+    if (timer._state === TO_BE_CLEARED) {
+      timer._state = NOT_IN_LIST
+
+      // Move the last element to the current index and decrement len if it is
+      // not the only element in the array.
+      if (--len !== 0) {
+        fastTimers[idx] = fastTimers[len]
       }
-      len -= 1
     } else {
-      idx += 1
+      ++idx
     }
   }
 
-  if (fastTimers.length > 0) {
+  // Set the length of the fastTimers array to the new length and thus
+  // removing the excess FastTimers elements from the array.
+  fastTimers.length = len
+
+  // If there are still active FastTimers in the array, refresh the Timer.
+  // If there are no active FastTimers, the timer will be refreshed again
+  // when a new FastTimer is instantiated.
+  if (fastTimers.length !== 0) {
     refreshTimeout()
   }
 }
 
 function refreshTimeout () {
-  if (fastNowTimeout?.refresh) {
+  // If the fastNowTimeout is already set, refresh it.
+  if (fastNowTimeout) {
     fastNowTimeout.refresh()
+  // fastNowTimeout is not instantiated yet, create a new Timer.
   } else {
     clearTimeout(fastNowTimeout)
-    fastNowTimeout = setTimeout(onTimeout, TICK_MS)
+    fastNowTimeout = setTimeout(onTick, TICK_MS)
+
+    // If the Timer has an unref method, call it to allow the process to exit if
+    // there are no other active handles.
     if (fastNowTimeout.unref) {
       fastNowTimeout.unref()
     }
   }
 }
 
-class Timeout {
-  constructor (callback, delay, opaque) {
-    this.callback = callback
-    this.delay = delay
-    this.opaque = opaque
+/**
+ * The `FastTimer` class is a data structure designed to store and manage
+ * timer information.
+ */
+class FastTimer {
+  [kFastTimer] = true
+
+  /**
+   * The state of the timer, which can be one of the following:
+   * - NOT_IN_LIST (-2)
+   * - TO_BE_CLEARED (-1)
+   * - PENDING (0)
+   * - ACTIVE (1)
+   *
+   * @type {-2|-1|0|1}
+   * @private
+   */
+  _state = NOT_IN_LIST
 
-    //  -2 not in timer list
-    //  -1 in timer list but inactive
-    //   0 in timer list waiting for time
-    // > 0 in timer list waiting for time to expire
-    this.state = -2
+  /**
+   * The number of milliseconds to wait before calling the callback.
+   *
+   * @type {number}
+   * @private
+   */
+  _idleTimeout = -1
+
+  /**
+   * The time in milliseconds when the timer was started. This value is used to
+   * calculate when the timer should expire.
+   *
+   * @type {number}
+   * @default -1
+   * @private
+   */
+  _idleStart = -1
+
+  /**
+   * The function to be executed when the timer expires.
+   * @type {Function}
+   * @private
+   */
+  _onTimeout
+
+  /**
+   * The argument to be passed to the callback when the timer expires.
+   *
+   * @type {*}
+   * @private
+   */
+  _timerArg
+
+  /**
+   * @constructor
+   * @param {Function} callback A function to be executed after the timer
+   * expires.
+   * @param {number} delay The time, in milliseconds that the timer should wait
+   * before the specified function or code is executed.
+   * @param {*} arg
+   */
+  constructor (callback, delay, arg) {
+    this._onTimeout = callback
+    this._idleTimeout = delay
+    this._timerArg = arg
 
     this.refresh()
   }
 
+  /**
+   * Sets the timer's start time to the current time, and reschedules the timer
+   * to call its callback at the previously specified duration adjusted to the
+   * current time.
+   * Using this on a timer that has already called its callback will reactivate
+   * the timer.
+   *
+   * @returns {void}
+   */
   refresh () {
-    if (this.state === -2) {
+    // In the special case that the timer is not in the list of active timers,
+    // add it back to the array to be processed in the next tick by the onTick
+    // function.
+    if (this._state === NOT_IN_LIST) {
       fastTimers.push(this)
-      if (!fastNowTimeout || fastTimers.length === 1) {
-        refreshTimeout()
-      }
     }
 
-    this.state = 0
+    // If the timer is the only active timer, refresh the fastNowTimeout for
+    // better resolution.
+    if (!fastNowTimeout || fastTimers.length === 1) {
+      refreshTimeout()
+    }
+
+    // Setting the state to PENDING will cause the timer to be reset in the
+    // next tick by the onTick function.
+    this._state = PENDING
   }
 
+  /**
+   * The `clear` method cancels the timer, preventing it from executing.
+   *
+   * @returns {void}
+   * @private
+   */
   clear () {
-    this.state = -1
+    // Set the state to TO_BE_CLEARED to mark the timer for removal in the next
+    // tick by the onTick function.
+    this._state = TO_BE_CLEARED
+
+    // Reset the _idleStart value to -1 to indicate that the timer is no longer
+    // active.
+    this._idleStart = -1
   }
 }
 
+/**
+ * This module exports a setTimeout and clearTimeout function that can be
+ * used as a drop-in replacement for the native functions.
+ */
 module.exports = {
-  setTimeout (callback, delay, opaque) {
-    return delay <= 1e3
-      ? setTimeout(callback, delay, opaque)
-      : new Timeout(callback, delay, opaque)
+  /**
+   * The setTimeout() method sets a timer which executes a function once the
+   * timer expires.
+   * @param {Function} callback A function to be executed after the timer
+   * expires.
+   * @param {number} delay The time, in milliseconds that the timer should
+   * wait before the specified function or code is executed.
+   * @param {*} [arg] An optional argument to be passed to the callback function
+   * when the timer expires.
+   * @returns {NodeJS.Timeout|FastTimer}
+   */
+  setTimeout (callback, delay, arg) {
+    // If the delay is less than or equal to the RESOLUTION_MS value return a
+    // native Node.js Timer instance.
+    return delay <= RESOLUTION_MS
+      ? setTimeout(callback, delay, arg)
+      : new FastTimer(callback, delay, arg)
   },
+  /**
+   * The clearTimeout method cancels an instantiated Timer previously created
+   * by calling setTimeout.
+   *
+   * @param {NodeJS.Timeout|FastTimer} timeout
+   */
   clearTimeout (timeout) {
-    if (timeout instanceof Timeout) {
+    // If the timeout is a FastTimer, call its own clear method.
+    if (timeout[kFastTimer]) {
+      /**
+       * @type {FastTimer}
+       */
       timeout.clear()
+      // Otherwise it is an instance of a native NodeJS.Timeout, so call the
+      // Node.js native clearTimeout function.
     } else {
       clearTimeout(timeout)
     }
-  }
+  },
+  /**
+   * The setFastTimeout() method sets a fastTimer which executes a function once
+   * the timer expires.
+   * @param {Function} callback A function to be executed after the timer
+   * expires.
+   * @param {number} delay The time, in milliseconds that the timer should
+   * wait before the specified function or code is executed.
+   * @param {*} [arg] An optional argument to be passed to the callback function
+   * when the timer expires.
+   * @returns {FastTimer}
+   */
+  setFastTimeout (callback, delay, arg) {
+    return new FastTimer(callback, delay, arg)
+  },
+  /**
+   * The clearTimeout method cancels an instantiated FastTimer previously
+   * created by calling setFastTimeout.
+   *
+   * @param {FastTimer} timeout
+   */
+  clearFastTimeout (timeout) {
+    timeout.clear()
+  },
+  /**
+   * The now method returns the value of the internal fast timer clock.
+   *
+   * @returns {number}
+   */
+  now () {
+    return fastNow
+  },
+  /**
+   * Trigger the onTick function to process the fastTimers array.
+   * Exported for testing purposes only.
+   * Marking as deprecated to discourage any use outside of testing.
+   * @deprecated
+   * @param {number} [delay=0] The delay in milliseconds to add to the now value.
+   */
+  tick (delay = 0) {
+    fastNow += delay - RESOLUTION_MS + 1
+    onTick()
+    onTick()
+  },
+  /**
+   * Reset FastTimers.
+   * Exported for testing purposes only.
+   * Marking as deprecated to discourage any use outside of testing.
+   * @deprecated
+   */
+  reset () {
+    fastNow = 0
+    fastTimers.length = 0
+    clearTimeout(fastNowTimeout)
+    fastNowTimeout = null
+  },
+  /**
+   * Exporting for testing purposes only.
+   * Marking as deprecated to discourage any use outside of testing.
+   * @deprecated
+   */
+  kFastTimer
 }
diff --git lib/web/cache/cache.js lib/web/cache/cache.js
index 45c6fec3467..1c1a5911242 100644
--- lib/web/cache/cache.js
+++ lib/web/cache/cache.js
@@ -37,6 +37,7 @@ class Cache {
       webidl.illegalConstructor()
     }
 
+    webidl.util.markAsUncloneable(this)
     this.#relevantRequestResponseList = arguments[1]
   }
 
diff --git lib/web/cache/cachestorage.js lib/web/cache/cachestorage.js
index cc773b94b49..55dba352e99 100644
--- lib/web/cache/cachestorage.js
+++ lib/web/cache/cachestorage.js
@@ -16,6 +16,8 @@ class CacheStorage {
     if (arguments[0] !== kConstruct) {
       webidl.illegalConstructor()
     }
+
+    webidl.util.markAsUncloneable(this)
   }
 
   async match (request, options = {}) {
diff --git lib/web/eventsource/eventsource.js lib/web/eventsource/eventsource.js
index 51634c1779f..5a488ffce27 100644
--- lib/web/eventsource/eventsource.js
+++ lib/web/eventsource/eventsource.js
@@ -105,6 +105,8 @@ class EventSource extends EventTarget {
     // 1. Let ev be a new EventSource object.
     super()
 
+    webidl.util.markAsUncloneable(this)
+
     const prefix = 'EventSource constructor'
     webidl.argumentLengthCheck(arguments, 1, prefix)
 
diff --git lib/web/fetch/body.js lib/web/fetch/body.js
index 464e7b50e5c..aa6e2973532 100644
--- lib/web/fetch/body.js
+++ lib/web/fetch/body.js
@@ -20,6 +20,14 @@ const { isErrored, isDisturbed } = require('node:stream')
 const { isArrayBuffer } = require('node:util/types')
 const { serializeAMimeType } = require('./data-url')
 const { multipartFormDataParser } = require('./formdata-parser')
+let random
+
+try {
+  const crypto = require('node:crypto')
+  random = (max) => crypto.randomInt(0, max)
+} catch {
+  random = (max) => Math.floor(Math.random(max))
+}
 
 const textEncoder = new TextEncoder()
 function noop () {}
@@ -113,7 +121,7 @@ function extractBody (object, keepalive = false) {
     // Set source to a copy of the bytes held by object.
     source = new Uint8Array(object.buffer.slice(object.byteOffset, object.byteOffset + object.byteLength))
   } else if (util.isFormDataLike(object)) {
-    const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}`
+    const boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}`
     const prefix = `--${boundary}\r\nContent-Disposition: form-data`
 
     /*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
diff --git lib/web/fetch/constants.js lib/web/fetch/constants.js
index dad8d0b5776..1f285e06283 100644
--- lib/web/fetch/constants.js
+++ lib/web/fetch/constants.js
@@ -1,27 +1,30 @@
 'use strict'
 
-const corsSafeListedMethods = ['GET', 'HEAD', 'POST']
+const corsSafeListedMethods = /** @type {const} */ (['GET', 'HEAD', 'POST'])
 const corsSafeListedMethodsSet = new Set(corsSafeListedMethods)
 
-const nullBodyStatus = [101, 204, 205, 304]
+const nullBodyStatus = /** @type {const} */ ([101, 204, 205, 304])
 
-const redirectStatus = [301, 302, 303, 307, 308]
+const redirectStatus = /** @type {const} */ ([301, 302, 303, 307, 308])
 const redirectStatusSet = new Set(redirectStatus)
 
-// https://fetch.spec.whatwg.org/#block-bad-port
-const badPorts = [
+/**
+ * @see https://fetch.spec.whatwg.org/#block-bad-port
+ */
+const badPorts = /** @type {const} */ ([
   '1', '7', '9', '11', '13', '15', '17', '19', '20', '21', '22', '23', '25', '37', '42', '43', '53', '69', '77', '79',
   '87', '95', '101', '102', '103', '104', '109', '110', '111', '113', '115', '117', '119', '123', '135', '137',
   '139', '143', '161', '179', '389', '427', '465', '512', '513', '514', '515', '526', '530', '531', '532',
   '540', '548', '554', '556', '563', '587', '601', '636', '989', '990', '993', '995', '1719', '1720', '1723',
   '2049', '3659', '4045', '4190', '5060', '5061', '6000', '6566', '6665', '6666', '6667', '6668', '6669', '6679',
   '6697', '10080'
-]
-
+])
 const badPortsSet = new Set(badPorts)
 
-// https://w3c.github.io/webappsec-referrer-policy/#referrer-policies
-const referrerPolicy = [
+/**
+ * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policies
+ */
+const referrerPolicy = /** @type {const} */ ([
   '',
   'no-referrer',
   'no-referrer-when-downgrade',
@@ -31,29 +34,31 @@ const referrerPolicy = [
   'origin-when-cross-origin',
   'strict-origin-when-cross-origin',
   'unsafe-url'
-]
+])
 const referrerPolicySet = new Set(referrerPolicy)
 
-const requestRedirect = ['follow', 'manual', 'error']
+const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error'])
 
-const safeMethods = ['GET', 'HEAD', 'OPTIONS', 'TRACE']
+const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE'])
 const safeMethodsSet = new Set(safeMethods)
 
-const requestMode = ['navigate', 'same-origin', 'no-cors', 'cors']
+const requestMode = /** @type {const} */ (['navigate', 'same-origin', 'no-cors', 'cors'])
 
-const requestCredentials = ['omit', 'same-origin', 'include']
+const requestCredentials = /** @type {const} */ (['omit', 'same-origin', 'include'])
 
-const requestCache = [
+const requestCache = /** @type {const} */ ([
   'default',
   'no-store',
   'reload',
   'no-cache',
   'force-cache',
   'only-if-cached'
-]
+])
 
-// https://fetch.spec.whatwg.org/#request-body-header-name
-const requestBodyHeader = [
+/**
+ * @see https://fetch.spec.whatwg.org/#request-body-header-name
+ */
+const requestBodyHeader = /** @type {const} */ ([
   'content-encoding',
   'content-language',
   'content-location',
@@ -63,18 +68,22 @@ const requestBodyHeader = [
   // removed in the Headers implementation. However, undici doesn't
   // filter out headers, so we add it here.
   'content-length'
-]
+])
 
-// https://fetch.spec.whatwg.org/#enumdef-requestduplex
-const requestDuplex = [
+/**
+ * @see https://fetch.spec.whatwg.org/#enumdef-requestduplex
+ */
+const requestDuplex = /** @type {const} */ ([
   'half'
-]
+])
 
-// http://fetch.spec.whatwg.org/#forbidden-method
-const forbiddenMethods = ['CONNECT', 'TRACE', 'TRACK']
+/**
+ * @see http://fetch.spec.whatwg.org/#forbidden-method
+ */
+const forbiddenMethods = /** @type {const} */ (['CONNECT', 'TRACE', 'TRACK'])
 const forbiddenMethodsSet = new Set(forbiddenMethods)
 
-const s,ubresource = [
+const subresource = /** @type {const} */ ([
   'audio',
   'audioworklet',
   'font',
@@ -87,7 +96,7 @@ const subresource = [
   'video',
   'xslt',
   ''
-]
+])
 const subresourceSet = new Set(subresource)
 
 module.exports = {
diff --git lib/web/fetch/formdata-parser.js lib/web/fetch/formdata-parser.js
index 7e567e9ec65..315a4626da5 100644
--- lib/web/fetch/formdata-parser.js
+++ lib/web/fetch/formdata-parser.js
@@ -87,11 +87,21 @@ function multipartFormDataParser (input, mimeType) {
   //    the first byte.
   const position = { position: 0 }
 
-  // Note: undici addition, allow \r\n before the body.
-  if (input[0] === 0x0d && input[1] === 0x0a) {
+  // Note: undici addition, allows leading and trailing CRLFs.
+  while (input[position.position] === 0x0d && input[position.position + 1] === 0x0a) {
     position.position += 2
   }
 
+  let trailing = input.length
+
+  while (input[trailing - 1] === 0x0a && input[trailing - 2] === 0x0d) {
+    trailing -= 2
+  }
+
+  if (trailing !== input.length) {
+    input = input.subarray(0, trailing)
+  }
+
   // 5. While true:
   while (true) {
     // 5.1. If position points to a sequence of bytes starting with 0x2D 0x2D
diff --git lib/web/fetch/formdata.js lib/web/fetch/formdata.js
index 94a84b03ab3..544e4125519 100644
--- lib/web/fetch/formdata.js
+++ lib/web/fetch/formdata.js
@@ -14,6 +14,8 @@ const File = globalThis.File ?? NativeFile
 // https://xhr.spec.whatwg.org/#formdata
 class FormData {
   constructor (form) {
+    webidl.util.markAsUncloneable(this)
+
     if (form !== undefined) {
       throw webidl.errors.conversionFailed({
         prefix: 'FormData constructor',
diff --git lib/web/fetch/headers.js lib/web/fetch/headers.js
index 816aceacce4..a68daf4a5d4 100644
--- lib/web/fetch/headers.js
+++ lib/web/fetch/headers.js
@@ -359,6 +359,8 @@ class Headers {
   #headersList
 
   constructor (init = undefined) {
+    webidl.util.markAsUncloneable(this)
+
     if (init === kConstruct) {
       return
     }
diff --git lib/web/fetch/index.js lib/web/fetch/index.js
index 784e0c2cdbb..b74ccf9deb8 100644
--- lib/web/fetch/index.js
+++ lib/web/fetch/index.js
@@ -2137,7 +2137,7 @@ async function httpNetworkFetch (
 
           // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
           if (codings.length !== 0 && request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status) && !willFollow) {
-            for (let i = 0; i < codings.length; ++i) {
+            for (let i = codings.length - 1; i >= 0; --i) {
               const coding = codings[i]
               // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2
               if (coding === 'x-gzip' || coding === 'gzip') {
@@ -2150,9 +2150,15 @@ async function httpNetworkFetch (
                   finishFlush: zlib.constants.Z_SYNC_FLUSH
                 }))
               } else if (coding === 'deflate') {
-                decoders.push(createInflate())
+                decoders.push(createInflate({
+                  flush: zlib.constants.Z_SYNC_FLUSH,
+                  finishFlush: zlib.constants.Z_SYNC_FLUSH
+                }))
               } else if (coding === 'br') {
-                decoders.push(zlib.createBrotliDecompress())
+                decoders.push(zlib.createBrotliDecompress({
+                  flush: zlib.constants.BROTLI_OPERATION_FLUSH,
+                  finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH
+                }))
               } else {
                 decoders.length = 0
                 break
@@ -2160,13 +2166,19 @@ async function httpNetworkFetch (
             }
           }
 
+          const onError = this.onError.bind(this)
+
           resolve({
             status,
             statusText,
             headersList,
             body: decoders.length
-              ? pipeline(this.body, ...decoders, () => { })
-              : this.body.on('error', () => { })
+              ? pipeline(this.body, ...decoders, (err) => {
+                if (err) {
+                  this.onError(err)
+                }
+              }).on('error', onError)
+              : this.body.on('error', onError)
           })
 
           return true
diff --git lib/web/fetch/request.js lib/web/fetch/request.js
index 542ea7fb28a..ee3ce488774 100644
--- lib/web/fetch/request.js
+++ lib/web/fetch/request.js
@@ -82,6 +82,7 @@ let patchMethodWarning = false
 class Request {
   // https://fetch.spec.whatwg.org/#dom-request
   constructor (input, init = {}) {
+    webidl.util.markAsUncloneable(this)
     if (input === kConstruct) {
       return
     }
diff --git lib/web/fetch/response.js lib/web/fetch/response.js
index 155dbadd1ad..3b6af35fbed 100644
--- lib/web/fetch/response.js
+++ lib/web/fetch/response.js
@@ -110,6 +110,7 @@ class Response {
 
   // https://fetch.spec.whatwg.org/#dom-response
   constructor (body = null, init = {}) {
+    webidl.util.markAsUncloneable(this)
     if (body === kConstruct) {
       return
     }
diff --git lib/web/fetch/util.js lib/web/fetch/util.js
index dc5ce9b392a..5101324a80c 100644
--- lib/web/fetch/util.js
+++ lib/web/fetch/util.js
@@ -1340,6 +1340,14 @@ function buildContentRange (rangeStart, rangeEnd, fullLength) {
 // interpreted as a zlib stream, otherwise it's interpreted as a
 // raw deflate stream.
 class InflateStream extends Transform {
+  #zlibOptions
+
+  /** @param {zlib.ZlibOptions} [zlibOptions] */
+  constructor (zlibOptions) {
+    super()
+    this.#zlibOptions = zlibOptions
+  }
+
   _transform (chunk, encoding, callback) {
     if (!this._inflateStream) {
       if (chunk.length === 0) {
@@ -1347,8 +1355,8 @@ class InflateStream extends Transform {
         return
       }
       this._inflateStream = (chunk[0] & 0x0F) === 0x08
-        ? zlib.createInflate()
-        : zlib.createInflateRaw()
+        ? zlib.createInflate(this.#zlibOptions)
+        : zlib.createInflateRaw(this.#zlibOptions)
 
       this._inflateStream.on('data', this.push.bind(this))
       this._inflateStream.on('end', () => this.push(null))
@@ -1367,8 +1375,12 @@ class InflateStream extends Transform {
   }
 }
 
-function createInflate () {
-  return new InflateStream()
+/**
+ * @param {zlib.ZlibOptions} [zlibOptions]
+ * @returns {InflateStream}
+ */
+function createInflate (zlibOptions) {
+  return new InflateStream(zlibOptions)
 }
 
 /**
diff --git lib/web/fetch/webidl.js lib/web/fetch/webidl.js
index 13cafae6f1b..cd5cb14454c 100644
--- lib/web/fetch/webidl.js
+++ lib/web/fetch/webidl.js
@@ -1,6 +1,7 @@
 'use strict'
 
 const { types, inspect } = require('node:util')
+const { markAsUncloneable } = require('node:worker_threads')
 const { toUSVString } = require('../../core/util')
 
 /** @type {import('../../../types/webidl').Webidl} */
@@ -86,6 +87,7 @@ webidl.util.Type = function (V) {
   }
 }
 
+webidl.util.markAsUncloneable = markAsUncloneable || (() => {})
 // https://webidl.spec.whatwg.org/#abstract-opdef-converttoint
 webidl.util.ConvertToInt = function (V, bitLength, signedness, opts) {
   let upperBound
diff --git lib/web/websocket/events.js lib/web/websocket/events.js
index 760b7297359..f899c21d42b 100644
--- lib/web/websocket/events.js
+++ lib/web/websocket/events.js
@@ -14,6 +14,7 @@ class MessageEvent extends Event {
   constructor (type, eventInitDict = {}) {
     if (type === kConstruct) {
       super(arguments[1], arguments[2])
+      webidl.util.markAsUncloneable(this)
       return
     }
 
@@ -26,6 +27,7 @@ class MessageEvent extends Event {
     super(type, eventInitDict)
 
     this.#eventInit = eventInitDict
+    webidl.util.markAsUncloneable(this)
   }
 
   get data () {
@@ -112,6 +114,7 @@ class CloseEvent extends Event {
     super(type, eventInitDict)
 
     this.#eventInit = eventInitDict
+    webidl.util.markAsUncloneable(this)
   }
 
   get wasClean () {
@@ -142,6 +145,7 @@ class ErrorEvent extends Event {
     webidl.argumentLengthCheck(arguments, 1, prefix)
 
     super(type, eventInitDict)
+    webidl.util.markAsUncloneable(this)
 
     type = webidl.converters.DOMString(type, prefix, 'type')
     eventInitDict = webidl.converters.ErrorEventInit(eventInitDict ?? {})
diff --git lib/web/websocket/websocket.js lib/web/websocket/websocket.js
index 109d7be2e2f..e4053024756 100644
--- lib/web/websocket/websocket.js
+++ lib/web/websocket/websocket.js
@@ -51,6 +51,8 @@ class WebSocket extends EventTarget {
   constructor (url, protocols = []) {
     super()
 
+    webidl.util.markAsUncloneable(this)
+
     const prefix = 'WebSocket constructor'
     webidl.argumentLengthCheck(arguments, 1, prefix)
 
diff --git package.json package.json
index bf777b24a2b..a465327b1bc 100644
--- package.json
+++ package.json
@@ -1,6 +1,6 @@
 {
   "name": "undici",
-  "version": "6.19.8",
+  "version": "6.21.1",
   "description": "An HTTP/1.1 client, written from scratch for Node.js",
   "homepage": "https://undici.nodejs.org",
   "bugs": {
@@ -78,6 +78,9 @@
     "test:fuzzing": "node test/fuzzing/fuzzing.test.js",
     "test:fetch": "npm run build:node && npm run test:fetch:nobuild",
     "test:fetch:nobuild": "borp --timeout 180000 --expose-gc --concurrency 1 -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy",
+    "test:h2": "npm run test:h2:core && npm run test:h2:fetch",
+    "test:h2:core": "borp -p \"test/http2*.js\"",
+    "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"",
     "test:interceptors": "borp -p \"test/interceptors/*.js\"",
     "test:jest": "cross-env NODE_V8_COVERAGE= jest",
     "test:unit": "borp --expose-gc -p \"test/*.js\"",
@@ -105,7 +108,7 @@
     "@fastify/busboy": "2.1.1",
     "@matteo.collina/tspl": "^0.1.1",
     "@sinonjs/fake-timers": "^11.1.0",
-    "@types/node": "^18.0.3",
+    "@types/node": "~18.19.50",
     "abort-controller": "^3.0.0",
     "borp": "^0.15.0",
     "c8": "^10.0.0",
diff --git a/test/busboy/issue-3676.js b/test/busboy/issue-3676.js
new file mode 100644
index 00000000000..4b74af88767
--- /dev/null
+++ test/busboy/issue-3676.js
@@ -0,0 +1,24 @@
+'use strict'
+
+const { test } = require('node:test')
+const assert = require('node:assert')
+const { Response } = require('../..')
+
+// https://github.com/nodejs/undici/issues/3676
+test('Leading and trailing CRLFs are ignored', async (t) => {
+  const response = new Response([
+    '--axios-1.7.7-boundary-bPgZ9x77LfApGVUN839vui4V7\r\n' +
+    'Content-Disposition: form-data; name="file"; filename="doc.txt"\r\n' +
+    'Content-Type: text/plain\r\n' +
+    '\r\n' +
+    'Helloworld\r\n' +
+    '--axios-1.7.7-boundary-bPgZ9x77LfApGVUN839vui4V7--\r\n' +
+    '\r\n'
+  ].join(''), {
+    headers: {
+      'content-type': 'multipart/form-data; boundary=axios-1.7.7-boundary-bPgZ9x77LfApGVUN839vui4V7'
+    }
+  })
+
+  await assert.doesNotReject(response.formData())
+})
diff --git test/client-request.js test/client-request.js
index 8cbad5ccb48..c5e90e840ee 100644
--- test/client-request.js
+++ test/client-request.js
@@ -655,6 +655,32 @@ test('request arrayBuffer', async (t) => {
   await t.completed
 })
 
+test('request bytes', async (t) => {
+  t = tspl(t, { plan: 2 })
+
+  const obj = { asd: true }
+  const server = createServer((req, res) => {
+    res.end(JSON.stringify(obj))
+  })
+  after(() => server.close())
+
+  server.listen(0, async () => {
+    const client = new Client(`http://localhost:${server.address().port}`)
+    after(() => client.destroy())
+
+    const { body } = await client.request({
+      path: '/',
+      method: 'GET'
+    })
+    const bytes = await body.bytes()
+
+    t.deepStrictEqual(new TextEncoder().encode(JSON.stringify(obj)), bytes)
+    t.ok(bytes instanceof Uint8Array)
+  })
+
+  await t.completed
+})
+
 test('request body', async (t) => {
   t = tspl(t, { plan: 1 })
 
@@ -1226,3 +1252,39 @@ test('request post body DataView', async (t) => {
 
   await t.completed
 })
+
+test('#3736 - Aborted Response (without consuming body)', async (t) => {
+  const plan = tspl(t, { plan: 1 })
+
+  const controller = new AbortController()
+  const server = createServer((req, res) => {
+    setTimeout(() => {
+      res.writeHead(200, 'ok', {
+        'content-type': 'text/plain'
+      })
+      res.write('hello from server')
+      res.end()
+    }, 100)
+  })
+
+  server.listen(0)
+
+  await EE.once(server, 'listening')
+  const client = new Client(`http://localhost:${server.address().port}`)
+
+  after(server.close.bind(server))
+  after(client.destroy.bind(client))
+
+  const { signal } = controller
+  const promise = client.request({
+    path: '/',
+    method: 'GET',
+    signal
+  })
+
+  controller.abort()
+
+  await plan.rejects(promise, { message: 'This operation was aborted' })
+
+  await plan.completed
+})
diff --git test/connect-timeout.js test/connect-timeout.js
index ff50eb777a6..186067f80ac 100644
--- test/connect-timeout.js
+++ test/connect-timeout.js
@@ -10,12 +10,13 @@ const skip = !!process.env.CITGM
 
 // Using describe instead of test to avoid the timeout
 describe('prioritize socket errors over timeouts', { skip }, async () => {
-  const t = tspl({ ...assert, after: () => {} }, { plan: 1 })
+  const t = tspl({ ...assert, after: () => {} }, { plan: 2 })
   const client = new Pool('http://foorbar.invalid:1234', { connectTimeout: 1 })
 
   client.request({ method: 'GET', path: '/foobar' })
     .then(() => t.fail())
     .catch((err) => {
+      t.strictEqual(err.code, 'ENOTFOUND')
       t.strictEqual(err.code !== 'UND_ERR_CONNECT_TIMEOUT', true)
     })
 
@@ -32,7 +33,7 @@ net.connect = function (options) {
 }
 
 test('connect-timeout', { skip }, async t => {
-  t = tspl(t, { plan: 1 })
+  t = tspl(t, { plan: 3 })
 
   const client = new Client('http://localhost:9000', {
     connectTimeout: 1e3
@@ -48,6 +49,8 @@ test('connect-timeout', { skip }, async t => {
     method: 'GET'
   }, (err) => {
     t.ok(err instanceof errors.ConnectTimeoutError)
+    t.strictEqual(err.code, 'UND_ERR_CONNECT_TIMEOUT')
+    t.strictEqual(err.message, 'Connect Timeout Error (attempted address: localhost:9000, timeout: 1000ms)')
     clearTimeout(timeout)
   })
 
@@ -55,7 +58,7 @@ test('connect-timeout', { skip }, async t => {
 })
 
 test('connect-timeout', { skip }, async t => {
-  t = tspl(t, { plan: 1 })
+  t = tspl(t, { plan: 3 })
 
   const client = new Pool('http://localhost:9000', {
     connectTimeout: 1e3
@@ -71,6 +74,8 @@ test('connect-timeout', { skip }, async t => {
     method: 'GET'
   }, (err) => {
     t.ok(err instanceof errors.ConnectTimeoutError)
+    t.strictEqual(err.code, 'UND_ERR_CONNECT_TIMEOUT')
+    t.strictEqual(err.message, 'Connect Timeout Error (attempted address: localhost:9000, timeout: 1000ms)')
     clearTimeout(timeout)
   })
 
diff --git test/fetch/encoding.js test/fetch/encoding.js
index 3986e03e505..93c98274b08 100644
--- test/fetch/encoding.js
+++ test/fetch/encoding.js
@@ -19,10 +19,10 @@ test('content-encoding header is case-iNsENsITIve', async (t) => {
     res.setHeader('Content-Encoding', contentCodings)
     res.setHeader('Content-Type', 'text/plain')
 
-    brotli.pipe(gzip).pipe(res)
+    gzip.pipe(brotli).pipe(res)
 
-    brotli.write(text)
-    brotli.end()
+    gzip.write(text)
+    gzip.end()
   }).listen(0)
 
   t.after(closeServerAsPromise(server))
@@ -45,10 +45,10 @@ test('response decompression according to content-encoding should be handled in
     res.setHeader('Content-Encoding', contentCodings)
     res.setHeader('Content-Type', 'text/plain')
 
-    gzip.pipe(deflate).pipe(res)
+    deflate.pipe(gzip).pipe(res)
 
-    gzip.write(text)
-    gzip.end()
+    deflate.write(text)
+    deflate.end()
   }).listen(0)
 
   t.after(closeServerAsPromise(server))
diff --git a/test/fetch/issue-3616.js b/test/fetch/issue-3616.js
new file mode 100644
index 00000000000..ed9f739bba1
--- /dev/null
+++ test/fetch/issue-3616.js
@@ -0,0 +1,48 @@
+'use strict'
+
+const { createServer } = require('node:http')
+const { tspl } = require('@matteo.collina/tspl')
+const { describe, test, after } = require('node:test')
+const { fetch } = require('../..')
+const { once } = require('node:events')
+
+describe('https://github.com/nodejs/undici/issues/3616', () => {
+  const cases = [
+    'x-gzip',
+    'gzip',
+    'deflate',
+    'br'
+  ]
+
+  for (const encoding of cases) {
+    test(encoding, async t => {
+      t = tspl(t, { plan: 2 })
+      const server = createServer((req, res) => {
+        res.writeHead(200, {
+          'Content-Length': '0',
+          Connection: 'close',
+          'Content-Encoding': encoding
+        })
+        res.end()
+      })
+
+      after(() => {
+        server.close()
+      })
+
+      server.listen(0)
+
+      await once(server, 'listening')
+      const result = await fetch(`http://localhost:${server.address().port}/`)
+
+      t.ok(result.body.getReader())
+
+      process.on('uncaughtException', (reason) => {
+        t.fail('Uncaught Exception:', reason, encoding)
+      })
+
+      await new Promise(resolve => setTimeout(resolve, 100))
+      t.ok(true)
+    })
+  }
+})
diff --git test/http2.js test/http2.js
index a43700574b8..7d130e670a9 100644
--- test/http2.js
+++ test/http2.js
@@ -10,7 +10,7 @@ const { Writable, pipeline, PassThrough, Readable } = require('node:stream')
 
 const pem = require('https-pem')
 
-const { Client, Agent } = require('..')
+const { Client, Agent, FormData } = require('..')
 
 const isGreaterThanv20 = process.versions.node.split('.').map(Number)[0] >= 20
 
@@ -217,66 +217,6 @@ test('Should support H2 connection(POST Buffer)', async t => {
   t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!')
 })
 
-test('Should support H2 GOAWAY (server-side)', async t => {
-  const body = []
-  const server = createSecureServer(pem)
-
-  server.on('stream', (stream, headers) => {
-    t.strictEqual(headers['x-my-header'], 'foo')
-    t.strictEqual(headers[':method'], 'GET')
-    stream.respond({
-      'content-type': 'text/plain; charset=utf-8',
-      'x-custom-h2': 'hello',
-      ':status': 200
-    })
-    stream.end('hello h2!')
-  })
-
-  server.on('session', session => {
-    setTimeout(() => {
-      session.goaway(0)
-    }, 1000)
-  })
-
-  server.listen(0)
-  await once(server, 'listening')
-
-  const client = new Client(`https://localhost:${server.address().port}`, {
-    connect: {
-      rejectUnauthorized: false
-    },
-    allowH2: true
-  })
-
-  t = tspl(t, { plan: 9 })
-  after(() => server.close())
-  after(() => client.close())
-
-  const response = await client.request({
-    path: '/',
-    method: 'GET',
-    headers: {
-      'x-my-header': 'foo'
-    }
-  })
-
-  response.body.on('data', chunk => {
-    body.push(chunk)
-  })
-
-  await once(response.body, 'end')
-  t.strictEqual(response.statusCode, 200)
-  t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8')
-  t.strictEqual(response.headers['x-custom-h2'], 'hello')
-  t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!')
-
-  const [url, disconnectClient, err] = await once(client, 'disconnect')
-
-  t.ok(url instanceof URL)
-  t.deepStrictEqual(disconnectClient, [client])
-  t.strictEqual(err.message, 'HTTP/2: "GOAWAY" frame received with code 0')
-})
-
 test('Should throw if bad allowH2 has been passed', async t => {
   t = tspl(t, { plan: 1 })
 
@@ -394,23 +334,15 @@ test(
 
     after(() => server.close())
     after(() => client.close())
-    t = tspl(t, { plan: 2 })
+    t = tspl(t, { plan: 1 })
 
-    try {
-      await client.request({
-        path: '/',
-        method: 'GET',
-        headers: {
-          'x-my-header': 'foo'
-        }
-      })
-    } catch (error) {
-      t.strictEqual(
-        error.message,
-        'Client network socket disconnected before secure TLS connection was established'
-      )
-      t.strictEqual(error.code, 'ECONNRESET')
-    }
+    await t.rejects(client.request({
+      path: '/',
+      method: 'GET',
+      headers: {
+        'x-my-header': 'foo'
+      }
+    }))
   }
 )
 
@@ -852,7 +784,10 @@ test('Should handle h2 request with body (string or buffer) - dispatch', async t
         onHeaders (statusCode, headers) {
           t.strictEqual(statusCode, 200)
           t.strictEqual(headers[0].toString('utf-8'), 'content-type')
-          t.strictEqual(headers[1].toString('utf-8'), 'text/plain; charset=utf-8')
+          t.strictEqual(
+            headers[1].toString('utf-8'),
+            'text/plain; charset=utf-8'
+          )
           t.strictEqual(headers[2].toString('utf-8'), 'x-custom-h2')
           t.strictEqual(headers[3].toString('utf-8'), 'foo')
         },
@@ -1183,56 +1118,53 @@ test('Agent should support H2 connection', async t => {
   t.strictEqual(Buffer.concat(body).toString('utf8'), 'hello h2!')
 })
 
-test(
-  'Should provide pseudo-headers in proper order',
-  async t => {
-    t = tspl(t, { plan: 2 })
+test('Should provide pseudo-headers in proper order', async t => {
+  t = tspl(t, { plan: 2 })
 
-    const server = createSecureServer(pem)
-    server.on('stream', (stream, _headers, _flags, rawHeaders) => {
-      t.deepStrictEqual(rawHeaders, [
-        ':authority',
-        `localhost:${server.address().port}`,
-        ':method',
-        'GET',
-        ':path',
-        '/',
-        ':scheme',
-        'https'
-      ])
+  const server = createSecureServer(pem)
+  server.on('stream', (stream, _headers, _flags, rawHeaders) => {
+    t.deepStrictEqual(rawHeaders, [
+      ':authority',
+      `localhost:${server.address().port}`,
+      ':method',
+      'GET',
+      ':path',
+      '/',
+      ':scheme',
+      'https'
+    ])
 
-      stream.respond({
-        'content-type': 'text/plain; charset=utf-8',
-        ':status': 200
-      })
-      stream.end()
+    stream.respond({
+      'content-type': 'text/plain; charset=utf-8',
+      ':status': 200
     })
+    stream.end()
+  })
 
-    server.listen(0)
-    await once(server, 'listening')
+  server.listen(0)
+  await once(server, 'listening')
 
-    const client = new Client(`https://localhost:${server.address().port}`, {
-      connect: {
-        rejectUnauthorized: false
-      },
-      allowH2: true
-    })
+  const client = new Client(`https://localhost:${server.address().port}`, {
+    connect: {
+      rejectUnauthorized: false
+    },
+    allowH2: true
+  })
 
-    after(() => server.close())
-    after(() => client.close())
+  after(() => server.close())
+  after(() => client.close())
 
-    const response = await client.request({
-      path: '/',
-      method: 'GET'
-    })
+  const response = await client.request({
+    path: '/',
+    method: 'GET'
+  })
 
-    t.strictEqual(response.statusCode, 200)
+  t.strictEqual(response.statusCode, 200)
 
-    await response.body.dump()
+  await response.body.dump()
 
-    await t.complete
-  }
-)
+  await t.complete
+})
 
 test('The h2 pseudo-headers is not included in the headers', async t => {
   const server = createSecureServer(pem)
@@ -1287,16 +1219,20 @@ test('Should throw informational error on half-closed streams (remote)', async t
   })
 
   t = tspl(t, { plan: 2 })
-  after(() => server.close())
-  after(() => client.close())
-
-  await client.request({
-    path: '/',
-    method: 'GET'
-  }).catch(err => {
-    t.strictEqual(err.message, 'HTTP/2: stream half-closed (remote)')
-    t.strictEqual(err.code, 'UND_ERR_INFO')
+  after(async () => {
+    server.close()
+    await client.close()
   })
+
+  await client
+    .request({
+      path: '/',
+      method: 'GET'
+    })
+    .catch(err => {
+      t.strictEqual(err.message, 'HTTP/2: stream half-closed (remote)')
+      t.strictEqual(err.code, 'UND_ERR_INFO')
+    })
 })
 
 test('#2364 - Concurrent aborts', async t => {
@@ -1325,62 +1261,76 @@ test('#2364 - Concurrent aborts', async t => {
     allowH2: true
   })
 
-  t = tspl(t, { plan: 18 })
+  t = tspl(t, { plan: 14 })
   after(() => server.close())
   after(() => client.close())
-  const controller = new AbortController()
+  const signal = AbortSignal.timeout(50)
 
-  client.request({
-    path: '/1',
-    method: 'GET',
-    headers: {
-      'x-my-header': 'foo'
+  client.request(
+    {
+      path: '/1',
+      method: 'GET',
+      headers: {
+        'x-my-header': 'foo'
+      }
+    },
+    (err, response) => {
+      t.ifError(err)
+      t.strictEqual(
+        response.headers['content-type'],
+        'text/plain; charset=utf-8'
+      )
+      t.strictEqual(response.headers['x-custom-h2'], 'hello')
+      t.strictEqual(response.statusCode, 200)
     }
-  }, (err, response) => {
-    t.ifError(err)
-    t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8')
-    t.strictEqual(response.headers['x-custom-h2'], 'hello')
-    t.strictEqual(response.statusCode, 200)
-    response.body.dump()
-  })
+  )
 
-  client.request({
-    path: '/2',
-    method: 'GET',
-    headers: {
-      'x-my-header': 'foo'
+  client.request(
+    {
+      path: '/2',
+      method: 'GET',
+      headers: {
+        'x-my-header': 'foo'
+      },
+      signal
     },
-    signal: controller.signal
-  }, (err, response) => {
-    t.strictEqual(err.name, 'AbortError')
-  })
-
-  client.request({
-    path: '/3',
-    method: 'GET',
-    headers: {
-      'x-my-header': 'foo'
+    (err, response) => {
+      t.strictEqual(err.name, 'TimeoutError')
     }
-  }, (err, response) => {
-    t.ifError(err)
-    t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8')
-    t.strictEqual(response.headers['x-custom-h2'], 'hello')
-    t.strictEqual(response.statusCode, 200)
-    response.body.dump()
-  })
+  )
 
-  client.request({
-    path: '/4',
-    method: 'GET',
-    headers: {
-      'x-my-header': 'foo'
+  client.request(
+    {
+      path: '/3',
+      method: 'GET',
+      headers: {
+        'x-my-header': 'foo'
+      }
     },
-    signal: controller.signal
-  }, (err, response) => {
-    t.strictEqual(err.name, 'AbortError')
-  })
+    (err, response) => {
+      t.ifError(err)
+      t.strictEqual(
+        response.headers['content-type'],
+        'text/plain; charset=utf-8'
+      )
+      t.strictEqual(response.headers['x-custom-h2'], 'hello')
+      t.strictEqual(response.statusCode, 200)
+    }
+  )
 
-  controller.abort()
+  client.request(
+    {
+      path: '/4',
+      method: 'GET',
+      headers: {
+        'x-my-header': 'foo'
+      },
+      signal
+    },
+    (err, response) => {
+      t.strictEqual(err.name, 'TimeoutError')
+    }
+  )
 
   await t.completed
 })
@@ -1418,8 +1368,8 @@ test('#3046 - GOAWAY Frame', async t => {
   })
 
   t = tspl(t, { plan: 7 })
-  after(() => server.close())
   after(() => client.close())
+  after(() => server.close())
 
   client.on('disconnect', (url, disconnectClient, err) => {
     t.ok(url instanceof URL)
@@ -1439,10 +1389,107 @@ test('#3046 - GOAWAY Frame', async t => {
   t.strictEqual(response.headers['x-custom-h2'], 'hello')
   t.strictEqual(response.statusCode, 200)
 
-  t.rejects(response.body.text.bind(response.body), {
+  t.rejects(response.body.text(), {
     message: 'HTTP/2: "GOAWAY" frame received with code 0',
-    code: 'UND_ERR_ABORTED'
+    code: 'UND_ERR_SOCKET'
+  })
+
+  await t.completed
+})
+
+test('#3671 - Graceful close', async (t) => {
+  const server = createSecureServer(pem)
+
+  server.on('stream', (stream, headers) => {
+    setTimeout(() => {
+      stream.respond({
+        'content-type': 'text/plain; charset=utf-8',
+        'x-custom-h2': 'hello',
+        ':status': 200
+      })
+      stream.end('Hello World')
+    }, 200)
+  })
+
+  server.listen(0)
+  await once(server, 'listening')
+
+  const client = new Client(`https://localhost:${server.address().port}`, {
+    connect: {
+      rejectUnauthorized: false
+    },
+    allowH2: true
+  })
+
+  t = tspl(t, { plan: 5 })
+  after(() => server.close())
+
+  client.request({
+    path: '/',
+    method: 'GET',
+    headers: {
+      'x-my-header': 'foo'
+    }
+  }, async (err, response) => {
+    t.ifError(err)
+    t.strictEqual(response.headers['content-type'], 'text/plain; charset=utf-8')
+    t.strictEqual(response.headers['x-custom-h2'], 'hello')
+    t.strictEqual(response.statusCode, 200)
+    t.equal(await response.body.text(), 'Hello World')
   })
 
+  await client.close()
+
   await t.completed
 })
+
+test('#3803 - sending FormData bodies works', async (t) => {
+  const assert = tspl(t, { plan: 4 })
+
+  const server = createSecureServer(pem).listen(0)
+  server.on('stream', async (stream, headers) => {
+    const contentLength = Number(headers['content-length'])
+
+    assert.ok(!Number.isNaN(contentLength))
+    assert.ok(headers['content-type']?.startsWith('multipart/form-data; boundary='))
+
+    stream.respond({ ':status': 200 })
+
+    const fd = await new Response(stream, {
+      headers: {
+        'content-type': headers['content-type']
+      }
+    }).formData()
+
+    assert.deepEqual(fd.get('a'), 'b')
+    assert.deepEqual(fd.get('c').name, 'e.fgh')
+
+    stream.end()
+  })
+
+  await once(server, 'listening')
+
+  const client = new Client(`https://localhost:${server.address().port}`, {
+    connect: {
+      rejectUnauthorized: false
+    },
+    allowH2: true
+  })
+
+  t.after(async () => {
+    server.close()
+    await client.close()
+  })
+
+  const fd = new FormData()
+  fd.set('a', 'b')
+  fd.set('c', new Blob(['d']), 'e.fgh')
+
+  await client.request({
+    path: '/',
+    method: 'POST',
+    body: fd
+  })
+
+  await assert.completed
+})
diff --git a/test/interceptors/dns.js b/test/interceptors/dns.js
new file mode 100644
index 00000000000..6b4b30b13cc
--- /dev/null
+++ test/interceptors/dns.js
@@ -0,0 +1,1721 @@
+'use strict'
+
+const { test, after } = require('node:test')
+const { isIP } = require('node:net')
+const { lookup } = require('node:dns')
+const { createServer } = require('node:http')
+const { createServer: createSecureServer } = require('node:https')
+const { once } = require('node:events')
+const { setTimeout: sleep } = require('node:timers/promises')
+
+const { tspl } = require('@matteo.collina/tspl')
+const pem = require('https-pem')
+
+const { interceptors, Agent } = require('../..')
+const { dns } = interceptors
+
+test('Should validate options', t => {
+  t = tspl(t, { plan: 10 })
+
+  t.throws(() => dns({ dualStack: 'true' }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ dualStack: 0 }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ affinity: '4' }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ affinity: 7 }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ maxTTL: -1 }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ maxTTL: '0' }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ maxItems: '1' }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ maxItems: -1 }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ lookup: {} }), { code: 'UND_ERR_INVALID_ARG' })
+  t.throws(() => dns({ pick: [] }), { code: 'UND_ERR_INVALID_ARG' })
+})
+
+test('Should automatically resolve IPs (dual stack)', async t => {
+  t = tspl(t, { plan: 8 })
+
+  const hostsnames = []
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        const url = new URL(opts.origin)
+
+        t.equal(hostsnames.includes(url.hostname), false)
+
+        if (url.hostname[0] === '[') {
+          // [::1] -> ::1
+          t.equal(isIP(url.hostname.slice(1, 4)), 6)
+        } else {
+          t.equal(isIP(url.hostname), 4)
+        }
+
+        hostsnames.push(url.hostname)
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      lookup: (_origin, _opts, cb) => {
+        cb(null, [
+          {
+            address: '::1',
+            family: 6
+          },
+          {
+            address: '127.0.0.1',
+            family: 4
+          }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+})
+
+test('Should respect DNS origin hostname for SNI on TLS', async t => {
+  t = tspl(t, { plan: 12 })
+
+  const hostsnames = []
+  const server = createSecureServer(pem)
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    t.equal(req.headers.host, 'localhost')
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent({
+    connect: {
+      rejectUnauthorized: false
+    }
+  }).compose([
+    dispatch => {
+      return (opts, handler) => {
+        const url = new URL(opts.origin)
+
+        t.equal(hostsnames.includes(url.hostname), false)
+        t.equal(opts.servername, 'localhost')
+
+        if (url.hostname[0] === '[') {
+          // [::1] -> ::1
+          t.equal(isIP(url.hostname.slice(1, 4)), 6)
+        } else {
+          t.equal(isIP(url.hostname), 4)
+        }
+
+        hostsnames.push(url.hostname)
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      lookup: (_origin, _opts, cb) => {
+        cb(null, [
+          {
+            address: '::1',
+            family: 6
+          },
+          {
+            address: '127.0.0.1',
+            family: 4
+          }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `https://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `https://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+})
+
+test('Should recover on network errors (dual stack - 4)', async t => {
+  t = tspl(t, { plan: 8 })
+
+  let counter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0, '::1')
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          case 3:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 4:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      lookup: (_origin, _opts, cb) => {
+        cb(null, [
+          {
+            address: '::1',
+            family: 6
+          },
+          {
+            address: '127.0.0.1',
+            family: 4
+          }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+})
+
+test('Should recover on network errors (dual stack - 6)', async t => {
+  t = tspl(t, { plan: 7 })
+
+  let counter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0, '127.0.0.1')
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          case 3:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname), 4)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      lookup: (_origin, _opts, cb) => {
+        cb(null, [
+          {
+            address: '::1',
+            family: 6
+          },
+          {
+            address: '127.0.0.1',
+            family: 4
+          }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+})
+
+test('Should throw when on dual-stack disabled (4)', async t => {
+  t = tspl(t, { plan: 2 })
+
+  let counter = 0
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({ dualStack: false, affinity: 4 })
+  ])
+
+  const promise = client.request({
+    ...requestOptions,
+    origin: 'http://localhost:1234'
+  })
+
+  await t.rejects(promise, 'ECONNREFUSED')
+
+  await t.complete
+})
+
+test('Should throw when on dual-stack disabled (6)', async t => {
+  t = tspl(t, { plan: 2 })
+
+  let counter = 0
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({ dualStack: false, affinity: 6 })
+  ])
+
+  const promise = client.request({
+    ...requestOptions,
+    origin: 'http://localhost:9999'
+  })
+
+  await t.rejects(promise, 'ECONNREFUSED')
+
+  await t.complete
+})
+
+test('Should automatically resolve IPs (dual stack disabled - 4)', async t => {
+  t = tspl(t, { plan: 6 })
+
+  let counter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname), 4)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({ dualStack: false })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+})
+
+test('Should automatically resolve IPs (dual stack disabled - 6)', async t => {
+  t = tspl(t, { plan: 6 })
+
+  let counter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({ dualStack: false, affinity: 6 })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+})
+
+test('Should we handle TTL (4)', async t => {
+  t = tspl(t, { plan: 10 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0, '127.0.0.1')
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 2:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 3:
+            t.equal(isIP(url.hostname), 4)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      dualStack: false,
+      affinity: 4,
+      maxTTL: 400,
+      lookup: (origin, opts, cb) => {
+        ++lookupCounter
+        lookup(
+          origin.hostname,
+          { all: true, family: opts.affinity },
+          cb
+        )
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  await sleep(200)
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  await sleep(300)
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 2)
+})
+
+test('Should we handle TTL (6)', async t => {
+  t = tspl(t, { plan: 10 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0, '::1')
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          case 3:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      dualStack: false,
+      affinity: 6,
+      maxTTL: 400,
+      lookup: (origin, opts, cb) => {
+        ++lookupCounter
+        lookup(
+          origin.hostname,
+          { all: true, family: opts.affinity },
+          cb
+        )
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  await sleep(200)
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  await sleep(300)
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+  t.equal(lookupCounter, 2)
+})
+
+test('Should set lowest TTL between resolved and option maxTTL', async t => {
+  t = tspl(t, { plan: 9 })
+
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0, '127.0.0.1')
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose(
+    dns({
+      dualStack: false,
+      affinity: 4,
+      maxTTL: 200,
+      lookup: (origin, opts, cb) => {
+        ++lookupCounter
+        cb(null, [
+          {
+            address: '127.0.0.1',
+            family: 4,
+            ttl: lookupCounter === 1 ? 50 : 500
+          }
+        ])
+      }
+    })
+  )
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  await sleep(100)
+
+  // 100ms: lookup since ttl = Math.min(50, maxTTL: 200)
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  await sleep(100)
+
+  // 100ms: cached since ttl = Math.min(500, maxTTL: 200)
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+
+  await sleep(150)
+
+  // 250ms: lookup since ttl = Math.min(500, maxTTL: 200)
+  const response4 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response4.statusCode, 200)
+  t.equal(await response4.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 3)
+})
+
+test('Should use all dns entries (dual stack)', async t => {
+  t = tspl(t, { plan: 16 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+        switch (counter) {
+          case 1:
+            t.equal(url.hostname, '1.1.1.1')
+            break
+
+          case 2:
+            t.equal(url.hostname, '[::1]')
+            break
+
+          case 3:
+            t.equal(url.hostname, '2.2.2.2')
+            break
+
+          case 4:
+            t.equal(url.hostname, '[::2]')
+            break
+
+          case 5:
+            t.equal(url.hostname, '1.1.1.1')
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        url.hostname = '127.0.0.1'
+        opts.origin = url.toString()
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '::1', family: 6 },
+          { address: '::2', family: 6 },
+          { address: '1.1.1.1', family: 4 },
+          { address: '2.2.2.2', family: 4 }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  for (let i = 0; i < 5; i++) {
+    const response = await client.request({
+      ...requestOptions,
+      origin: `http://localhost:${server.address().port}`
+    })
+
+    t.equal(response.statusCode, 200)
+    t.equal(await response.body.text(), 'hello world!')
+  }
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should use all dns entries (dual stack disabled - 4)', async t => {
+  t = tspl(t, { plan: 10 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(url.hostname, '1.1.1.1')
+            break
+
+          case 2:
+            t.equal(url.hostname, '2.2.2.2')
+            break
+
+          case 3:
+            t.equal(url.hostname, '1.1.1.1')
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        url.hostname = '127.0.0.1'
+        opts.origin = url.toString()
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      dualStack: false,
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '1.1.1.1', family: 4 },
+          { address: '2.2.2.2', family: 4 }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response1 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response1.statusCode, 200)
+  t.equal(await response1.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should use all dns entries (dual stack disabled - 6)', async t => {
+  t = tspl(t, { plan: 10 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(url.hostname, '[::1]')
+            break
+
+          case 2:
+            t.equal(url.hostname, '[::2]')
+            break
+
+          case 3:
+            t.equal(url.hostname, '[::1]')
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        url.hostname = '127.0.0.1'
+        opts.origin = url.toString()
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      dualStack: false,
+      affinity: 6,
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '::1', family: 6 },
+          { address: '::2', family: 6 }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response1 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response1.statusCode, 200)
+  t.equal(await response1.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should handle single family resolved (dual stack)', async t => {
+  t = tspl(t, { plan: 7 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        if (lookupCounter === 1) {
+          cb(null, [
+            { address: '127.0.0.1', family: 4, ttl: 50 }
+          ])
+        } else {
+          cb(null, [
+            { address: '::1', family: 6, ttl: 50 }
+          ])
+        }
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  await sleep(100)
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 2)
+})
+
+test('Should prefer affinity (dual stack - 4)', async t => {
+  t = tspl(t, { plan: 10 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(url.hostname, '1.1.1.1')
+            break
+
+          case 2:
+            t.equal(url.hostname, '2.2.2.2')
+            break
+
+          case 3:
+            t.equal(url.hostname, '1.1.1.1')
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        url.hostname = '127.0.0.1'
+        opts.origin = url.toString()
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      affinity: 4,
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '1.1.1.1', family: 4 },
+          { address: '2.2.2.2', family: 4 },
+          { address: '::1', family: 6 },
+          { address: '::2', family: 6 }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  await sleep(100)
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should prefer affinity (dual stack - 6)', async t => {
+  t = tspl(t, { plan: 10 })
+
+  let counter = 0
+  let lookupCounter = 0
+  const server = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(url.hostname, '[::1]')
+            break
+
+          case 2:
+            t.equal(url.hostname, '[::2]')
+            break
+
+          case 3:
+            t.equal(url.hostname, '[::1]')
+            break
+          default:
+            t.fail('should not reach this point')
+        }
+
+        url.hostname = '127.0.0.1'
+        opts.origin = url.toString()
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      affinity: 6,
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '1.1.1.1', family: 4 },
+          { address: '2.2.2.2', family: 4 },
+          { address: '::1', family: 6 },
+          { address: '::2', family: 6 }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  await sleep(100)
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server.address().port}`
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world!')
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should use resolved ports (4)', async t => {
+  t = tspl(t, { plan: 5 })
+
+  let lookupCounter = 0
+  const server1 = createServer()
+  const server2 = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server1.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server1.listen(0)
+
+  server2.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world! (x2)')
+  })
+  server2.listen(0)
+
+  await Promise.all([once(server1, 'listening'), once(server2, 'listening')])
+
+  const client = new Agent().compose([
+    dns({
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '127.0.0.1', family: 4, port: server1.address().port },
+          { address: '127.0.0.1', family: 4, port: server2.address().port }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server1.close()
+    server2.close()
+
+    await Promise.all([once(server1, 'close'), once(server2, 'close')])
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: 'http://localhost'
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+   , ...requestOptions,
+    origin: 'http://localhost'
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world! (x2)')
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should use resolved ports (6)', async t => {
+  t = tspl(t, { plan: 5 })
+
+  let lookupCounter = 0
+  const server1 = createServer()
+  const server2 = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server1.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server1.listen(0, '::1')
+
+  server2.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world! (x2)')
+  })
+  server2.listen(0, '::1')
+
+  await Promise.all([once(server1, 'listening'), once(server2, 'listening')])
+
+  const client = new Agent().compose([
+    dns({
+      lookup (origin, opts, cb) {
+        lookupCounter++
+        cb(null, [
+          { address: '::1', family: 6, port: server1.address().port },
+          { address: '::1', family: 6, port: server2.address().port }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server1.close()
+    server2.close()
+
+    await Promise.all([once(server1, 'close'), once(server2, 'close')])
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: 'http://localhost'
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: 'http://localhost'
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world! (x2)')
+
+  t.equal(lookupCounter, 1)
+})
+
+test('Should handle max cached items', async t => {
+  t = tspl(t, { plan: 9 })
+
+  let counter = 0
+  const server1 = createServer()
+  const server2 = createServer()
+  const requestOptions = {
+    method: 'GET',
+    path: '/',
+    headers: {
+      'content-type': 'application/json'
+    }
+  }
+
+  server1.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world!')
+  })
+
+  server1.listen(0)
+
+  server2.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    res.end('hello world! (x2)')
+  })
+  server2.listen(0)
+
+  await Promise.all([once(server1, 'listening'), once(server2, 'listening')])
+
+  const client = new Agent().compose([
+    dispatch => {
+      return (opts, handler) => {
+        ++counter
+        const url = new URL(opts.origin)
+
+        switch (counter) {
+          case 1:
+            t.equal(isIP(url.hostname), 4)
+            break
+
+          case 2:
+            // [::1] -> ::1
+            t.equal(isIP(url.hostname.slice(1, 4)), 6)
+            break
+
+          case 3:
+            t.equal(url.hostname, 'developer.mozilla.org')
+            // Rewrite origin to avoid reaching internet
+            opts.origin = `http://127.0.0.1:${server2.address().port}`
+            break
+          default:
+            t.fails('should not reach this point')
+        }
+
+        return dispatch(opts, handler)
+      }
+    },
+    dns({
+      maxItems: 1,
+      lookup: (_origin, _opts, cb) => {
+        cb(null, [
+          {
+            address: '::1',
+            family: 6
+          },
+          {
+            address: '127.0.0.1',
+            family: 4
+          }
+        ])
+      }
+    })
+  ])
+
+  after(async () => {
+    await client.close()
+    server1.close()
+    server2.close()
+
+    await Promise.all([once(server1, 'close'), once(server2, 'close')])
+  })
+
+  const response = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server1.address().port}`
+  })
+
+  t.equal(response.statusCode, 200)
+  t.equal(await response.body.text(), 'hello world!')
+
+  const response2 = await client.request({
+    ...requestOptions,
+    origin: `http://localhost:${server1.address().port}`
+  })
+
+  t.equal(response2.statusCode, 200)
+  t.equal(await response2.body.text(), 'hello world!')
+
+  const response3 = await client.request({
+    ...requestOptions,
+    origin: 'https://developer.mozilla.org'
+  })
+
+  t.equal(response3.statusCode, 200)
+  t.equal(await response3.body.text(), 'hello world! (x2)')
+})
diff --git a/test/interceptors/response-error.js b/test/interceptors/response-error.js
new file mode 100644
index 00000000000..afd9c00a500
--- /dev/null
+++ test/interceptors/response-error.js
@@ -0,0 +1,67 @@
+'use strict'
+
+const assert = require('assert')
+const { test } = require('node:test')
+const createResponseErrorInterceptor = require('../../lib/interceptor/response-error')
+
+test('should not error if request is not meant to throw error', async (t) => {
+  const opts = { throwOnError: false }
+  const handler = {
+    onError: () => {},
+    onData: () => {},
+    onComplete: () => {}
+  }
+
+  const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete())
+
+  assert.doesNotThrow(() => interceptor(opts, handler))
+})
+
+test('should error if request status code is in the specified error codes', async (t) => {
+  const opts = { throwOnError: true, statusCodes: [500] }
+  const response = { statusCode: 500 }
+  let capturedError
+  const handler = {
+    onError: (err) => {
+      capturedError = err
+    },
+    onData: () => {},
+    onComplete: () => {}
+  }
+
+  const interceptor = createResponseErrorInterceptor((opts, handler) => {
+    if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
+      handler.onError(new Error('Response Error'))
+    } else {
+      handler.onComplete()
+    }
+  })
+
+  interceptor({ ...opts, response }, handler)
+
+  await new Promise(resolve => setImmediate(resolve))
+
+  assert(capturedError, 'Expected error to be captured but it was not.')
+  assert.strictEqual(capturedError.message, 'Response Error')
+  assert.strictEqual(response.statusCode, 500)
+})
+
+test('should not error if request status code is not in the specified error codes', async (t) => {
+  const opts = { throwOnError: true, statusCodes: [500] }
+  const response = { statusCode: 404 }
+  const handler = {
+    onError: () => {},
+    onData: () => {},
+    onComplete: () => {}
+  }
+
+  const interceptor = createResponseErrorInterceptor((opts, handler) => {
+    if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
+      handler.onError(new Error('Response Error'))
+    } else {
+      handler.onComplete()
+    }
+  })
+
+  assert.doesNotThrow(() => interceptor({ ...opts, response }, handler))
+})
diff --git test/interceptors/retry.js test/interceptors/retry.js
index 6e6f997cafa..b510ddba6c0 100644
--- test/interceptors/retry.js
+++ test/interceptors/retry.js
@@ -253,14 +253,15 @@ test('Should handle 206 partial content', async t => {
   const server = createServer((req, res) => {
     if (x === 0) {
       t.ok(true, 'pass')
+      res.setHeader('content-length', '6')
       res.setHeader('etag', 'asd')
       res.write('abc')
       setTimeout(() => {
         res.destroy()
       }, 1e2)
     } else if (x === 1) {
-      t.deepStrictEqual(req.headers.range, 'bytes=3-')
-      res.setHeader('content-range', 'bytes 3-6/6')
+      t.deepStrictEqual(req.headers.range, 'bytes=3-5')
+      res.setHeader('content-range', 'bytes 3-5/6')
       res.setHeader('etag', 'asd')
       res.statusCode = 206
       res.end('def')
diff --git a/test/issue-3356.js b/test/issue-3356.js
new file mode 100644
index 00000000000..fd7bf59656f
--- /dev/null
+++ test/issue-3356.js
@@ -0,0 +1,60 @@
+'use strict'
+
+const { tspl } = require('@matteo.collina/tspl')
+const { test, after } = require('node:test')
+const { createServer } = require('node:http')
+const { once } = require('node:events')
+const { tick: fastTimersTick } = require('../lib/util/timers')
+const { fetch, Agent, RetryAgent } = require('..')
+
+test('https://github.com/nodejs/undici/issues/3356', async (t) => {
+  t = tspl(t, { plan: 3 })
+
+  let shouldRetry = true
+  const server = createServer()
+  server.on('request', (req, res) => {
+    res.writeHead(200, { 'content-type': 'text/plain' })
+    if (shouldRetry) {
+      shouldRetry = false
+
+      res.flushHeaders()
+      res.write('h')
+      setTimeout(() => { res.end('ello world!') }, 100)
+    } else {
+      res.end('hello world!')
+    }
+  })
+
+  server.listen(0)
+
+  await once(server, 'listening')
+
+  after(async () => {
+    server.close()
+
+    await once(server, 'close')
+  })
+
+  const agent = new RetryAgent(new Agent({ bodyTimeout: 50 }), {
+    errorCodes: ['UND_ERR_BODY_TIMEOUT']
+  })
+
+  const response = await fetch(`http://localhost:${server.address().port}`, {
+    dispatcher: agent
+  })
+
+  fastTimersTick()
+
+  setTimeout(async () => {
+    try {
+      t.equal(response.status, 200)
+      // consume response
+      await response.text()
+    } catch (err) {
+      t.equal(err.name, 'TypeError')
+      t.equal(err.cause.code, 'UND_ERR_REQ_RETRY')
+    }
+  }, 200)
+
+  await t.completed
+})
diff --git a/test/issue-3410.js b/test/issue-3410.js
new file mode 100644
index 00000000000..d5bb8093ef2
--- /dev/null
+++ test/issue-3410.js
@@ -0,0 +1,88 @@
+'use strict'
+
+const { tspl } = require('@matteo.collina/tspl')
+const { fork } = require('node:child_process')
+const { resolve: pathResolve } = require('node:path')
+const { describe, test } = require('node:test')
+const { Agent, fetch, setGlobalDispatcher } = require('..')
+const { eventLoopBlocker } = require('./utils/event-loop-blocker')
+
+describe('https://github.com/nodejs/undici/issues/3410', () => {
+  test('FastTimers', async (t) => {
+    t = tspl(t, { plan: 1 })
+
+    // Spawn a server in a new process to avoid effects from the blocking event loop
+    const {
+      serverProcess,
+      address
+    } = await new Promise((resolve, reject) => {
+      const childProcess = fork(
+        pathResolve(__dirname, './utils/hello-world-server.js'),
+        [],
+        { windowsHide: true }
+      )
+
+      childProcess.on('message', (address) => {
+        resolve({
+          serverProcess: childProcess,
+          address
+        })
+      })
+      childProcess.on('error', err => {
+        reject(err)
+      })
+    })
+
+    const connectTimeout = 2000
+    setGlobalDispatcher(new Agent({ connectTimeout }))
+
+    const fetchPromise = fetch(address)
+
+    eventLoopBlocker(3000)
+
+    const response = await fetchPromise
+
+    t.equal(await response.text(), 'Hello World')
+
+    serverProcess.kill('SIGKILL')
+  })
+
+  test('native Timers', async (t) => {
+    t = tspl(t, { plan: 1 })
+
+    // Spawn a server in a new process to avoid effects from the blocking event loop
+    const {
+      serverProcess,
+      address
+    } = await new Promise((resolve, reject) => {
+      const childProcess = fork(
+        pathResolve(__dirname, './utils/hello-world-server.js'),
+        [],
+        { windowsHide: true }
+      )
+
+      childProcess.on('message', (address) => {
+        resolve({
+          serverProcess: childProcess,
+          address
+        })
+      })
+      childProcess.on('error', err => {
+        reject(err)
+      })
+    })
+
+    const connectTimeout = 900
+    setGlobalDispatcher(new Agent({ connectTimeout }))
+
+    const fetchPromise = fetch(address)
+
+    eventLoopBlocker(1500)
+
+    const response = await fetchPromise
+
+    t.equal(await response.text(), 'Hello World')
+
+    serverProcess.kill('SIGKILL')
+  })
+})
diff --git test/mock-interceptor.js test/mock-interceptor.js
index 0d16290f579..8364fb40415 100644
--- test/mock-interceptor.js
+++ test/mock-interceptor.js
@@ -6,6 +6,7 @@ const { MockInterceptor, MockScope } = require('../lib/mock/mock-interceptor')
 const MockAgent = require('../lib/mock/mock-agent')
 const { kDispatchKey } = require('../lib/mock/mock-symbols')
 const { InvalidArgumentError } = require('../lib/core/errors')
+const { fetch } = require('../lib/web/fetch/index')
 
 describe('MockInterceptor - path', () => {
   test('should remove hash fragment from paths', t => {
@@ -257,3 +258,51 @@ describe('MockInterceptor - replyContentLength', () => {
     t.ok(result instanceof MockInterceptor)
   })
 })
+
+describe('MockInterceptor - different payloads', () => {
+  [
+    // Buffer
+    ['arrayBuffer', 'ArrayBuffer', 'ArrayBuffer', new TextEncoder().encode('{"test":true}').buffer, new TextEncoder().encode('{"test":true}').buffer],
+    ['json', 'ArrayBuffer', 'Object', new TextEncoder().encode('{"test":true}').buffer, { test: true }],
+    ['bytes', 'ArrayBuffer', 'Uint8Array', new TextEncoder().encode('{"test":true}').buffer, new TextEncoder().encode('{"test":true}')],
+    ['text', 'ArrayBuffer', 'string', new TextEncoder().encode('{"test":true}').buffer, '{"test":true}'],
+
+    // Buffer
+    ['arrayBuffer', 'Buffer', 'ArrayBuffer', Buffer.from('{"test":true}'), new TextEncoder().encode('{"test":true}').buffer],
+    ['json', 'Buffer', 'Object', Buffer.from('{"test":true}'), { test: true }],
+    ['bytes', 'Buffer', 'Uint8Array', Buffer.from('{"test":true}'), new TextEncoder().encode('{"test":true}')],
+    ['text', 'Buffer', 'string', Buffer.from('{"test":true}'), '{"test":true}'],
+
+    // Uint8Array
+    ['arrayBuffer', 'Uint8Array', 'ArrayBuffer', new TextEncoder().encode('{"test":true}'), new TextEncoder().encode('{"test":true}').buffer],
+    ['json', 'Uint8Array', 'Object', new TextEncoder().encode('{"test":true}'), { test: true }],
+    ['bytes', 'Uint8Array', 'Uint8Array', new TextEncoder().encode('{"test":true}'), new TextEncoder().encode('{"test":true}')],
+    ['text', 'Uint8Array', 'string', new TextEncoder().encode('{"test":true}'), '{"test":true}'],
+
+    // string
+    ['arrayBuffer', 'string', 'ArrayBuffer', '{"test":true}', new TextEncoder().encode('{"test":true}').buffer],
+    ['json', 'string', 'Object', '{"test":true}', { test: true }],
+    ['bytes', 'string', 'Uint8Array', '{"test":true}', new TextEncoder().encode('{"test":true}')],
+    ['text', 'string', 'string', '{"test":true}', '{"test":true}'],
+
+    // object
+    ['arrayBuffer', 'Object', 'ArrayBuffer', { test: true }, new TextEncoder().encode('{"test":true}').buffer],
+    ['json', 'Object', 'Object', { test: true }, { test: true }],
+    ['bytes', 'Object', 'Uint8Array', { test: true }, new TextEncoder().encode('{"test":true}')],
+    ['text', 'Object', 'string', { test: true }, '{"test":true}']
+  ].forEach(([method, inputType, outputType, input, output]) => {
+    test(`${inputType} will be returned as ${outputType} via ${method}()`, async (t) => {
+      t = tspl(t, { plan: 1 })
+
+      const mockAgent = new MockAgent()
+      mockAgent.disableNetConnect()
+      mockAgent
+        .get('https://localhost')
+        .intercept({ path: '/' }).reply(200, input)
+
+      const response = await fetch('https://localhost', { dispatcher: mockAgent })
+
+      t.deepStrictEqual(await response[method](), output)
+    })
+  })
+})
diff --git test/mock-utils.js test/mock-utils.js
index f27a6763ae9..b80db2a401c 100644
--- test/mock-utils.js
+++ test/mock-utils.js
@@ -162,6 +162,18 @@ describe('getResponseData', () => {
     const responseData = getResponseData(Buffer.from('test'))
     t.ok(Buffer.isBuffer(responseData))
   })
+
+  test('it should return Uint8Array untouched', (t) => {
+    t = tspl(t, { plan: 1 })
+    const responseData = getResponseData(new TextEncoder().encode('{"test":true}'))
+    t.ok(responseData instanceof Uint8Array)
+  })
+
+  test('it should return ArrayBuffers untouched', (t) => {
+    t = tspl(t, { plan: 1 })
+    const responseData = getResponseData(new TextEncoder().encode('{"test":true}').buffer)
+    t.ok(responseData instanceof ArrayBuffer)
+  })
 })
 
 test('getStatusText', (t) => {
diff --git a/test/node-platform-objects.js b/test/node-platform-objects.js
new file mode 100644
index 00000000000..19e2b01a507
--- /dev/null
+++ test/node-platform-objects.js
@@ -0,0 +1,33 @@
+'use strict'
+
+const { tspl } = require('@matteo.collina/tspl')
+const { test } = require('node:test')
+const { markAsUncloneable } = require('node:worker_threads')
+const { Response, Request, FormData, Headers, ErrorEvent, MessageEvent, CloseEvent, EventSource, WebSocket } = require('..')
+const { CacheStorage } = require('../lib/web/cache/cachestorage')
+const { Cache } = require('../lib/web/cache/cache')
+const { kConstruct } = require('../lib/core/symbols')
+
+test('unserializable web instances should be uncloneable if node exposes the api', (t) => {
+  if (markAsUncloneable !== undefined) {
+    t = tspl(t, { plan: 11 })
+    const uncloneables = [
+      { Uncloneable: Response, brand: 'Response' },
+      { Uncloneable: Request, value: 'http://localhost', brand: 'Request' },
+      { Uncloneable: FormData, brand: 'FormData' },
+      { Uncloneable: MessageEvent, value: 'dummy event', brand: 'MessageEvent' },
+      { Uncloneable: CloseEvent, value: 'dummy event', brand: 'CloseEvent' },
+      { Uncloneable: ErrorEvent, value: 'dummy event', brand: 'ErrorEvent' },
+      { Uncloneable: EventSource, value: 'http://localhost', brand: 'EventSource' },
+      { Uncloneable: Headers, brand: 'Headers' },
+      { Uncloneable: WebSocket, value: 'http://localhost', brand: 'WebSocket' },
+      { Uncloneable: Cache, value: kConstruct, brand: 'Cache' },
+      { Uncloneable: CacheStorage, value: kConstruct, brand: 'CacheStorage' }
+    ]
+    uncloneables.forEach((platformEntity) => {
+      t.throws(() => structuredClone(new platformEntity.Uncloneable(platformEntity.value)),
+        DOMException,
+        `Cloning ${platformEntity.brand} should throw DOMException`)
+    })
+  }
+})
diff --git test/node-test/client-dispatch.js test/node-test/client-dispatch.js
index f9ed888d44b..296e3b8d075 100644
--- test/node-test/client-dispatch.js
+++ test/node-test/client-dispatch.js
@@ -1051,7 +1051,7 @@ test('Issue#3065 - fix bad destroy handling', async (t) => {
 
 test('Issue#3065 - fix bad destroy handling (h2)', async (t) => {
   // Due to we handle the session, the request for h2 will fail on servername change
-  const p = tspl(t, { plan: 5 })
+  const p = tspl(t, { plan: 4 })
   const server = createSecureServer(pem)
   server.on('stream', (stream) => {
     stream.respond({
@@ -1105,8 +1105,7 @@ test('Issue#3065 - fix bad destroy handling (h2)', async (t) => {
         p.deepStrictEqual(dispatches, ['onConnect', 'onBodySent', 'onResponseStarted', 'onHeaders1', 'onData', 'onComplete'])
       },
       onError (err) {
-        p.strictEqual(err.code, 'UND_ERR_INFO')
-        p.strictEqual(err.message, 'servername changed')
+        p.ifError(err)
       }
     })
 
diff --git test/readable.js test/readable.js
index dd0631daf8b..e6a6ed0dccd 100644
--- test/readable.js
+++ test/readable.js
@@ -83,6 +83,27 @@ describe('Readable', () => {
     t.deepStrictEqual(arrayBuffer, expected)
   })
 
+  test('.bytes()', async function (t) {
+    t = tspl(t, { plan: 1 })
+
+    function resume () {
+    }
+    function abort () {
+    }
+    const r = new Readable({ resume, abort })
+
+    r.push(Buffer.from('hello'))
+    r.push(Buffer.from(' world'))
+
+    process.nextTick(() => {
+      r.push(null)
+    })
+
+    const bytes = await r.bytes()
+
+    t.deepStrictEqual(bytes, new TextEncoder().encode('hello world'))
+  })
+
   test('.json()', async function (t) {
     t = tspl(t, { plan: 1 })
 
diff --git test/request-timeout.js test/request-timeout.js
index 03d34c9bef5..19f602fff8b 100644
--- test/request-timeout.js
+++ test/request-timeout.js
@@ -1,11 +1,11 @@
 'use strict'
 
 const { tspl } = require('@matteo.collina/tspl')
-const { test, after } = require('node:test')
+const { resolve: pathResolve } = require('node:path')
+const { test, after, beforeEach } = require('node:test')
 const { createReadStream, writeFileSync, unlinkSync } = require('node:fs')
 const { Client, errors } = require('..')
 const { kConnect } = require('../lib/core/symbols')
-const timers = require('../lib/util/timers')
 const { createServer } = require('node:http')
 const EventEmitter = require('node:events')
 const FakeTimers = require('@sinonjs/fake-timers')
@@ -16,6 +16,14 @@ const {
   Writable,
   PassThrough
 } = require('node:stream')
+const {
+  tick: fastTimersTick,
+  reset: resetFastTimers
+} = require('../lib/util/timers')
+
+beforeEach(() => {
+  resetFastTimers()
+})
 
 test('request timeout', async (t) => {
   t = tspl(t, { plan: 1 })
@@ -23,7 +31,7 @@ test('request timeout', async (t) => {
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
-    }, 1000)
+    }, 2000)
   })
   after(() => server.close())
 
@@ -46,7 +54,7 @@ test('request timeout with readable body', async (t) => {
   })
   after(() => server.close())
 
-  const tempfile = `${__filename}.10mb.txt`
+  const tempfile = pathResolve(__dirname, 'request-timeout.10mb.bin')
   writeFileSync(tempfile, Buffer.alloc(10 * 1024 * 1024))
   after(() => unlinkSync(tempfile))
 
@@ -72,12 +80,6 @@ test('body timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     res.write('hello')
   })
@@ -91,12 +93,14 @@ test('body timeout', async (t) => {
       t.ifError(err)
       body.on('data', () => {
         clock.tick(100)
+        fastTimersTick(100)
       }).on('error', (err) => {
         t.ok(err instanceof errors.BodyTimeoutError)
       })
     })
 
     clock.tick(50)
+    fastTimersTick(50)
   })
 
   await t.completed
@@ -111,17 +115,12 @@ test('overridden request timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 100)
     clock.tick(100)
+    fastTimersTick(100)
   })
   after(() => server.close())
 
@@ -134,6 +133,7 @@ test('overridden request timeout', async (t) => {
     })
 
     clock.tick(50)
+    fastTimersTick(50)
   })
 
   await t.completed
@@ -148,12 +148,6 @@ test('overridden body timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     res.write('hello')
   })
@@ -166,13 +160,15 @@ test('overridden body timeout', async (t) => {
     client.request({ path: '/', method: 'GET', bodyTimeout: 50 }, (err, { body }) => {
       t.ifError(err)
       body.on('data', () => {
-        clock.tick(100)
+        fastTimersTick()
+        fastTimersTick()
       }).on('error', (err) => {
         t.ok(err instanceof errors.BodyTimeoutError)
       })
     })
 
-    clock.tick(50)
+    fastTimersTick()
+    fastTimersTick()
   })
 
   await t.completed
@@ -187,17 +183,12 @@ test('With EE signal', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 100)
     clock.tick(100)
+    fastTimersTick(100)
   })
   after(() => server.close())
 
@@ -213,6 +204,7 @@ test('With EE signal', async (t) => {
     })
 
     clock.tick(50)
+    fastTimersTick(50)
   })
 
   await t.completed
@@ -227,17 +219,12 @@ test('With abort-controller signal', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 100)
     clock.tick(100)
+    fastTimersTick(100)
   })
   after(() => server.close())
 
@@ -253,6 +240,7 @@ test('With abort-controller signal', async (t) => {
     })
 
     clock.tick(50)
+    fastTimersTick(50)
   })
 
   await t.completed
@@ -267,12 +255,6 @@ test('Abort before timeout (EE)', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const ee = new EventEmitter()
   const server = createServer((req, res) => {
     setTimeout(() => {
@@ -280,6 +262,7 @@ test('Abort before timeout (EE)', async (t) => {
     }, 100)
     ee.emit('abort')
     clock.tick(50)
+    fastTimersTick(50)
   })
   after(() => server.close())
 
@@ -292,6 +275,7 @@ test('Abort before timeout (EE)', async (t) => {
     client.request({ path: '/', method: 'GET', signal: ee }, (err, response) => {
       t.ok(err instanceof errors.RequestAbortedError)
       clock.tick(100)
+      fastTimersTick(100)
     })
   })
 
@@ -307,12 +291,6 @@ test('Abort before timeout (abort-controller)', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const abortController = new AbortController()
   const server = createServer((req, res) => {
     setTimeout(() => {
@@ -320,6 +298,7 @@ test('Abort before timeout (abort-controller)', async (t) => {
     }, 100)
     abortController.abort()
     clock.tick(50)
+    fastTimersTick(50)
   })
   after(() => server.close())
 
@@ -332,6 +311,7 @@ test('Abort before timeout (abort-controller)', async (t) => {
     client.request({ path: '/', method: 'GET', signal: abortController.signal }, (err, response) => {
       t.ok(err instanceof errors.RequestAbortedError)
       clock.tick(100)
+      fastTimersTick(100)
     })
   })
 
@@ -347,17 +327,12 @@ test('Timeout with pipelining', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 100)
     clock.tick(50)
+    fastTimersTick(50)
   })
   after(() => server.close())
 
@@ -393,17 +368,12 @@ test('Global option', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 100)
     clock.tick(100)
+    fastTimersTick(100)
   })
   after(() => server.close())
 
@@ -418,6 +388,7 @@ test('Global option', async (t) => {
     })
 
     clock.tick(50)
+    fastTimersTick(50)
   })
 
   await t.completed
@@ -432,17 +403,12 @@ test('Request options overrides global option', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 100)
     clock.tick(100)
+    fastTimersTick(100)
   })
   after(() => server.close())
 
@@ -457,6 +423,7 @@ test('Request options overrides global option', async (t) => {
     })
 
     clock.tick(50)
+    fastTimersTick(50)
   })
 
   await t.completed
@@ -496,12 +463,6 @@ test('client.close should wait for the timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
   })
   after(() => server.close())
@@ -523,6 +484,7 @@ test('client.close should wait for the timeout', async (t) => {
     client.on('connect', () => {
       process.nextTick(() => {
         clock.tick(100)
+        fastTimersTick(100)
       })
     })
   })
@@ -581,17 +543,12 @@ test('Disable request timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 32e3)
     clock.tick(33e3)
+    fastTimersTick(33e3)
   })
   after(() => server.close())
 
@@ -614,6 +571,7 @@ test('Disable request timeout', async (t) => {
     })
 
     clock.tick(31e3)
+    fastTimersTick(31e3)
   })
 
   await t.completed
@@ -628,17 +586,12 @@ test('Disable request timeout for a single request', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 32e3)
     clock.tick(33e3)
+    fastTimersTick(33e3)
   })
   after(() => server.close())
 
@@ -661,6 +614,7 @@ test('Disable request timeout for a single request', async (t) => {
     })
 
     clock.tick(31e3)
+    fastTimersTick(31e3)
   })
 
   await t.completed
@@ -675,17 +629,12 @@ test('stream timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 301e3)
     clock.tick(301e3)
+    fastTimersTick(301e3)
   })
   after(() => server.close())
 
@@ -716,17 +665,12 @@ test('stream custom timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       res.end('hello')
     }, 31e3)
     clock.tick(31e3)
+    fastTimersTick(31e3)
   })
   after(() => server.close())
 
@@ -759,17 +703,12 @@ test('pipeline timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       req.pipe(res)
     }, 301e3)
     clock.tick(301e3)
+    fastTimersTick(301e3)
   })
   after(() => server.close())
 
@@ -819,17 +758,12 @@ test('pipeline timeout', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
     setTimeout(() => {
       req.pipe(res)
     }, 31e3)
     clock.tick(31e3)
+    fastTimersTick(31e3)
   })
   after(() => server.close())
 
@@ -881,12 +815,6 @@ test('client.close should not deadlock', async (t) => {
   })
   after(() => clock.uninstall())
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   const server = createServer((req, res) => {
   })
   after(() => server.close())
@@ -911,6 +839,7 @@ test('client.close should not deadlock', async (t) => {
       })
 
       clock.tick(100)
+      fastTimersTick(100)
     })
   })
   await t.completed
diff --git test/socket-timeout.js test/socket-timeout.js
index 5be62fe4119..43ee1576615 100644
--- test/socket-timeout.js
+++ test/socket-timeout.js
@@ -3,7 +3,6 @@
 const { tspl } = require('@matteo.collina/tspl')
 const { test, after } = require('node:test')
 const { Client, errors } = require('..')
-const timers = require('../lib/util/timers')
 const { createServer } = require('node:http')
 const FakeTimers = require('@sinonjs/fake-timers')
 
@@ -68,12 +67,6 @@ test('Disable socket timeout', async (t) => {
   const clock = FakeTimers.install()
   after(clock.uninstall.bind(clock))
 
-  const orgTimers = { ...timers }
-  Object.assign(timers, { setTimeout, clearTimeout })
-  after(() => {
-    Object.assign(timers, orgTimers)
-  })
-
   server.once('request', (req, res) => {
     setTimeout(() => {
       res.end('hello')
diff --git a/test/timers.js b/test/timers.js
new file mode 100644
index 00000000000..9d8cd596e90
--- /dev/null
+++ test/timers.js
@@ -0,0 +1,258 @@
+'use strict'
+
+const { tspl } = require('@matteo.collina/tspl')
+const { describe, test } = require('node:test')
+const FakeTimers = require('@sinonjs/fake-timers')
+
+const clock = FakeTimers.install()
+
+const timers = require('../lib/util/timers')
+const { eventLoopBlocker } = require('./utils/event-loop-blocker')
+
+// timers.setTimeout implements a low resolution timer with a 500 ms granularity
+// It is expected that in the worst case, a timer will fire about 500 ms after the
+// intended amount of time, an extra 200 ms is added to account event loop overhead
+// Timers should never fire excessively early, 1ms early is tolerated
+const ACCEPTABLE_DELTA = 700
+
+function tick (duration) {
+  for (let i = 0; i < duration; ++i) {
+    clock.tick(1)
+  }
+}
+
+describe('timers', () => {
+  test('timers exports a clearTimeout', (t) => {
+    t = tspl(t, { plan: 1 })
+
+    t.ok(typeof timers.clearTimeout === 'function')
+  })
+
+  test('timers exports a setTimeout', (t) => {
+    t = tspl(t, { plan: 1 })
+
+    t.ok(typeof timers.setTimeout === 'function')
+  })
+
+  test('setTimeout instantiates a native NodeJS.Timeout when delay is lower or equal 1e3 ms', (t) => {
+    t = tspl(t, { plan: 2 })
+
+    t.strictEqual(timers.setTimeout(() => { }, 999)[timers.kFastTimer], undefined)
+    t.strictEqual(timers.setTimeout(() => { }, 1e3)[timers.kFastTimer], undefined)
+  })
+
+  test('setTimeout instantiates a FastTimer when delay is bigger than 1e3 ms', (t) => {
+    t = tspl(t, { plan: 1 })
+
+    const timeout = timers.setTimeout(() => { }, 1001)
+    t.strictEqual(timeout[timers.kFastTimer], true)
+  })
+
+  test('clearTimeout can clear a node native Timeout', (t) => {
+    t = tspl(t, { plan: 1 })
+
+    const nativeTimeoutId = setTimeout(() => { t.fail() }, 1)
+    t.ok(timers.clearTimeout(nativeTimeoutId) === undefined)
+    tick(10)
+  })
+
+  test('a FastTimer will get a _idleStart value after short time', async (t) => {
+    t = tspl(t, { plan: 3 })
+
+    const timer = timers.setTimeout(() => {
+      t.fail('timer should not have fired')
+    }, 1e4)
+
+    t.strictEqual(timer[timers.kFastTimer], true)
+    t.strictEqual(timer._idleStart, -1)
+
+    tick(1e3)
+    t.notStrictEqual(timer._idleStart, -1)
+
+    timers.clearTimeout(timer)
+  })
+
+  test('a cleared FastTimer will reset the _idleStart value to -1', async (t) => {
+    t = tspl(t, { plan: 4 })
+
+    const timer = timers.setTimeout(() => {
+      t.fail('timer should not have fired')
+    }, 1e4)
+
+    t.strictEqual(timer[timers.kFastTimer], true)
+    t.strictEqual(timer._idleStart, -1)
+    tick(750)
+    t.notStrictEqual(timer._idleStart, -1)
+    timers.clearTimeout(timer)
+    t.strictEqual(timer._idleStart, -1)
+  })
+
+  test('a FastTimer can be cleared', async (t) => {
+    t = tspl(t, { plan: 3 })
+
+    const timer = timers.setTimeout(() => {
+      t.fail('timer should not have fired')
+    }, 1001)
+
+    t.strictEqual(timer[timers.kFastTimer], true)
+    timers.clearTimeout(timer)
+
+    t.strictEqual(timer._idleStart, -1)
+    tick(750)
+    t.strictEqual(timer._idleStart, -1)
+  })
+
+  test('a cleared FastTimer can be refreshed', async (t) => {
+    t = tspl(t, { plan: 2 })
+
+    const timer = timers.setFastTimeout(() => {
+      t.ok('pass')
+    }, 1001)
+
+    t.strictEqual(timer[timers.kFastTimer], true)
+    timers.clearTimeout(timer)
+    timer.refresh()
+    tick(2000)
+    timers.clearTimeout(timer)
+  })
+
+  const getDelta = (start, target) => {
+    const end = performance.now()
+    const actual = end - start
+    return actual - target
+  }
+
+  test('refresh correctly with timeout < TICK_MS', async (t) => {
+    t = tspl(t, { plan: 3 })
+
+    const start = performance.now()
+
+    const timeout = timers.setTimeout(() => {
+      // 80 ms timer was refreshed after 120 ms; total target is 200 ms
+      const delta = getDelta(start, 200)
+
+      t.ok(delta >= -1, 'refreshed timer fired early')
+      t.ok(delta < ACCEPTABLE_DELTA, 'refreshed timer fired late')
+    }, 80)
+
+    setTimeout(() => timeout.refresh(), 40)
+    setTimeout(() => timeout.refresh(), 80)
+    setTimeout(() => timeout.refresh(), 120)
+
+    setTimeout(() => t.ok(true), 260)
+
+    tick(500)
+    await t.completed
+  })
+
+  test('refresh correctly with timeout > TICK_MS', async (t) => {
+    t = tspl(t, { plan: 3 })
+
+    const start = performance.now()
+
+    const timeout = timers.setTimeout(() => {
+      // 501ms timer was refreshed after 1250ms; total target is 1751
+      const delta = getDelta(start, 1751)
+
+      t.ok(delta >= -1, 'refreshed timer fired early')
+      t.ok(delta < ACCEPTABLE_DELTA, 'refreshed timer fired late')
+    }, 501)
+
+    setTimeout(() => timeout.refresh(), 250)
+    setTimeout(() => timeout.refresh(), 750)
+    setTimeout(() => timeout.refresh(), 1250)
+
+    setTimeout(() => t.ok(true), 1800)
+
+    tick(2000)
+    await t.completed
+  })
+
+  test('refresh correctly FastTimer with timeout > TICK_MS', async (t) => {
+    t = tspl(t, { plan: 3 })
+
+    // The long running FastTimer will ensure that the internal clock is
+    // incremented by the TICK_MS value in the onTick function
+    const longRunningFastTimer = timers.setTimeout(() => {}, 1e10)
+
+    const start = timers.now()
+
+    const timeout = timers.setFastTimeout(() => {
+      const delta = (timers.now() - start) - 2493
+
+      t.ok(delta >= -1, `refreshed timer fired early (${delta} ms)`)
+      t.ok(delta < ACCEPTABLE_DELTA, `refreshed timer fired late (${delta} ms)`)
+    }, 1001)
+
+    tick(250)
+    timeout.refresh()
+
+    tick(250)
+    timeout.refresh()
+
+    tick(250)
+    timeout.refresh()
+
+    tick(250)
+    timeout.refresh()
+
+    timers.clearTimeout(longRunningFastTimer)
+    setTimeout(() => t.ok(true), 500)
+
+    tick(5000)
+    await t.completed
+  })
+
+  test('a FastTimer will only increment by the defined TICK_MS value', async (t) => {
+    t = tspl(t, { plan: 6 })
+
+    const startInternalClock = timers.now()
+
+    // The long running FastTimer will ensure that the internal clock is
+    // incremented by the TICK_MS value in the onTick function
+    const longRunningFastTimer = timers.setTimeout(() => {}, 1e10)
+
+    eventLoopBlocker(1000)
+
+    // wait to ensure the timer has fired in the next loop
+    await new Promise((resolve) => resolve())
+
+    tick(250)
+    t.strictEqual(timers.now() - startInternalClock, 0)
+    tick(250)
+    t.strictEqual(timers.now() - startInternalClock, 499)
+    tick(250)
+    t.strictEqual(timers.now() - startInternalClock, 499)
+    tick(250)
+    t.strictEqual(timers.now() - startInternalClock, 998)
+    tick(250)
+    t.strictEqual(timers.now() - startInternalClock, 998)
+    tick(250)
+    t.strictEqual(timers.now() - startInternalClock, 1497)
+
+    timers.clearTimeout(longRunningFastTimer)
+  })
+
+  test('meet acceptable resolution time', async (t) => {
+    const testTimeouts = [0, 1, 499, 500, 501, 990, 999, 1000, 1001, 1100, 1400, 1499, 1500, 4000, 5000]
+
+    t = tspl(t, { plan: testTimeouts.length * 2 })
+
+    const start = performance.now()
+
+    for (const target of testTimeouts) {
+      timers.setTimeout(() => {
+        const delta = getDelta(start, target)
+
+        t.ok(delta >= -1, `${target}ms fired early`)
+        t.ok(delta < ACCEPTABLE_DELTA, `${target}ms fired late, got difference of ${delta}ms`)
+      }, target)
+    }
+
+    for (let i = 0; i < 6000; ++i) {
+      clock.tick(1)
+    }
+
+    await t.completed
+  })
+})
diff --git test/types/readable.test-d.ts test/types/readable.test-d.ts
index d004b706569..b5d32f6c221 100644
--- test/types/readable.test-d.ts
+++ test/types/readable.test-d.ts
@@ -20,6 +20,9 @@ expectAssignable<BodyReadable>(new BodyReadable())
   // blob
   expectAssignable<Promise<Blob>>(readable.blob())
 
+  // bytes
+  expectAssignable<Promise<Uint8Array>>(readable.bytes())
+
   // arrayBuffer
   expectAssignable<Promise<ArrayBuffer>>(readable.arrayBuffer())
 
diff --git a/test/types/retry-handler.test-d.ts b/test/types/retry-handler.test-d.ts
new file mode 100644
index 00000000000..8dac930fa98
--- /dev/null
+++ test/types/retry-handler.test-d.ts
@@ -0,0 +1,49 @@
+import { expectType, expectAssignable, expectNotAssignable } from 'tsd'
+import { Dispatcher, RetryHandler } from '../..'
+
+// Test the basic structure of RetryCallback
+expectType<RetryHandler.RetryCallback>((err, context, callback) => {
+  expectType<Error>(err)
+  expectType<{
+    state: RetryHandler.RetryState;
+    opts: Dispatcher.DispatchOptions & {
+      retryOptions?: RetryHandler.RetryOptions;
+    };
+  }>(context)
+  expectType<RetryHandler.OnRetryCallback>(callback)
+})
+
+// Test that RetryCallback returns void
+const testCallback = (() => {}) as RetryHandler.RetryCallback
+const testContext = {
+  state: {} as RetryHandler.RetryState,
+  opts: {} as Dispatcher.DispatchOptions & {
+    retryOptions?: RetryHandler.RetryOptions;
+  }
+}
+
+expectType<void>(testCallback(new Error(), testContext, () => {}))
+
+// Test that the function is assignable to RetryCallback
+expectAssignable<RetryHandler.RetryCallback>(testCallback)
+
+// Test that an incorrectly typed function is not assignable to RetryCallback
+expectNotAssignable<RetryHandler.RetryCallback>((() => {}) as (
+  err: string,
+  context: number,
+  callback: boolean
+) => void)
+
+// Test the nested types
+const contextTest: Parameters<RetryHandler.RetryCallback>[1] = {
+  state: {} as RetryHandler.RetryState,
+  opts: {
+    method: 'GET',
+    path: 'some-path',
+    retryOptions: {} as RetryHandler.RetryOptions
+  }
+}
+expectType<RetryHandler.RetryState>(contextTest.state)
+expectType<
+  Dispatcher.DispatchOptions & { retryOptions?: RetryHandler.RetryOptions }
+>(contextTest.opts)
diff --git test/util.js test/util.js
index f646ec4ff25..b3e1193e506 100644
--- test/util.js
+++ test/util.js
@@ -1,12 +1,10 @@
 'use strict'
 
-const { tspl } = require('@matteo.collina/tspl')
 const { strictEqual, throws, doesNotThrow } = require('node:assert')
 const { test, describe } = require('node:test')
 const { isBlobLike, parseURL, isHttpOrHttpsPrefixed, isValidPort } = require('../lib/core/util')
 const { Blob, File } = require('node:buffer')
 const { InvalidArgumentError } = require('../lib/core/errors')
-const timers = require('../lib/util/timers')
 
 describe('isBlobLike', () => {
   test('buffer', () => {
@@ -255,79 +253,3 @@ describe('parseURL', () => {
     })
   })
 })
-
-describe('timers', () => {
-  const getDelta = (start, target) => {
-    const end = process.hrtime.bigint()
-    const actual = (end - start) / 1_000_000n
-    return actual - BigInt(target)
-  }
-
-  // timers.setTimeout implements a low resolution timer with a 500 ms granularity
-  // It is expected that in the worst case, a timer will fire about 500 ms after the
-  // intended amount of time, an extra 200 ms is added to account event loop overhead
-  // Timers should never fire excessively early, 1ms early is tolerated
-  const ACCEPTABLE_DELTA = 700n
-
-  test('meet acceptable resolution time', async (t) => {
-    const testTimeouts = [0, 1, 499, 500, 501, 990, 999, 1000, 1001, 1100, 1400, 1499, 1500, 4000, 5000]
-
-    t = tspl(t, { plan: 1 + testTimeouts.length * 2 })
-
-    const start = process.hrtime.bigint()
-
-    for (const target of testTimeouts) {
-      timers.setTimeout(() => {
-        const delta = getDelta(start, target)
-
-        t.ok(delta >= -1n, `${target}ms fired early`)
-        t.ok(delta < ACCEPTABLE_DELTA, `${target}ms fired late`)
-      }, target)
-    }
-
-    setTimeout(() => t.ok(true), 6000)
-    await t.completed
-  })
-
-  test('refresh correctly with timeout < TICK_MS', async (t) => {
-    t = tspl(t, { plan: 3 })
-
-    const start = process.hrtime.bigint()
-
-    const timeout = timers.setTimeout(() => {
-      // 400 ms timer was refreshed after 600ms; total target is 1000
-      const delta = getDelta(start, 1000)
-
-      t.ok(delta >= -1n, 'refreshed timer fired early')
-      t.ok(delta < ACCEPTABLE_DELTA, 'refreshed timer fired late')
-    }, 400)
-
-    setTimeout(() => timeout.refresh(), 200)
-    setTimeout(() => timeout.refresh(), 400)
-    setTimeout(() => timeout.refresh(), 600)
-
-    setTimeout(() => t.ok(true), 1500)
-    await t.completed
-  })
-
-  test('refresh correctly with timeout > TICK_MS', async (t) => {
-    t = tspl(t, { plan: 3 })
-
-    const start = process.hrtime.bigint()
-
-    const timeout = timers.setTimeout(() => {
-      // 501ms timer was refreshed after 1250ms; total target is 1751
-      const delta = getDelta(start, 1751)
-
-      t.ok(delta >= -1n, 'refreshed timer fired early')
-      t.ok(delta < ACCEPTABLE_DELTA, 'refreshed timer fired late')
-    }, 501)
-
-    setTimeout(() => timeout.refresh(), 250)
-    setTimeout(() => timeout.refresh(), 750)
-    setTimeout(() => timeout.refresh(), 1250)
-
-    setTimeout(() => t.ok(true), 3000)
-    await t.completed
-  })
-})
diff --git a/test/utils/event-loop-blocker.js b/test/utils/event-loop-blocker.js
new file mode 100644
index 00000000000..9c2ec5075f0
--- /dev/null
+++ test/utils/event-loop-blocker.js
@@ -0,0 +1,10 @@
+'use strict'
+
+function eventLoopBlocker (ms) {
+  const nil = new Int32Array(new SharedArrayBuffer(4))
+  Atomics.wait(nil, 0, 0, ms)
+}
+
+module.exports = {
+  eventLoopBlocker
+}
diff --git a/test/utils/hello-world-server.js b/test/utils/hello-world-server.js
new file mode 100644
index 00000000000..520ed3a734d
--- /dev/null
+++ test/utils/hello-world-server.js
@@ -0,0 +1,30 @@
+'use strict'
+
+const { createServer } = require('node:http')
+const hostname = '127.0.0.1'
+
+const server = createServer(async (req, res) => {
+  res.statusCode = 200
+  res.setHeader('Content-Type', 'text/plain')
+
+  await sendInDelayedChunks(res, 'Hello World', 125)
+  res.end()
+})
+
+async function sendInDelayedChunks (res, payload, delay) {
+  const chunks = payload.split('')
+
+  for (const chunk of chunks) {
+    await new Promise(resolve => setTimeout(resolve, delay))
+
+    res.write(chunk)
+  }
+}
+
+server.listen(0, hostname, () => {
+  if (process.send) {
+    process.send(`http://${hostname}:${server.address().port}/`)
+  } else {
+    console.log(`http://${hostname}:${server.address().port}/`)
+  }
+})
diff --git types/dispatcher.d.ts types/dispatcher.d.ts
index 0aa2aba00e3..1b4c9c74a5d 100644
--- types/dispatcher.d.ts
+++ types/dispatcher.d.ts
@@ -244,6 +244,7 @@ declare namespace Dispatcher {
     readonly bodyUsed: boolean;
     arrayBuffer(): Promise<ArrayBuffer>;
     blob(): Promise<Blob>;
+    bytes(): Promise<Uint8Array>;
     formData(): Promise<never>;
     json(): Promise<unknown>;
     text(): Promise<string>;
diff --git types/eventsource.d.ts types/eventsource.d.ts
index eecda8c0151..deccd730041 100644
--- types/eventsource.d.ts
+++ types/eventsource.d.ts
@@ -2,8 +2,6 @@ import { MessageEvent, ErrorEvent } from './websocket'
 import Dispatcher from './dispatcher'
 
 import {
-  EventTarget,
-  Event,
   EventListenerOptions,
   AddEventListenerOptions,
   EventListenerOrEventListenerObject
diff --git types/filereader.d.ts types/filereader.d.ts
index f05d231b2ff..d1c0f9ef723 100644
--- types/filereader.d.ts
+++ types/filereader.d.ts
@@ -1,7 +1,7 @@
 /// <reference types="node" />
 
 import { Blob } from 'buffer'
-import { DOMException, Event, EventInit, EventTarget } from './patch'
+import { DOMException, EventInit } from './patch'
 
 export declare class FileReader {
   __proto__: EventTarget & FileReader
diff --git types/interceptors.d.ts types/interceptors.d.ts
index fab6da08c0d..65e9397554e 100644
--- types/interceptors.d.ts
+++ types/interceptors.d.ts
@@ -1,3 +1,5 @@
+import { LookupOptions } from 'node:dns'
+
 import Dispatcher from "./dispatcher";
 import RetryHandler from "./retry-handler";
 
@@ -7,9 +9,23 @@ declare namespace Interceptors {
   export type DumpInterceptorOpts = { maxSize?: number }
   export type RetryInterceptorOpts = RetryHandler.RetryOptions
   export type RedirectInterceptorOpts = { maxRedirections?: number }
-  
+  export type ResponseErrorInterceptorOpts = { throwOnError: boolean }
+
+  // DNS interceptor
+  export type DNSInterceptorRecord = { address: string, ttl: number, family: 4 | 6 }
+  export type DNSInterceptorOriginRecords = { 4: { ips: DNSInterceptorRecord[] } | null, 6: { ips: DNSInterceptorRecord[] } | null }
+  export type DNSInterceptorOpts = {
+    maxTTL?: number
+    maxItems?: number
+    lookup?: (hostname: string, options: LookupOptions, callback: (err: NodeJS.ErrnoException | null, addresses: DNSInterceptorRecord[]) => void) => void
+    pick?: (origin: URL, records: DNSInterceptorOriginRecords, affinity: 4 | 6) => DNSInterceptorRecord
+    dualStack?: boolean
+    affinity?: 4 | 6
+  }
+
   export function createRedirectInterceptor(opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function dump(opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function retry(opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
   export function redirect(opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
+  export function responseError(opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
 }
diff --git types/patch.d.ts types/patch.d.ts
index 3871acfebc6..4ac38450e67 100644
--- types/patch.d.ts
+++ types/patch.d.ts
@@ -6,44 +6,6 @@ export type DOMException = typeof globalThis extends { DOMException: infer T }
  ? T
  : any
 
-export type EventTarget = typeof globalThis extends { EventTarget: infer T }
-  ? T
-  : {
-    addEventListener(
-      type: string,
-      listener: any,
-      options?: any,
-    ): void
-    dispatchEvent(event: Event): boolean
-    removeEventListener(
-      type: string,
-      listener: any,
-      options?: any | boolean,
-    ): void
-  }
-
-export type Event = typeof globalThis extends { Event: infer T }
-  ? T
-  : {
-    readonly bubbles: boolean
-    cancelBubble: () => void
-    readonly cancelable: boolean
-    readonly composed: boolean
-    composedPath(): [EventTarget?]
-    readonly currentTarget: EventTarget | null
-    readonly defaultPrevented: boolean
-    readonly eventPhase: 0 | 2
-    readonly isTrusted: boolean
-    preventDefault(): void
-    returnValue: boolean
-    readonly srcElement: EventTarget | null
-    stopImmediatePropagation(): void
-    stopPropagation(): void
-    readonly target: EventTarget | null
-    readonly timeStamp: number
-    readonly type: string
-  }
-
 export interface EventInit {
   bubbles?: boolean
   cancelable?: boolean
diff --git types/readable.d.ts types/readable.d.ts
index a5fce8a20d3..c4f052af05e 100644
--- types/readable.d.ts
+++ types/readable.d.ts
@@ -25,6 +25,11 @@ declare class BodyReadable extends Readable {
    */
   blob(): Promise<Blob>
 
+  /** Consumes and returns the body as an Uint8Array
+   *  https://fetch.spec.whatwg.org/#dom-body-bytes
+   */
+  bytes(): Promise<Uint8Array>
+
   /** Consumes and returns the body as an ArrayBuffer
    *  https://fetch.spec.whatwg.org/#dom-body-arraybuffer
    */
diff --git types/retry-handler.d.ts types/retry-handler.d.ts
index e44b207c221..6cb34c12d6d 100644
--- types/retry-handler.d.ts
+++ types/retry-handler.d.ts
@@ -32,7 +32,7 @@ declare namespace RetryHandler {
       };
     },
     callback: OnRetryCallback
-  ) => number | null;
+  ) => void
 
   export interface RetryOptions {
     /**
diff --git types/webidl.d.ts types/webidl.d.ts
index 8a23a85bf01..fd83b68af90 100644
--- types/webidl.d.ts
+++ types/webidl.d.ts
@@ -67,6 +67,12 @@ interface WebidlUtil {
    * Stringifies {@param V}
    */
   Stringify (V: any): string
+
+  /**
+   * Mark a value as uncloneable for Node.js.
+   * This is only effective in some newer Node.js versions.
+   */
+  markAsUncloneable (V: any): void
 }
 
 interface WebidlConverters {
diff --git types/websocket.d.ts types/websocket.d.ts
index d1be45235d4..dfdd8156cce 100644
--- types/websocket.d.ts
+++ types/webs,ocket.d.ts
@@ -3,8 +3,6 @@
 import type { Blob } from 'buffer'
 import type { MessagePort } from 'worker_threads'
 import {
-  EventTarget,
-  Event,
   EventInit,
   EventListenerOptions,
   AddEventListenerOptions,

Description

This PR introduces several changes and improvements, touching various areas of the undici codebase. The primary changes include:

  1. Adding a .bytes() method to BodyReadable to return the body as a Uint8Array.
  2. Introducing new FastTimer implementation aimed at optimizing timer performance.
  3. Adding support for DNS interception and error handling.
  4. Implementing a new ResponseError interceptor.
  5. Updating .gitignore and .npmignore files to handle test artifacts.
  6. Introducing new H2-related tests and enhancements to existing test suites.

Possible Issues

  • The handling of response errors now involves a new interceptor which might affect existing error handling behaviors.
  • The introduction of FastTimer might significantly change the behavior for components relying on timers, especially in timing-sensitive tests.

Security Hotspots

  • The new DNS Interceptor functionality involves handling DNS responses and could involve security implications if not handled correctly, specifically regarding the origins and validation of those DNS records.
  • Changes related to the new ResponseError could potentially expose different error management surfaces, demanding careful review to avoid leaking sensitive error details.
Changes

Changes

.gitignore

+.tap
+
+# File generated by /test/request-timeout.js
+test/request-timeout.10mb.bin

.npmignore

+
+# File generated by /test/request-timeout.js
+test/request-timeout.10mb.bin

README.md

+- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)

benchmarks/timers/compare-timer-getters.mjs

import { bench, group, run } from 'mitata'

group('timers', () => {
  bench('Date.now()', () => {
    Date.now()
  })
  bench('performance.now()', () => {
    performance.now()
  })
  bench('Math.trunc(performance.now())', () => {
    Math.trunc(performance.now())
  })
  bench('process.uptime()', () => {
    process.uptime()
  })
})

await run()

index.js

+  dns: require('./lib/interceptor/dns')

lib/api/api-request.js

-            util.destroy(this.res, this.reason)
+            util.destroy(this.res.on('error', util.nop), this.reason)

lib/api/api-upgrade.js

+    assert(statusCode === 101)

-    assert.strictEqual(statusCode, 101)

lib/api/readable.js

+  async bytes () {
+    return consume(this, 'bytes')
+  }

+    } else if (type === 'bytes') {
+      resolve(chunksConcat(body, length))
+    }
+}

lib/core/connect.js

+const timers = require('../util/timers')

+    const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
+  } else if (type === TIMEOUT_HEADERS) {
+        timers.clearFastTimeout(fastTimer)
+        clearImmediate(s1)
+        clearImmediate(s2)
+      }
-  }
})

+function setupTimeout (onConnectTimeout, timeout) {
+}

+function onConnectTimeout (socket, opts) {
+  if (socket == null) {
+    return
+  }
}
-    message += ` (attempted addresses: ${ attempts})})`
+    message += ` (attempted address: ${ opts.hostname}:${opts.port},`
}

lib/core/errors.js

+class ResponseError extends UndiciError {
+  constructor (message, code, { headers, data })

+    this.statusCode = code
+    this.data = data
+    this.headers = headers
+  }
+}

+  ResponseError,

lib/core/util.js

-  assert.strictEqual(typeof host, 'string')
+  assert(typeof host === 'string')

function getServerName (host) {
  if (net.isIP(servername)) {
    return new Uint8Array(0)
  }
  if (chunks.length === 1) {
    // fast-path
+  return new Uint8Array(chunk.buffer)
}

lib/dispatcher/client-h1.js

+let currentBufferSize = 0
+let currentBufferPtr = null

-    const TIMEOUT_HEADERS = 1
-    const TIMEOUT_BODY = 2
-    const TIMEOUT_IDLE = 3

lib/dispatcher/client-h2.js

  kResume

-async function lazyllhttp() {{
  const exports

 +    function onHttp2SessionError (err) {

lib/dispatcher/client.js

+const noop = () => {}

   try {
     const { Client } = require ( 'client' )

@@ -438,7 +444,7 @@ class DispatcherBase extends EventEmitter {

     const client = new Client(`https://localhost:${port}`, {
     client.p...  }
   }
  return 

lib/dispatcher/pool-base.js

+      await Promise.all(clients.map(c => c.close ())) 
+      await Promise.all(clients.map(c => c.destroy (err)))

lib/dispatcher/proxy-agent.js

+const noop = () => {}
 
             socket.on('error', noop).destroy()
}

eventsource.js

   super()
+  webidl.util.markAsUncloneable(this)

   const { EventInit } = require('./webidl')

module.exports = {
      ...localHeaders
+     webidl.util.markAsUncloneable(this)
browser: {
 module.exports = interceptorsOptions => {}
}

package.json

"test:h2": "npm run test:h2:core && npm run test:h2:fetch",
+ "test:h2:core": "borp -p \"test/http2*.js\"" 
+ "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"",

These changes introduce new features while attempting to maintain backward compatibility. Special attention should be given to the new timer implementation and the DNS interceptor to ensure they are robust and secure.```mermaid
sequenceDiagram
participant User
participant Browser
participant Local Library
participant Server
User ->> Browser: Open App
Browser ->> Local Library: Fetch Data
Local Library ->> Server: API Request
Server -->> Local Library: Response
Local Library -->> Browser: Render Data
Browser -->> User: Display App

</details>

<!-- Generated by gpt-4o-2024-05-13 -->

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
dependencies Pull requests that update a dependency file puLL-Merge
Projects
None yet
Development

Successfully merging this pull request may close these issues.

0 participants