Skip to content

[miniflare/wrangler] fix: avoid "expected non-null body source" on 401 responses with a request body#13119

Closed
petebacondarwin wants to merge 2 commits intomainfrom
fix/undici-401-body-source
Closed

[miniflare/wrangler] fix: avoid "expected non-null body source" on 401 responses with a request body#13119
petebacondarwin wants to merge 2 commits intomainfrom
fix/undici-401-body-source

Conversation

@petebacondarwin
Copy link
Copy Markdown
Contributor

@petebacondarwin petebacondarwin commented Mar 30, 2026

Fixes #12967 (and the follow-up for miniflare reported in #13058).

undici.fetch() implements the Fetch spec's 401 credential-retry path, which throws TypeError: fetch failed with cause "expected non-null body source" whenever a request has a ReadableStream body and the server responds with HTTP 401. PR #13058 fixed this for wrangler by patching the bundled copy of undici, but miniflare loads undici as a runtime dependency from the user's node_modules, so end-users installing miniflare standalone received an unpatched version.

The upstream fix (nodejs/undici#4910, PR #4936) is currently blocked — maintainers consider the behaviour spec-mandated.

What this PR does

Replaces undici.fetch() with undici.request() at the affected call sites in miniflare/src/http/fetch.ts, wrangler/src/api/dev.ts, and wrangler/src/cfetch/internal.ts. undici.request() uses the Dispatcher API directly and has no credential-retry path, so the crash cannot occur regardless of the undici version installed.

The fetchViaRequest() helper in fetch.ts explicitly replicates the undici.fetch() behaviours that callers depend on:

  • Accept: */* and Accept-Encoding: gzip, deflate default headers (same as undici.fetch)
  • Transparent decompression of gzip, deflate, and brotli responses
  • Redirect following, honouring the request's redirect mode (follow/manual/error)
  • Correct set-cookie handling: builds a Headers object with append() so multiple cookies are not merged

The workspace-level pnpm patch for undici@7.24.4 introduced in #13058 is removed — it is no longer needed.


  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: bug fix with no API change

Open with Devin

…1 with request body

Replace undici.fetch() with undici.request() at the call sites that
triggered the Fetch spec 401 credential-retry crash.

undici.fetch() crashes with 'expected non-null body source' when the
request has a ReadableStream body and the server responds 401, because
it implements the Fetch spec credential-retry path which checks
request.body.source (null for streams). Tracked upstream at
nodejs/undici#4910 — the upstream fix is
currently blocked by spec compliance concerns.

The fix uses undici.request() which goes through the Dispatcher API
directly and has no credential-retry path. The wrapper in
miniflare/src/http/fetch.ts explicitly replicates the behaviours of
undici.fetch() that callers depend on:
- Accept: */* and Accept-Encoding: gzip, deflate default headers
- Transparent decompression (gzip, deflate, brotli)
- Redirect following honouring the request's redirect mode
- Correct set-cookie handling via Headers.append()

The workspace-level pnpm patch for undici@7.24.4 that was previously
used as a partial workaround is removed.
@petebacondarwin petebacondarwin requested a review from a team as a code owner March 30, 2026 07:41
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: 0fe7f50

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod
Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • package.json: [@cloudflare/wrangler]
  • packages/miniflare/src/http/fetch.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/http/fetch.spec.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/api/dev.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/cfetch/internal.ts: [@cloudflare/wrangler]
  • patches/undici@7.24.4.patch: [@cloudflare/wrangler]
  • pnpm-lock.yaml: [@cloudflare/wrangler]

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 30, 2026

✅ All changesets look good

@petebacondarwin petebacondarwin marked this pull request as draft March 30, 2026 07:45
@petebacondarwin petebacondarwin removed the request for review from dario-piotrowicz March 30, 2026 07:45
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm Bonk, and I've done a quick review of your PR.

This PR replaces undici.fetch() with undici.request() at three call sites to avoid the 401 credential-retry crash, removes the workspace-level undici patch, and adds a regression test.

Issues (ranked by severity)

  1. Bug: Redirect following with a body will throw on body reuse — In fetchViaRequest, the request body stream is consumed by Readable.fromWeb(request.body) at line 150 and sent to undici.request(). On a non-303 redirect, line 234 reads request.body again and passes it to new Request(...). Since the stream has already been locked and consumed, this will throw TypeError: Response body object should not be disturbed or locked. This affects 301/302/307/308 redirects of requests with bodies. Since undici.request() does not follow redirects itself, this code path is reachable.

  2. Stale/duplicate comment block — Lines 134-140 contain two overlapping comment blocks ("Match undici.fetch() behaviour..." and "Match undici.fetch() defaults...") with broken indentation. Looks like a leftover from editing.

  3. Observation (not blocking): performApiFetch and fetchR2Objects still use undici.fetch() — The AI fetcher (src/ai/fetcher.ts) calls performApiFetch with request.body (a ReadableStream), and putRemoteObject calls fetchR2Objects with streaming file bodies. Both could still hit the same 401 crash. This is pre-existing and outside the stated scope, but worth noting for follow-up.

Comment on lines +232 to +244
await rawBody.dump();
// For 303 See Other: switch to GET and drop the body
const redirectMethod = statusCode === 303 ? "GET" : request.method;
const redirectBody = statusCode === 303 ? null : request.body;
return fetchViaRequest(
new Request(location, {
method: redirectMethod,
headers: request.headers,
body: redirectBody,
redirect: request.redirect,
} as RequestInit),
dispatcher,
redirectsRemaining - 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: request.body was already consumed by Readable.fromWeb() on line 150 and drained by undici.request(). Attempting to pass the locked/disturbed stream to new Request(...) will throw TypeError: Response body object should not be disturbed or locked.

You need to tee() or buffer the body before the initial undici.request() call so it can be replayed on redirect. One option is to clone the request up-front when redirect !== "manual" and redirect !== "error":

Suggested change
await rawBody.dump();
// For 303 See Other: switch to GET and drop the body
const redirectMethod = statusCode === 303 ? "GET" : request.method;
const redirectBody = statusCode === 303 ? null : request.body;
return fetchViaRequest(
new Request(location, {
method: redirectMethod,
headers: request.headers,
body: redirectBody,
redirect: request.redirect,
} as RequestInit),
dispatcher,
redirectsRemaining - 1
// For 303 See Other: switch to GET and drop the body
const redirectMethod = statusCode === 303 ? "GET" : request.method;
// The original request body was already consumed by undici.request().
// For non-303 redirects that need to replay the body, we cannot reuse
// request.body (it is locked/disturbed). Pass null for now — this
// matches browser behaviour for 301/302 (which switch to GET) and
// is the safest default. A full fix for 307/308 body replay would
// require buffering/teeing the body before the initial request.
const redirectBody = null;
return fetchViaRequest(
new Request(location, {
method: redirectMethod,
headers: request.headers,
body: redirectBody,
redirect: request.redirect,
} as RequestInit),
dispatcher,
redirectsRemaining - 1
);

Note: this suggestion drops the body on all redirects. For full spec compliance on 307/308, the body would need to be tee'd or buffered before the initial undici.request() call — but that's a larger change and these redirects with bodies are rare in practice. At minimum the current code should not throw.

Comment on lines +134 to +140
body: rawBody,
} = await undici.request(request.url, {
method: request.method,
// Match undici.fetch() behaviour: add Accept-Encoding if the caller
// has not already specified one, so that the workerd entry worker
// applies the same content-encoding logic.
// Match undici.fetch() defaults: it sends `Accept: */*` and
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: These two comment blocks appear to be stale leftovers — they overlap in content (both explain the Accept-Encoding / Accept defaults) and have inconsistent indentation (extra leading tab on lines 134 and 137). Consider consolidating into a single comment:

Suggested change
body: rawBody,
} = await undici.request(request.url, {
method: request.method,
// Match undici.fetch() behaviour: add Accept-Encoding if the caller
// has not already specified one, so that the workerd entry worker
// applies the same content-encoding logic.
// Match undici.fetch() defaults: it sends `Accept: */*` and
// Match undici.fetch() defaults: it sends `Accept: */*` and
// `Accept-Encoding: gzip, deflate` when the caller hasn't set them.
// These are placed before the spread so the caller's own headers
// take precedence.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +233 to +234
// For 303 See Other: switch to GET and drop the body
const redirectMethod = statusCode === 303 ? "GET" : request.method;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Redirect handling for 301/302 POST does not convert method to GET and null the body

The Fetch spec (HTTP-redirect fetch, step 13) requires that when a 301 or 302 redirect is received and the request method is POST, the method must be changed to GET and the body set to null. The code at lines 233-234 only handles 303 (statusCode === 303), leaving 301/302 POST redirects to keep the POST method and attempt to reuse request.body. Since the body ReadableStream was already locked by Readable.fromWeb(request.body) at line 150 during the initial request, accessing request.body returns a locked stream, and new Request(location, { body: lockedStream }) will throw a TypeError. This is a behavioral regression from undici.fetch(), which correctly converted 301/302 POST to GET with a null body.

Suggested change
// For 303 See Other: switch to GET and drop the body
const redirectMethod = statusCode === 303 ? "GET" : request.method;
const redirectMethod = (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && request.method === "POST")) ? "GET" : request.method;
const redirectBody = (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && request.method === "POST")) ? null : request.body;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +90 to +115
// path. See: https://github.com/cloudflare/workers-sdk/issues/12967
const {
statusCode,
headers: rawHeaders,
body,
} = await undiciRequest(request.url, {
method: request.method,
headers: Object.fromEntries(request.headers),
body:
request.body !== null
? Readable.fromWeb(request.body as ReadableStream)
: null,
});
// Build a Headers object that preserves multiple set-cookie values.
const responseHeaders = new Headers();
for (const [name, value] of Object.entries(rawHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
responseHeaders.append(name, v);
}
} else if (value !== undefined) {
responseHeaders.set(name, value);
}
}
const response = new Response(body.body, {
status: statusCode,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 createCloudflareClient no longer follows HTTP redirects after switch to undici.request()

The replacement of undici.fetch() with undici.request() in the custom fetch provided to the Cloudflare SDK client removes automatic redirect following. undici.fetch() follows redirects by default (up to 20), while undici.request() defaults to maxRedirections: 0 — it returns redirect responses (3xx with Location header) as-is without following them. No manual redirect-following logic was added (unlike in the miniflare fetchViaRequest). If any Cloudflare API endpoint returns a redirect, the SDK will receive the 3xx response directly instead of the final response, likely causing unexpected errors in SDK consumers.

Prompt for agents
In packages/wrangler/src/cfetch/internal.ts, the createCloudflareClient function (lines 68-121) replaced undici.fetch() with undici.request(). However, undici.request() does not follow redirects by default (maxRedirections: 0), unlike undici.fetch() which follows up to 20 redirects. Either add maxRedirections: 20 to the undici.request() options at line 92, or implement manual redirect following similar to the fetchViaRequest function in packages/miniflare/src/http/fetch.ts.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +264 to +289
const forward = forwardInit as Request;
const {
statusCode,
headers: rawHeaders,
body,
} = await undiciRequest(url.toString(), {
method: forward.method,
headers: Object.fromEntries(forward.headers),
body:
forward.body !== null
? Readable.fromWeb(forward.body as ReadableStream)
: null,
});
// Build a Headers object that preserves multiple set-cookie values.
const responseHeaders = new Headers();
for (const [name, value] of Object.entries(rawHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
responseHeaders.append(name, v);
}
} else if (value !== undefined) {
responseHeaders.set(name, value);
}
}
return new Response(body.body, {
status: statusCode,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 unstable_dev fetch no longer follows HTTP redirects after switch to undici.request()

The fetch method returned by unstable_dev() previously used undici.fetch(), which follows redirects by default. It now uses undici.request() which defaults to maxRedirections: 0 and returns redirect responses as-is. No manual redirect-following logic was added (unlike in the miniflare fetchViaRequest). Consumers calling worker.fetch('/some-path') where the worker responds with a redirect will now receive the 3xx response instead of the final response after following the redirect chain, silently changing behavior for existing users.

Prompt for agents
In packages/wrangler/src/api/dev.ts, the fetch method of unstable_dev (lines 248-290) replaced undici.fetch() with undici.request(). However, undici.request() does not follow redirects by default (maxRedirections: 0), unlike undici.fetch() which follows up to 20 redirects. Either add maxRedirections: 20 to the undici.request() options at line 264, or implement manual redirect following similar to the fetchViaRequest function in packages/miniflare/src/http/fetch.ts.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 30, 2026

Review posted on PR #13119. Here's a summary of what I found:

  1. Bug (high severity): The redirect-following path in fetchViaRequest has a body-reuse bug. The request body stream is consumed by Readable.fromWeb(request.body) on the initial undici.request() call. When a non-303 redirect is received, the code tries to pass the already-consumed request.body to new Request(...), which will throw TypeError: Response body object should not be disturbed or locked. Since undici.request() does not follow redirects by default, this manual redirect code is reachable. I posted a suggestion comment with a minimal fix.

  2. Stale/duplicate comments at lines 134-140 with broken indentation — posted a cleanup suggestion.

  3. Informational note that performApiFetch and fetchR2Objects in wrangler still use undici.fetch() and can be reached with streaming bodies (via AI fetcher and R2 object uploads), so they remain theoretically vulnerable to the same 401 crash. Not blocking since it's pre-existing and outside scope.

github run

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 30, 2026

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@13119

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@13119

miniflare

npm i https://pkg.pr.new/miniflare@13119

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@13119

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@13119

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@13119

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@13119

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@13119

wrangler

npm i https://pkg.pr.new/wrangler@13119

commit: 0fe7f50

@beanow-at-crabnebula
Copy link
Copy Markdown

The upstream fix (nodejs/undici#4910, PR #4936) is currently blocked — maintainers consider the behaviour spec-mandated.

A new fix is being worked on by the maintainers.
nodejs/undici#4941

@petebacondarwin
Copy link
Copy Markdown
Contributor Author

The upstream fix (nodejs/undici#4910, PR #4936) is currently blocked — maintainers consider the behaviour spec-mandated.

A new fix is being worked on by the maintainers. nodejs/undici#4941

Ah I didn't see that. In which case I think it is better for us to just wait for that to land and then upgrade to the new version when it is ready.

@github-project-automation github-project-automation bot moved this from Untriaged to Done in workers-sdk Mar 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Unstable_DevWorker.fetch fails when request has a body and receives a 401 response

3 participants