Skip to content

fix(ui): stop cloning body-carrying requests into stream uploads in fetchClient middleware - #34122

Merged
ryan-crabbe-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_fix_fetchclient_h1_stream_body
Jul 21, 2026
Merged

fix(ui): stop cloning body-carrying requests into stream uploads in fetchClient middleware#34122
ryan-crabbe-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_fix_fetchclient_h1_stream_body

Conversation

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

The bug is a browser network-layer failure, so the proof is captured in Chromium against a live HTTP/1.1 server (python3 -m http.server 47831, same protocol uvicorn speaks), running the middleware's exact old and new request constructions from the page's console

Before (old construction, parent commit 212a921): a plain fetch POST with a string body reaches the server, while the middleware's new Request(url, request) clone of the identical request turns the body into a stream and dies at the network layer

const url = "http://localhost:47831/post-target";
const orig = new Request(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ a: 1 }) });
await fetch(orig.clone());                    // reached server, status 501
const cloned = new Request(url, orig);
Object.prototype.toString.call(cloned.body);  // [object ReadableStream], duplex=half
await fetch(cloned);                          // TypeError: Failed to fetch (net::ERR_ALPN_NEGOTIATION_FAILED)

After (new construction from this PR, commit 979e869): rebuilding with the body materialized as bytes reaches the server like the plain fetch does

const rebased = new Request(url, { method: orig.method, headers: orig.headers, body: await orig.arrayBuffer(), mode: orig.mode, credentials: orig.credentials, cache: orig.cache, redirect: orig.redirect, referrer: orig.referrer, integrity: orig.integrity, keepalive: orig.keepalive, signal: orig.signal });
await fetch(rebased);                         // reached server, status 501

(501 is python http.server declining POST; the point is the request reaches the server instead of failing before it leaves the browser)

To see it in the product: run the proxy over plain http on localhost:4000, run the dashboard dev server on localhost:3000, open the MCP Servers page, pick a server and use the "connect with your API key" credential modal. On the parent commit the save fails with "Failed to fetch"; on this branch it completes

Type

🐛 Bug Fix

Changes

The openapi-fetch middleware in ui/litellm-dashboard/src/lib/http/api.ts rebuilt every outgoing request with new Request(url, request). Per the fetch spec that converts a string JSON body into a ReadableStream with duplex: "half", i.e. a streaming upload, which Chromium only allows over HTTP/2 or HTTP/3. LiteLLM's uvicorn server only speaks HTTP/1.1, so on any deployment where the browser reaches the proxy over plain http (docker -p 4000:4000, an HTTP/1.1-only edge) every body-carrying request failed with net::ERR_ALPN_NEGOTIATION_FAILED, surfaced to the user as "Failed to fetch"

The middleware landed in #29884 with only GET callers (null body, unaffected), which is why nothing broke in v1.93.0. #33103 added the first POST caller (the MCP BYOK credential modal), so that flow is broken on plain-http deployments in the v1.94.0 RCs. https deployments behind an h2 edge never saw it

The fix removes the unconditional clone. When no runtime base url is registered the middleware now injects the auth header on the original request in place; when a rebase is needed it rebuilds the request with the body read out as bytes via arrayBuffer(), which fetch sends with Content-Length instead of a streaming upload, carrying over method, headers, credentials, signal and the other standard init fields

The regression test spies on the global Request constructor and asserts no construction receives a body-carrying Request as init or a ReadableStream body, for both the no-rebase and rebase paths, plus that the body round-trips as the exact JSON string with the auth header applied. Both tests fail on the parent commit and pass here. Existing coverage for header injection, url rebasing and ApiError mapping still passes

Note #34116 deliberately kept useSetKeyBlockedState on the legacy apiClient because of this bug; with this fix it can move back to fetchClient, left out of here to keep the scope isolated

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…etchClient middleware

The openapi-fetch middleware rebuilt every outgoing request with
new Request(url, request), which converts a string JSON body into a
ReadableStream with duplex=half. Chromium only allows streaming uploads
over HTTP/2 or HTTP/3, so against any HTTP/1.1 hop (uvicorn serves
HTTP/1.1 only) the fetch dies at the network layer with
net::ERR_ALPN_NEGOTIATION_FAILED, surfaced as "Failed to fetch".

GET callers were unaffected (null body); the first body-carrying caller
arrived with the MCP BYOK credential modal, breaking that flow on plain
http deployments in the v1.94.0 RCs.

The middleware now mutates headers on the original request when no
runtime base is registered, and when rebasing onto a runtime base it
rebuilds the request with the body materialized as bytes via
arrayBuffer(), which fetch sends with Content-Length instead of a
streaming upload
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes how the dashboard middleware handles body-carrying requests. The main changes are:

  • Reuses the original request when no URL rebase is needed
  • Materializes request bodies as bytes before rebuilding rebased requests
  • Adds POST tests for body preservation, authentication, and URL rebasing

Confidence Score: 5/5

The upload fix looks mergeable after preserving the original referrer policy.

  • Body-carrying requests are no longer cloned into stream uploads.
  • Tests cover both runtime base URL branches.
  • Rebasing can reset an explicitly restrictive referrer policy.

ui/litellm-dashboard/src/lib/http/api.ts

Security Review

Rebased requests do not preserve an explicitly configured referrer policy, which can expose referrer data when the runtime base URL is cross-origin.

Important Files Changed

Filename Overview
ui/litellm-dashboard/src/lib/http/api.ts Avoids streaming request clones during URL rebasing, but omits the original request's referrer policy.
ui/litellm-dashboard/src/lib/http/api.test.ts Adds focused tests for byte-backed POST bodies on rebased and non-rebased requests.

Reviews (1): Last reviewed commit: "fix(ui): stop cloning body-carrying requ..." | Re-trigger Greptile

Comment thread ui/litellm-dashboard/src/lib/http/api.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@codspeed-hq

codspeed-hq Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_fetchclient_h1_stream_body (113ed0d) with litellm_internal_staging (212a921)

Open in CodSpeed

@ryan-crabbe-berri
ryan-crabbe-berri merged commit b47fe73 into litellm_internal_staging Jul 21, 2026
76 checks passed
@ryan-crabbe-berri
ryan-crabbe-berri deleted the litellm_fix_fetchclient_h1_stream_body branch July 21, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants