@effect/platform: HttpClientRequest emits explicit Content-Length, causing wire mismatches under undici 8.2+
Maintainers usually reply within 1 day
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 72/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- node.js, typescript
Research direction
Start in packages/platform/src/internal/httpClientRequest.ts, focusing on setBody, then review the body types created in internal/httpBody.ts. Reproduce the issue with Node 22 and undici 8.2.x using the provided bodyUrlParams example. Done means outbound body helpers no longer provide an explicit Content-Length and the transport computes a matching wire value.
Written by the indexing model from the issue text.
Description
Summary
@effect/platform's HttpClientRequest body helpers (bodyUrlParams, bodyUnsafeJson, bodyText, bodyUint8Array) attach an explicit content-length header to outbound requests, computed at request-build time from the encoded Uint8Array.length. The transport (fetch / undici) is supposed to compute that header itself. With undici 8.2.0+ this preempting causes real-world breakage because the value Effect snapshots no longer matches the bytes actually written to the wire.
Where it happens
In packages/platform/src/internal/httpClientRequest.ts, in setBody:
const contentLength = body.contentLength;
if (contentLength) {
headers = Headers.set(headers, "content-length", contentLength.toString());
}
The Uint8ArrayImpl body type (created by text, uint8Array, unsafeJson, urlParams in internal/httpBody.ts) exposes contentLength from Uint8Array.length. So every request built with the form/json/text helpers carries a caller-supplied content-length header.
Why this matters
The header value Effect emits isn't malformed — it's a correct digit string at the moment Effect builds the request. The problem is that Effect is preempting work the transport owns:
- The WHATWG Fetch spec puts
Content-Lengthon the forbidden request-header list precisely so the implementation can compute the canonical value from what it's actually going to send (after body extraction, interceptor cloning, redirect handling, stream wrapping, OTel instrumentation, etc.). - Before undici 8.2.0, undici would always recompute its own
content-lengthand overwrite the caller-supplied one, so any drift between Effect's snapshot and the transport's actual byte count was silently corrected. - undici 8.2.0 (PR nodejs/undici#5060) stopped auto-overriding when the caller already set the header. Now Effect's value goes to the wire unchanged. If anything between the
body.lengthsnapshot and the bytes leaving the socket differs by even one byte, the upstream server gets a mismatchedContent-Lengthand rejects the request.
We observed this in production against multiple unrelated third-party APIs that share only the bodyUrlParams code path. Pinning undici to 8.1.0 restored the prior lenient behavior. Note this reproduces on Node 22 even though Node ships its own bundled undici: the moment any dependency does import "undici" (e.g. @vercel/blob, @workflow/world-vercel), userland undici installs itself as the global dispatcher and the new behavior applies process-wide.
Workaround
Replace bodyUrlParams({...}) with HttpBody.raw(new URLSearchParams(...), { contentType: "application/x-www-form-urlencoded" }). HttpBody.raw doesn't expose a contentLength unless you pass it explicitly, so setBody skips emitting the header, and the transport computes it correctly.
Suggested fix
Stop emitting content-length from body.contentLength in setBody. The contentLength field is still useful internally (stream framing, server responses), but it shouldn't be reflected into outbound request headers — the transport is the only layer that can compute a value that matches what's actually sent.
export const setBody = dual(2, (self, body) => {
let headers = self.headers;
if (body._tag === "Empty" || body._tag === "FormData") {
headers = Headers.remove(headers, ["Content-type", "Content-length"]);
} else {
const contentType = body.contentType;
if (contentType) {
headers = Headers.set(headers, "content-type", contentType);
}
// removed: explicit content-length header — let the transport compute it
}
return makeInternal(self.method, self.url, self.urlParams, self.hash, headers, body);
});
Reproduction
Against Node 22 + undici 8.2.x:
import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"
import { Effect } from "effect"
// Any dep that imports "undici" installs userland 8.x as global dispatcher
await import("undici")
const program = Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
return yield* http.execute(
HttpClientRequest.post("https://api.example.com/oauth/token").pipe(
HttpClientRequest.bodyUrlParams({ grant_type: "refresh_token", refresh_token: "..." })
)
)
})
await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)))
// Upstream rejects with a Content-Length mismatch
Pinning userland undici to 8.1.0 resolves it without any Effect change.
Environment
@effect/platform: 0.96.xeffect: latestnode: 22.xundici(userland, transitive): 8.2.0+
- Dominant language
- TypeScript
- Stars
- 16.7k
- Forks
- 808
- Avg merge
- 10h 36m
- Merged PRs (30d)
- 449
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Effect-TS/effect
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Effect-TS/effect#8881 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 1-3 hours Newbie friendliness 86/100
Effect-TS/effect#8863 · 1 comment ·
Maintainers usually reply within 1 day
-
BrowserWorkerRunner: port finalizer throws when the worker global has no close() (Bun)Possibly taken @santiago-ramos-02 claimed this 7 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Effect-TS/effect#8635 · 3 comments ·
Maintainers usually reply within 1 day
-
Support {self: this} for fnUntracedMay be free again @ArjunCodess claimed this 18 days ago, and no pull request is open. Openenhancement
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Effect-TS/effect#8101 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 67/100
Effect-TS/effect#8860 · 1 reaction ·
Maintainers usually reply within 1 day
All issues in Effect-TS/effect
Similar issues
-
Bump Firebase JS SDK (12.19.0 → 13.0.0)Possibly taken @SelaseKay claimed this today. OpenNeeds Attention type: enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
invertase/react-native-firebase#9364 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 4 days
-
e2e-failure ready-to-code
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
redhat-developer/rhdh-plugin-export-overlays#4261 · 1 comment ·
Maintainers usually reply within 1 day
-
[Bug] 官网文档的图片挂了Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
Maintainers usually reply within 1 day
-
area:cli bug triage:in-progress
Difficulty 1/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day