From f678a038c2ee8f57fed68ea70163997d8213db48 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 3 Feb 2026 07:54:39 +0000 Subject: [PATCH] fix: return AbortError instead of TypeError when consuming aborted cloned response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Response was cloned and the AbortController was aborted before consuming the body, consuming the body threw a TypeError (Body is unusable) instead of the expected AbortError. Changes: - Check for aborted state before checking if body is unusable in consumeBody() - Set response.aborted flag in abortFetch() to properly track abort state Fixes: https://github.com/nodejs/undici/issues/4799 Refs: https://github.com/nodejs/undici/pull/4800 Co-authored-by: Antoine Fourès --- lib/web/fetch/body.js | 10 ++-- lib/web/fetch/index.js | 5 +- test/fetch/issue-4799.js | 115 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 test/fetch/issue-4799.js diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js index 619bc783a4c..8557c94269c 100644 --- a/lib/web/fetch/body.js +++ b/lib/web/fetch/body.js @@ -440,14 +440,16 @@ function consumeBody (object, convertBytesToJSValue, instance, getInternalState) // 1. If object is unusable, then return a promise rejected // with a TypeError. - if (bodyUnusable(state)) { - return Promise.reject(new TypeError('Body is unusable: Body has already been read')) - } - + // Note: check for aborted first, as the stream may be disturbed + // due to aborting, in which case we want to throw AbortError. if (state.aborted) { return Promise.reject(new DOMException('The operation was aborted.', 'AbortError')) } + if (bodyUnusable(state)) { + return Promise.reject(new TypeError('Body is unusable: Body has already been read')) + } + // 2. Let promise be a new promise. const promise = createDeferredPromise() diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index bb33e8d77e8..711d9ec9691 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -354,7 +354,10 @@ function abortFetch (p, request, responseObject, error) { // 4. Let response be responseObject’s response. const response = getResponseState(responseObject) - // 5. If response’s body is not null and is readable, then error response’s + // 5. Set response’s aborted flag. + response.aborted = true + + // 6. If response’s body is not null and is readable, then error response’s // body with error. if (response.body?.stream != null && isReadable(response.body.stream)) { response.body.stream.cancel(error).catch((err) => { diff --git a/test/fetch/issue-4799.js b/test/fetch/issue-4799.js new file mode 100644 index 00000000000..c2cbba80c51 --- /dev/null +++ b/test/fetch/issue-4799.js @@ -0,0 +1,115 @@ +'use strict' + +const { test } = require('node:test') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { closeServerAsPromise } = require('../utils/node-http') + +test('response clone + abort should return AbortError, not TypeError', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ message: 'hello' })) + }) + + t.after(closeServerAsPromise(server)) + server.listen(0) + await once(server, 'listening') + + const controller = new AbortController() + const response = await fetch(`http://localhost:${server.address().port}`, { + signal: controller.signal + }) + + // Clone the response before aborting + const clonedResponse = response.clone() + + // Abort after cloning + controller.abort() + + // Both original and cloned response should reject with AbortError + await t.test('original response should reject with AbortError', async () => { + await t.assert.rejects( + response.text(), + { + name: 'AbortError', + message: 'The operation was aborted.', + code: DOMException.ABORT_ERR + } + ) + }) + + await t.test('cloned response should reject with AbortError', async () => { + await t.assert.rejects( + clonedResponse.text(), + { + name: 'AbortError', + message: 'This operation was aborted', + code: DOMException.ABORT_ERR + } + ) + }) +}) + +test('response without clone + abort should still return AbortError', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ message: 'hello' })) + }) + + t.after(closeServerAsPromise(server)) + server.listen(0) + await once(server, 'listening') + + const controller = new AbortController() + const response = await fetch(`http://localhost:${server.address().port}`, { + signal: controller.signal + }) + + // Abort without cloning + controller.abort() + + await t.assert.rejects( + response.text(), + { + name: 'AbortError', + message: 'The operation was aborted.', + code: DOMException.ABORT_ERR + } + ) +}) + +test('response bodyUsed after clone and abort', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ message: 'hello' })) + }) + + t.after(closeServerAsPromise(server)) + server.listen(0) + await once(server, 'listening') + + const controller = new AbortController() + const response = await fetch(`http://localhost:${server.address().port}`, { + signal: controller.signal + }) + + t.assert.strictEqual(response.bodyUsed, false) + + const clonedResponse = response.clone() + + t.assert.strictEqual(response.bodyUsed, false) + t.assert.strictEqual(clonedResponse.bodyUsed, false) + + controller.abort() + + // After abort, the original response's stream is cancelled which makes it disturbed + t.assert.strictEqual(response.bodyUsed, true) + + // The cloned response's stream is a separate tee'd branch that is not directly disturbed + // when the original is cancelled - it's simply closed/done + t.assert.strictEqual(clonedResponse.bodyUsed, false) +})