@effect/platform: HttpClientRequest emits explicit Content-Length, causing wire mismatches under undici 8.2+

クローズ 初心者向け
#6,240 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@sijie-Z がすでに取り組んでいます。

2026年6月9日 から。

  • #6265 @sijie-Z による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
72/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
静か
技術スタック
node.js, typescript
領域
api, backend

調査の方向性

packages/platform/src/internal/httpClientRequest.ts から始め、setBody に注目し、その後 internal/httpBody.ts で作成される body 型を確認してください。提供されている bodyUrlParams の例を使用して、Node 22 と undici 8.2.x で問題を再現してください。完了の条件は、送信 body ヘルパーが明示的な Content-Length を提供せず、transport が一致する wire 値を計算することです。

索引モデルが issue の本文から書いたものです。

説明

3.0 bug

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-Length on 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-length and 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.length snapshot and the bytes leaving the socket differs by even one byte, the upstream server gets a mismatched Content-Length and 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.x
  • effect: latest
  • node: 22.x
  • undici (userland, transitive): 8.2.0+
主要言語
TypeScript
スター
16.7k
フォーク
808
平均マージ
11時間 3分
マージ済み PR(30日)
839

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

Effect-TS/effect のほかの issue

Effect-TS/effect の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。