Skip to content

HttpClientRequest: add removeHeader / updateHeaders for removing & transforming headers #6271

Description

@dpennell

What is the problem this feature would solve?

HttpClientRequest lets you add/override headers (setHeader, setHeaders) but provides no way to remove a header or transform the header set after a request has been built. The Headers module already has Headers.remove (and set/setAll/merge), but HttpClientRequest doesn't expose an equivalent, so there's no public, prototype-safe way to drop a single header from an existing request.

This isn't hypothetical — it bit us in production. HttpClientRequest.bodyUnsafeJson (→ setBody) writes an explicit content-length header derived from the body's byte length:

// @effect/platform/internal/httpClientRequest.js — setBody
const contentLength = body.contentLength
if (contentLength) {
  headers = Headers.set(headers, "content-length", contentLength.toString())
}

When that request is executed through FetchHttpClient, the platform fetch/undici also derives and appends its own content-length from the body. On Node 20, undici then sees a duplicated content-length: <n>, <n> and rejects the request with:

RequestError (reason: Transport)
  └─ TypeError: fetch failed
       └─ InvalidArgumentError  UND_ERR_INVALID_ARG  'invalid content-length header'

(Node 22/24's undici is more lenient, so it only reproduces on Node 20 — which is exactly where many CI runners and Lambda runtimes sit.)

The natural fix is to remove the redundant explicit content-length header before execution and let fetch/undici compute it exactly once. But there's no API to do that. HttpClientRequest.modify only applies set-style operations (it routes headers through setHeaders), and there's no removeHeader/updateHeaders.

What is the feature you are proposing to solve the problem?

Add header-removal / header-transformation combinators to HttpClientRequest, mirroring the existing setHeader/setHeaders and delegating to the Headers module that already has the needed primitives:

// Remove one (or several) headers by key — mirrors Headers.remove
export const removeHeader: {
  (key: string): (self: HttpClientRequest) => HttpClientRequest
  (self: HttpClientRequest, key: string): HttpClientRequest
}

// General-purpose: transform the whole header set — the most flexible primitive
export const updateHeaders: {
  (f: (headers: Headers.Headers) => Headers.Headers): (self: HttpClientRequest) => HttpClientRequest
  (self: HttpClientRequest, f: (headers: Headers.Headers) => Headers.Headers): HttpClientRequest
}

updateHeaders is the more fundamental of the two — removeHeader, setHeader, etc. can all be expressed in terms of it — and it composes cleanly with the existing Headers combinators:

import { Headers, HttpClientRequest } from "@effect/platform"

request.pipe(HttpClientRequest.removeHeader("content-length"))
// or, equivalently:
request.pipe(HttpClientRequest.updateHeaders(Headers.remove("content-length")))

This also fixes the motivating case ergonomically, e.g. via HttpClient.mapRequest:

const client = HttpClient.mapRequest(
  baseClient,
  HttpClientRequest.removeHeader("content-length")
)

(Optionally, HttpClientRequest.modify's options could also gain a removeHeaders?: ReadonlyArray<string> field for symmetry, but removeHeader/updateHeaders are the core asks.)

What alternatives have you considered?

  1. Manual clone (current workaround). Because makeInternal builds requests via Object.create(Proto) and there's no public constructor that takes pre-built headers without re-running setBody (which would re-add content-length), we reconstruct the request by hand while preserving the prototype:

    import { Headers, HttpClientRequest } from "@effect/platform"
    
    const removeHeader = (
      request: HttpClientRequest.HttpClientRequest,
      name: string
    ): HttpClientRequest.HttpClientRequest => {
      const headers = Headers.remove(request.headers, name)
      // Preserve the HttpClientRequest prototype (TypeId, pipe) while swapping headers.
      const next = Object.create(Object.getPrototypeOf(request))
      return Object.assign(next, request, { headers })
    }

    This works but reaches past the public API and is fragile to internal representation changes — exactly the kind of thing a first-class combinator should make unnecessary.

  2. Rebuild via HttpClientRequest.make/modify. Not viable: passing body back through modify/make re-runs setBody, which re-adds the content-length header we're trying to remove. There's no public path to set headers after the body without the body re-injecting them.

  3. Fix FetchHttpClient to not forward a content-length it already set. This is a reasonable orthogonal improvement (and arguably a separate bug report), but a general removeHeader/updateHeaders is broadly useful well beyond this one case — conditional auth/trace headers, redaction, normalizing proxy headers, stripping hop-by-hop headers, etc. — and brings HttpClientRequest to parity with the Headers module and with server-side response builders.

Environment

  • @effect/platform 0.96.1, effect 3.21.2
  • Node.js 20 (where undici's stricter content-length validation surfaces the duplicate-header rejection)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions