Description
When using hookOptions: { cloneResponse: true }, the onError hook never fires for error responses (4xx, 5xx). Instead, a TypeError: Response.clone: Body has already been consumed is thrown.
Root Cause
In the error handling path, response.text() is called before attempting to clone the response for the onError hook:
// fetch.ts error path
const responseText = await response.text(); // <-- Consumes body
// ...
for (const onError of hooks.onError) {
await onError({
...errorContext,
response: cloneResponse ? response.clone() : response // <-- Fails: body already consumed
});
}
Minimal Reproduction
Save as repro.js and run with node repro.js:
const http = require('http');
const { createFetch } = require('@better-fetch/fetch');
const server = http.createServer((req, res) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal Server Error' }));
});
server.listen(3456, async () => {
const client = createFetch({
baseURL: 'http://localhost:3456',
throw: true,
});
console.log('=== Test 1: Without cloneResponse (works) ===\n');
await client('/', {
onError: (ctx) => console.log('onError fired! status:', ctx.response?.status),
}).catch(e => console.log('Caught:', e.constructor.name, '-', e.message, '- status:', e.status));
console.log('\n=== Test 2: With cloneResponse: true (broken) ===\n');
await client('/', {
hookOptions: { cloneResponse: true },
onError: (ctx) => console.log('onError fired! status:', ctx.response?.status),
}).catch(e => console.log('Caught:', e.constructor.name, '-', e.message, '- status:', e.status));
server.close();
});
Output:
=== Test 1: Without cloneResponse (works) ===
onError fired! status: 500
Caught: BetterFetchError - Internal Server Error - status: 500
=== Test 2: With cloneResponse: true (broken) ===
Caught: TypeError - Response.clone: Body has already been consumed. - status: undefined
Expected Behavior
onError hook should fire with a cloned response when cloneResponse: true is set.
Environment
- better-fetch version: 1.1.12
- Node.js version: v22.x
- Also reproducible in browser
Description
When using
hookOptions: { cloneResponse: true }, theonErrorhook never fires for error responses (4xx, 5xx). Instead, aTypeError: Response.clone: Body has already been consumedis thrown.Root Cause
In the error handling path,
response.text()is called before attempting to clone the response for theonErrorhook:Minimal Reproduction
Save as
repro.jsand run withnode repro.js:Output:
Expected Behavior
onErrorhook should fire with a cloned response whencloneResponse: trueis set.Environment