Skip to content

fetch() throws network error on POST 401 responses when body source is null #4940

@sano-suguru

Description

@sano-suguru

Bug Description

fetch() throws TypeError: fetch failed for POST requests that receive a 401 response when the request body is a ReadableStream (i.e. body.source is null). GET requests with 401 responses work correctly since #4769.

This is a remaining edge case from the isTraversableNavigable / 401 retry logic introduced in #4747 and partially fixed by #4761 and #4769.

Root Cause

In httpNetworkOrCacheFetch, the 401 handler block has an early return for POST requests that fires before the return response fix from #4769:

// Step 13 (L1647)
if (response.status === 401 && ... && isTraversableNavigable(...)) {
  // Step 13.2: body check
  if (request.body != null) {                     // POST → true
    if (request.body.source == null) {            // ReadableStream → source is null
      return makeNetworkError('expected non-null body source')  // ← throws here
    }
  }

  // ...

  if (request.useURLCredentials === undefined || isAuthenticationFetch) {
    // ...
    return response  // ← #4769 fix (never reached for POST with ReadableStream body)
  }
}

The flow:

  1. isTraversableNavigable() returns true (the TODO stub) → enters the block
  2. POST has a body → request.body != null is true
  3. When body was constructed from a ReadableStream (e.g. new Request(url, { body: stream, method: 'POST', duplex: 'half' })), body.source is null
  4. Returns makeNetworkError('expected non-null body source')TypeError: fetch failed
  5. The return response fix from return response when receiving 401 instead of network error #4769 at the bottom of the block is never reached

Why GET 401 works

GET requests have no body, so request.body != null is false, skipping the body check entirely and reaching the return response fix from #4769.

Reproducible Example

import { createServer } from 'node:http';
import { once } from 'node:events';

const server = createServer((req, res) => {
  res.writeHead(401, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'unauthorized' }));
});

server.listen(0);
await once(server, 'listening');

const url = `http://localhost:${server.address().port}`;

// ✅ GET 401 — works correctly (fixed by #4769)
const getRes = await fetch(url);
console.log('GET status:', getRes.status); // 401

// ❌ POST 401 — throws TypeError: fetch failed
try {
  const postRes = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ data: 'test' }),
  });
  console.log('POST status:', postRes.status);
} catch (e) {
  console.error('POST threw:', e.message); // "fetch failed"
}

server.close();

Suggested Fix

Two possible approaches:

Option A: Return response early for null body source (minimal, consistent with #4769)

  if (request.body != null) {
    if (request.body.source == null) {
-     return makeNetworkError('expected non-null body source')
+     return response
    }

Option B: Make isTraversableNavigable return false (addresses the root cause)

  function isTraversableNavigable (navigable) {
-   // TODO
-   return true
+   return false
  }

Option B prevents entering the entire 401 retry block, which makes sense since Node.js has no traversable navigable that can prompt users for credentials. This was the approach originally proposed in #4756 by @killagu.

Impact

This affects any Node.js application using fetch() for HTTP APIs that return 401 on POST requests — a very common pattern for authentication endpoints. The workaround is to patch isTraversableNavigable to return false, but a proper fix in undici would be preferable.

Real-world reproduction: Cloudflare Workers local dev via @cloudflare/vite-plugin + miniflare (which uses undici internally). Any POST endpoint returning 401 crashes the dev server.

Related

Environment

  • undici: 7.24.4 (also reproduced on 7.24.5)
  • Node.js: v24.13.1
  • OS: macOS (Darwin 25.3.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions