Skip to content

cloneResponse: true breaks onError hook - "Body has already been consumed" #74

Description

@leo91000

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions