@effect/platform: HttpClientRequest emits explicit Content-Length, causing wire mismatches under undici 8.2+
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 2/5
- Tempo stimato
- 1-3 ore
- Idoneità per principianti
- 72/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Tranquilla
- Stack tecnologico
- node.js, typescript
Direzione di ricerca
Inizia in packages/platform/src/internal/httpClientRequest.ts, concentrandoti su setBody, quindi esamina i tipi di body creati in internal/httpBody.ts. Riproduci il problema con Node 22 e undici 8.2.x usando l’esempio bodyUrlParams fornito. Il lavoro è completato quando gli helper del body in uscita non forniscono più un Content-Length esplicito e il transport calcola un valore corrispondente per la trasmissione.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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+
- Lingua principale
- TypeScript
- Stelle
- 16.7k
- Fork
- 808
- Merge medio
- 11h 29m
- PR unite (30g)
- 453
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di Effect-TS/effect
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 86/100
Effect-TS/effect#8863 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
BrowserWorkerRunner: port finalizer throws when the worker global has no close() (Bun)Forse già presa @santiago-ramos-02 l’ha presa 8 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
Effect-TS/effect#8635 · 3 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Support {self: this} for fnUntracedForse di nuovo libera @ArjunCodess l’ha presa 19 giorni fa e non c’è nessuna pull request aperta. Apertaenhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
Effect-TS/effect#8101 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Support delayed jobs in PersistedQueueForse già presa @tim-smart l’ha presa oggi. Apertaenhancement
Difficoltà 5/5 Più di una settimana Idoneità per principianti 25/100
I maintainer di solito rispondono entro 1 giorno
-
ChildProcess: support bidirectional (duplex) additional file descriptorsForse già presa Una pull request collegata a questa issue è aperta o già unita. Aperta
Difficoltà 3/5 Mezza giornata Idoneità per principianti 25/100
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di Effect-TS/effect
Issue simili
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
lukilabs/beautiful-mermaid#160 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 66/100
rescript-lang/rescript-lang.org#1420 ·
I maintainer di solito rispondono entro 2 giorni
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
chthollyphile/folia-major#520 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
databuddy-analytics/Databuddy#1106 ·
I maintainer di solito rispondono entro 1 giorno