Skip to content

node:http: fix ERR_HTTP_CONTENT_LENGTH_MISMATCH and ERR_HTTP_TRAILER_INVALID message text - #35777

Closed
robobun wants to merge 2 commits into
mainfrom
farm/58eea9f1/http-error-messages
Closed

robobun wants to merge 2 commits into
mainfrom
farm/58eea9f1/http-error-messages

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Two node:http client error codes had broken message templates. The codes and throw timing were already node-identical; only the .message text was wrong.

Code Bun before Node.js
ERR_HTTP_CONTENT_LENGTH_MISMATCH "3" "Response body's content-length of 3 byte(s) does not match the content-length of 5 byte(s) set in header"
ERR_HTTP_TRAILER_INVALID "undefined" "Trailers are invalid with this transfer encoding"

Repro

import http from 'node:http';
import net from 'node:net';
const srv = net.createServer(s => s.on('data', () => s.end('HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')));
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
{
  const req = http.request({ host: '127.0.0.1', port, method: 'POST', agent: false, headers: { 'Content-Length': 5 } });
  req.on('error', () => {}); req.strictContentLength = true;
  try { req.end('abc'); } catch (e) { console.log(e.code, JSON.stringify(e.message)); }
  req.destroy();
}
{
  const req = http.request({ host: '127.0.0.1', port, method: 'GET', agent: false, headers: { Trailer: 'X-T' } });
  req.on('error', () => {});
  try { req.end(); } catch (e) { console.log(e.code, JSON.stringify(e.message)); }
  req.destroy();
}
srv.close();

Cause

jsFunctionMakeErrorWithCode in src/jsc/bindings/ErrorCode.cpp had no case for either code, so they fell through to the default which stringifies callFrame->argument(1). $ERR_HTTP_CONTENT_LENGTH_MISMATCH(3, 5) became "3"; $ERR_HTTP_TRAILER_INVALID() became String(undefined).

Fix

Added a two-arg template case for ERR_HTTP_CONTENT_LENGTH_MISMATCH and a constant-message case for ERR_HTTP_TRAILER_INVALID, matching Node's lib/internal/errors.js. Also dropped the now-redundant inline string from the two server-side $ERR_HTTP_TRAILER_INVALID(...) calls in _http_server.ts.

Verification

$ bun bd test test/js/node/http/node-http-outgoing-errors.test.ts
 2 pass, 0 fail

Also passes node-http-transfer-encoding.test.ts (23 tests) and the Node parallel tests test-http-content-length-mismatch.js / test-http-server-de-chunked-trailer.js.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 81663ec1-3ac3-4876-8682-67d0d8cada26

📥 Commits

Reviewing files that changed from the base of the PR and between d3cfcea and 35de9ec.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • src/jsc/bindings/ErrorCode.cpp
  • test/js/node/http/node-http-outgoing-errors.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: Fix verified locally.

Reproduced with:

USE_SYSTEM_BUN=1 bun test test/js/node/http/node-http-outgoing-errors.test.ts

Both assertions fail before (messages "3" and "undefined"), both pass after bun bd test.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:15 PM PT - Jul 25th, 2026

❌ @autofix-ci[bot], your commit 35de9ec has some failures in Build #81276 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 35777

That installs a local version of the PR into your bun-35777 executable, so you can run:

bun-35777 --bun

Comment on lines +2394 to +2403
case Bun::ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH: {
auto arg0 = callFrame->argument(1);
auto str0 = arg0.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto arg1 = callFrame->argument(2);
auto str1 = arg1.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto message = makeString("Response body's content-length of "_s, str0, " byte(s) does not match the content-length of "_s, str1, " byte(s) set in header"_s);
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH, message));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Heads-up: the server-side ServerResponse path still throws ERR_HTTP_CONTENT_LENGTH_MISMATCH with the old "Content-Length mismatch" message — _http_server.ts calls handle.write/end(..., strictContentLength(this)), which lands in src/runtime/server/NodeHTTPResponse.rs:1992-2003 and throws via err_throw(global, code, "Content-Length mismatch"), bypassing jsFunctionMakeErrorWithCode entirely. Same-class sibling of this fix per REVIEW.md; worth either updating the Rust literal to the templated Node text (it has both byte counts in scope) or noting the exclusion in the PR description. Not blocking — nothing regresses.

Extended reasoning...

What's left unfixed

This PR correctly adds an ERR_HTTP_CONTENT_LENGTH_MISMATCH case to jsFunctionMakeErrorWithCode, so any JS-side $ERR_HTTP_CONTENT_LENGTH_MISMATCH(actual, expected) call now produces Node's templated message. That covers the client (_http_outgoing.ts) path.

However, the server ServerResponse write/end path does not go through $ERR_HTTP_CONTENT_LENGTH_MISMATCH(...). In src/js/node/_http_server.ts (lines 3186/3217/3363/3366/3544/3547), ServerResponse#write / #end call handle.write(chunk, encoding, cb, strictContentLength(this)) / handle.end(...) — passing the strict-content-length flag into native code. The native handle is NodeHTTPResponse (src/runtime/server/NodeHTTPResponse.rs), whose write_or_end throws the mismatch error directly:

// NodeHTTPResponse.rs:1991-2003
if bytes_written as u64 != content_length {
    return err_throw(
        global_object,
        ErrorCode::ERR_HTTP_CONTENT_LENGTH_MISMATCH,
        "Content-Length mismatch",
    );
}

And err_throw → err_throw_cold (lines 281-282) does global.err(code, format_args!("{}", msg)).throw() — it constructs the JS error from the literal string right there. It never routes through jsFunctionMakeErrorWithCode, so the new template case in ErrorCode.cpp is never reached for server responses.

Step-by-step repro

  1. http.createServer((req, res) => { res.strictContentLength = true; res.setHeader('Content-Length', 5); res.writeHead(200); try { res.end('abc'); } catch (e) { console.log(e.code, JSON.stringify(e.message)); } }).listen(0)
  2. Send any request. res.end('abc') → _http_server.ts:3217 → handle.end('abc', ..., true /* strictContentLength */).
  3. In NodeHTTPResponse.rs::write_or_end, is_end == true, bytes_written = 3, content_length = 5, so the bytes_written != content_length branch at line 1991 fires.
  4. err_throw(global, ERR_HTTP_CONTENT_LENGTH_MISMATCH, "Content-Length mismatch") is returned; the JS error's .message is the literal "Content-Length mismatch".
  5. Output: ERR_HTTP_CONTENT_LENGTH_MISMATCH "Content-Length mismatch" — Node.js prints "Response body's content-length of 3 byte(s) does not match the content-length of 5 byte(s) set in header".

The existing test test/js/bun/test/parallel/test-http-strictContentLength-should-work-on-server.ts only asserts .code, not .message, so it doesn't catch this.

Why this is in scope

REVIEW.md, Correctness: the bug class, not the bug: "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep). Grep for every sibling site sharing the pattern... If a site is intentionally excluded, say so in the PR." This is the same error code with the same wrong-message-text problem, just on the Rust-native server path instead of the JS client path. The PR title is "fix ERR_HTTP_CONTENT_LENGTH_MISMATCH ... message text", so the server-side sibling is directly in the stated scope.

Impact & fix

Message-text-only Node compat gap; .code is already correct and nothing regresses if merged as-is — hence nit. To fix, replace the literal in NodeHTTPResponse.rs:1992-2003 with the templated form (both bytes_written and content_length are in scope), e.g. via global.err(code, format_args!("Response body's content-length of {} byte(s) does not match the content-length of {} byte(s) set in header", bytes_written, content_length)).throw(), or route the throw back through JS via $ERR_HTTP_CONTENT_LENGTH_MISMATCH(actual, expected). If intentionally deferred, a note in the PR description would satisfy REVIEW.md.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — straightforward error-message template fix following the existing jsFunctionMakeErrorWithCode case pattern. The prior inline note about the Rust-native NodeHTTPResponse server path is a pre-existing gap and non-blocking.

Checked: all JS-side $ERR_HTTP_CONTENT_LENGTH_MISMATCH/$ERR_HTTP_TRAILER_INVALID call sites match the new arg shapes; RETURN_IF_EXCEPTION after each toWTFString; message text matches Node's lib/internal/errors.js and the existing test-http-server-de-chunked-trailer.js assertion; no other tests assert the old broken text.

Extended reasoning...

Overview

Adds two switch cases to jsFunctionMakeErrorWithCode in src/jsc/bindings/ErrorCode.cpp: a two-arg templated case for ERR_HTTP_CONTENT_LENGTH_MISMATCH and a constant-message case for ERR_HTTP_TRAILER_INVALID. Drops the now-redundant inline string args from two $ERR_HTTP_TRAILER_INVALID(...) calls in src/js/node/_http_server.ts. Adds test/js/node/http/node-http-outgoing-errors.test.ts with two tests exercising the client-side throw paths and asserting exact .code and .message.

Security risks

None. Pure error-message text change; no input parsing, no auth, no allocation sizing based on untrusted data.

Level of scrutiny

Low. The C++ change is byte-for-byte the same shape as the neighboring ERR_HTTP_INVALID_HEADER_VALUE case (two toWTFString + RETURN_IF_EXCEPTION + makeString), and the constant-message case matches the neighboring ERR_HTTP_BODY_NOT_ALLOWED/ERR_HTTP_SOCKET_ASSIGNED block. All five $ERR_* call sites in src/js/ (_http_outgoing.ts:563,934,1079 and _http_server.ts:2117,2147) were checked against the new signatures — the two-arg mismatch calls pass (actual, expected) in the order the template expects, and all trailer-invalid calls are now zero-arg. Grepped test/ for assertions on the old text — none exist; the one existing message assertion (test-http-server-de-chunked-trailer.js:14) already expects the Node-correct string, so this fix makes it pass rather than break.

Other factors

The test uses once(srv, 'listening'), port: 0, no network egress, and cleans up in finally. robobun confirmed USE_SYSTEM_BUN=1 fails / bun bd test passes. A prior review pass left a non-blocking nit about the Rust-native NodeHTTPResponse.rs server path still using a "Content-Length mismatch" literal — that path pre-dates this PR, is not touched here, and does not go through jsFunctionMakeErrorWithCode, so nothing in this diff regresses it. The author can address it in a follow-up or note the exclusion; it does not block this strictly-improving change.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #35791, which covers these two codes plus ERR_HTTP2_UNSUPPORTED_PROTOCOL, ERR_STREAM_DESTROYED (internal/http.ts), ERR_OPERATION_FAILED (fs.promises.ts), ERR_SCRIPT_EXECUTION_INTERRUPTED, and a debug-only guard on the fallthrough tail. The two ErrorCode.cpp template additions here are byte-identical in #35791, and the _http_server.ts cleanup has been folded in.

@robobun robobun closed this Jul 25, 2026
dylan-conway pushed a commit that referenced this pull request Sep 19, 2026
### Problem

- Seven Node error codes have a broken `.message`. `http.request({
headers: { Trailer: "X-T" } }).end()` throws `ERR_HTTP_TRAILER_INVALID`
with the message `undefined`. `url.fileURLToPath(httpUrl, { windows:
true })` throws `ERR_INVALID_URL_SCHEME` with the message `file`. Notes
list all seven.
- The cause is the tail of `jsFunctionMakeErrorWithCode`
(`src/jsc/bindings/ErrorCode.cpp:2478`). A code with no message template
uses its first argument as the whole message. These call sites pass
Node's template arguments, or nothing.
- The reverse also happens. A sentence passed to a templated code gives
`Cannot call Stream is destroyed after a stream was destroyed`.

### Fix

- Four codes get Node's template: `ERR_HTTP_TRAILER_INVALID`,
`ERR_SCRIPT_EXECUTION_INTERRUPTED`, `ERR_HTTP_CONTENT_LENGTH_MISMATCH`,
`ERR_INVALID_URL_SCHEME`. The server `strictContentLength` check throws
from Rust (`NodeHTTPResponse.rs`). It now formats Node's sentence with
both byte counts.
- The call sites of three codes now pass Node's argument:
`ERR_STREAM_DESTROYED("write")`, `ERR_OPERATION_FAILED("write failed
after retries")`, `ERR_METHOD_NOT_IMPLEMENTED("FileHandle with fs")`.
Dead arguments are gone.
- Verified: `test/js/node/errors/error-code-messages.test.ts`. Bun
1.4.3-canary.1+367d939d9 fails 7 of its 8 tests. The expected messages
are node v26.3.0's.
- Self-reviewed: 12 concerns raised, 8 addressed, 4 rejected (see
Notes).

### Background

- Built-in JS writes `$ERR_FOO(a, b)`. The build turns it into a call of
`jsFunctionMakeErrorWithCode`, which builds the message in C++.
- A message template is a `case` in that function, or a row in its
`simpleErrorMessages` table: fixed text around one or two arguments.
- `strictContentLength` makes `node:http` throw when the body size
differs from `Content-Length`. The client checks in JS. The server
checks in native code, which never reaches the C++ template.

<details><summary>Notes</summary>

Before and after, per code. The "after" text is identical to node
v26.3.0.

| code | call | before | after |
| --- | --- | --- | --- |
| `ERR_HTTP_TRAILER_INVALID` | client request with a `Trailer` header
and no chunked body | `undefined` | `Trailers are invalid with this
transfer encoding` |
| `ERR_HTTP_CONTENT_LENGTH_MISMATCH` | client `req.end("abc")`,
`Content-Length: 5` | `3` | `Response body's content-length of 3 byte(s)
does not match the content-length of 5 byte(s) set in header` |
| `ERR_HTTP_CONTENT_LENGTH_MISMATCH` | server `res.end("abc")`,
`Content-Length: 5` | `Content-Length mismatch` | the same sentence |
| `ERR_INVALID_URL_SCHEME` | `fileURLToPath(httpUrl, { windows })` |
`file` | `The URL must be of scheme file` |
| `ERR_STREAM_DESTROYED` | `res.destroy(); res.write("x", cb)` | `Cannot
call Stream is destroyed after a stream was destroyed` | `Cannot call
write after a stream was destroyed` |
| `ERR_SCRIPT_EXECUTION_INTERRUPTED` | REPL, Ctrl+C during `await` |
`undefined` | ``Script execution was interrupted by `SIGINT` `` |
| `ERR_OPERATION_FAILED` | `FileHandle` writer, every write returns 0
bytes | `Operation failed: Operation failed: write failed after retries`
| `Operation failed: write failed after retries` |
| `ERR_METHOD_NOT_IMPLEMENTED` | `createReadStream(null, { fd:
fileHandle, fs })` | `The fs.FileHandle with custom fs operations method
is not implemented` | `The FileHandle with fs method is not implemented`
|

- How the list was made: a sweep over `src/js` for every `$ERR_X(` call,
split by whether `X` has a `case` or a table row in `ErrorCode.cpp`. 57
codes have no template. All of them pass a full sentence, except the
four above and `ERR_HTTP2_UNSUPPORTED_PROTOCOL`. The reverse direction
(a sentence passed into a template) gave `ERR_STREAM_DESTROYED` and
`ERR_OPERATION_FAILED`. `ERR_METHOD_NOT_IMPLEMENTED` passes a fragment,
but not the one Node passes.
- The ported `test-fs-read-stream-file-handle.js` gets its upstream
`message:` assertion back. It was commented out because of the
`ERR_METHOD_NOT_IMPLEMENTED` text.
- Deleted arguments: the sentence in two
`$ERR_HTTP_TRAILER_INVALID(...)` calls in `_http_server.ts`, and the
`...args` of the REPL wrapper for `ERR_SCRIPT_EXECUTION_INTERRUPTED`.
The constant-message `case` ignores them.
- `fileURLToPathBuffer` (`url.ts:1320`) passed the whole sentence and
was correct. With the new table row it passes `"file"`, like its sibling
and like Node. Without that edit the message would read `The URL must be
of scheme The URL must be of scheme file`.
- `src/js/builtins.d.ts` declares the argument shapes of the four codes
that got a template. Without a declaration the code generator emits
`(message: string)`.
- Not in this PR: `ERR_HTTP2_UNSUPPORTED_PROTOCOL`
(`http2.connect("ftp://...")` prints `ftp:`) has the same cause. A
separate change owns it (branch
`robobun/d80742c6/http2-unsupported-protocol`), so this PR does not add
its row. #43087 also edits `makeSimpleErrorMessage` and adds a row at
the end of the table. The new rows here are in the middle of the table
to keep the merges clean.
- Not in this PR: `fetch()` also throws
`ERR_HTTP_CONTENT_LENGTH_MISMATCH` (`FetchTasklet.rs`) with its own
sentence about the request body. That is a Bun `fetch` error, not a
`node:http` one, so its text stays.
- Not in this PR: `fs.readFileSync(new URL("http://example.com"))`
throws `ERR_INVALID_URL_SCHEME` from `src/runtime/node/types.rs` with
the text `URL must be a non-empty "file:" path`.
`ERR_INVALID_FILE_URL_PATH` and `ERR_INVALID_FILE_URL_HOST` share that
same sentence there. A separate change fixes the three together, because
the path and host texts need more than a new literal.
- Three of the five `ERR_OPERATION_FAILED` call sites doubled the
prefix, all in the `FileHandle` writer. The other two already pass
Node's argument. The test reaches the synchronous writer site: it
replaces `fs.writeSync` with a function that returns 0, and the writer
looks `writeSync` up on the public module at call time. The two async
sites bind `write` and `writev` at module load, so a test cannot make
them return 0. They get the same change. Node's own writer calls its
binding directly, so this expected string comes from Node's source
(`'Operation failed: %s'` with `'write failed after retries'`) and not
from a Node run. Every other expected string is Node's output for the
same call.
- The REPL test asserts that the output contains the message, not the
whole line. Bun prints `Uncaught Error: <message>` where Node prints
`Uncaught:` and the inspected error with its `[ERR_...]` bracket. That
difference is about the stack header, not the message.
- Node does not check the first `res.write()` against `Content-Length`
(its `_contentLength` is still null at that point). Bun's native check
does. The tests use a second write, which both runtimes reject with `6
byte(s)`.
- Found on the way and not changed here (#43520). Four templates in
`ErrorCode.cpp` differ from Node's own text: `ERR_HTTP_SOCKET_ASSIGNED`
(`Socket already assigned`, Node: `ServerResponse has an already
assigned socket`), `ERR_TLS_INVALID_PROTOCOL_VERSION` and
`ERR_TLS_PROTOCOL_VERSION_CONFLICT` (Node formats the values with `%j`,
so it prints `"TLSv9" is not a valid minimum TLS protocol version`), and
`ERR_IPC_CHANNEL_CLOSED` (`Channel closed.`, Node has no period). Those
are wrong templates, not call sites that miss a template. A script
compared the 118 constant and table messages with the literal templates
in Node's `lib/internal/errors.js`. 108 are identical, and these four
differ.
- Found on the way and not changed here (#43519). The server
`strictContentLength` check differs from Node in behavior:
`Content-Length: 0` is never checked, the first `res.write()` is checked
(Node checks from the second write on), and a string header is parsed
with `parseInt` where Node uses `+value`.
- Self-review, the four concerns I did not act on. (1) Fix the
`types.rs` arm of `ERR_INVALID_URL_SCHEME` here: a separate change fixes
the three arms together. (2) Add the `ERR_HTTP2_UNSUPPORTED_PROTOCOL`
row here: a separate change owns it. (3) Fix `ERR_HTTP_SOCKET_ASSIGNED`
and the `Content-Length` parsing here: they are a different class,
tracked in #43520 and #43519. (4) The REPL test passes a 20 s timeout,
and `test/CLAUDE.md` says not to set one: `node:repl` takes 5 to 9 s to
load on a debug build with ASAN, and `test/js/bun/repl/repl.test.ts`
uses the same value for the same reason.
- Earlier work: #35791 covered five of these codes and was closed as
stale with conflicts, not on its merits. #35777 covered two. This PR
follows the review threads of #35791: the server path, the REPL code,
and the dead arguments.
- Suites run on the debug build: `error-code-messages.test.ts`,
`test/js/node/url/url-fileurltopath*.test.*`,
`node-http-transfer-encoding.test.ts`, `node-http.test.ts`,
`test/js/node/fs/promises.test.js`, and the ported
`test-http-content-length-mismatch.js`,
`test-http-server-de-chunked-trailer.js`, `test-http-set-trailers.js`,
`test-url-fileurltopath.js`, `test-fs-whatwg-url.js`,
`test-fs-read-stream-file-handle.js`, `test-repl-sigint.js`,
`test-repl-sigint-nested-eval.js`, `test-worker-unsupported-path.js`. In
`node-http.test.ts`, `should propagate exception in sync data handler`
timed out once in the full run and passes alone in 3 s.

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants