-
-
Notifications
You must be signed in to change notification settings - Fork 732
Description
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:
isTraversableNavigable()returnstrue(the TODO stub) → enters the block- POST has a body →
request.body != nullistrue - When body was constructed from a
ReadableStream(e.g.new Request(url, { body: stream, method: 'POST', duplex: 'half' })),body.sourceisnull - Returns
makeNetworkError('expected non-null body source')→TypeError: fetch failed - The
return responsefix 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
- fix websocket basic auth #4747 — PR that introduced
isTraversableNavigablereturningtrue - fix(fetch): prevent infinite retry loop on 401 responses #4756 — @killagu's original fix proposal (
isTraversableNavigable → false), closed in favor of fix fetch 401 loop #4761 - fix fetch 401 loop #4761 — @KhafraDev's fix for the infinite loop (added
return makeNetworkError()) - return response when receiving 401 instead of network error #4769 — @KhafraDev's follow-up fix (changed to
return response), fixed GET 401 but missed POST - Fetch 401 behavior nodejs #4767 — @xconverge's report that 401 responses always throw
Environment
- undici: 7.24.4 (also reproduced on 7.24.5)
- Node.js: v24.13.1
- OS: macOS (Darwin 25.3.0)