Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions lib/web/fetch/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion lib/web/fetch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
115 changes: 115 additions & 0 deletions test/fetch/issue-4799.js
Original file line number Diff line number Diff line change
@@ -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)
})
Loading