node:http: send 500 instead of 200 when handler throws synchronously#30075
node:http: send 500 instead of 200 when handler throws synchronously#30075robobun wants to merge 2 commits into
Conversation
WalkthroughA bug fix inverts the condition that guards writing a "500 Internal Server Error" status when a JS exception occurs during HTTP request handling, ensuring the status is written only when no prior status has been set. New tests validate the corrected behavior for both synchronous throws before and after ChangesHTTP Error Status Handling
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Review rate limit: 3/5 reviews remaining, refill in 22 minutes and 20 seconds. Comment |
|
Updated 3:07 PM PT - May 4th, 2026
❌ @robobun, your commit 3c8651d has 2 failures in
🧪 To try this PR locally: bunx bun-pr 30075That installs a local version of the PR into your bun-30075 --bun |
There was a problem hiding this comment.
LGTM — one-character condition inversion that brings the sync-throw cleanup path in line with the already-correct async rejection handler, with tests covering both branches.
Extended reasoning...
Overview
This PR is a single-character logic fix in src/bun.js/api/server.zig (line 2147): inverting if (raw_response.state().isHttpStatusCalled()) to if (!raw_response.state().isHttpStatusCalled()) in the node:http handler exception/rejection cleanup path. It also adds a new test file with two tests covering both arms of the branch (throw before any status → 500, throw after writeHead(201) → 201 preserved + stream terminated).
Correctness
The fix is verifiably correct by inspection. writeStatus("500 Internal Server Error") only makes sense when no status line has been written yet, i.e. when isHttpStatusCalled() is false. The parallel async-rejection handler at src/bun.js/api/server/NodeHTTPResponse.zig:680 already uses !isHttpStatusCalled() to guard the same writeStatus call, confirming the original condition here was simply inverted. The previous behavior — endStream(true) falling through to uWS's default HTTP_200_OK when the handler threw — was clearly a bug.
Security risks
None. This changes only the HTTP status code emitted on an error path from an incorrect 200 to a correct 500. No auth, crypto, input parsing, or memory-safety surface is touched.
Level of scrutiny
Low. This is a mechanical one-token bug fix with a clear root-cause analysis, a sibling code path that already does it correctly, and regression tests that exercise both the fixed branch and the unchanged branch. No design decisions are involved.
Other factors
No CODEOWNERS entry covers this path. No outstanding human reviewer comments. The bug-hunting system found no issues. The new tests use raw TCP sockets to assert the exact status line, which is robust against client-side normalization.
|
CI status — all failures are pre-existing/infra, unrelated to this one-line node:http change. Build 51204 — ongoing CI agent outage: 21 jobs expired, 106 waiting_failed. Actual test failures:
|
The cleanup path in onNodeHTTPRequestWithUpgradeCtx writes a 500 status when a node:http request handler throws or rejects before completing the response. The isHttpStatusCalled() check guarding that write was inverted, so the 500 branch only ran when a status had already been sent (a no-op), and the no-status case fell through to endStream() which defaults to "HTTP/1.1 200 OK". Invert the condition to match the equivalent logic in NodeHTTPResponse.Bun__NodeHTTPRequest__onReject: write 500 only when no status has been sent yet; otherwise terminate the already-started chunked stream.
27d1059 to
1ac5f0a
Compare
There was a problem hiding this comment.
LGTM — one-character logic inversion that matches the existing async-rejection path, with tests covering both branches.
Extended reasoning...
Overview
This PR flips a single boolean condition in src/runtime/server/server.zig (onNodeHTTPRequestWithUpgradeCtx's exception/rejection cleanup) from if (raw_response.state().isHttpStatusCalled()) to if (!raw_response.state().isHttpStatusCalled()). The original condition was clearly inverted: it tried to writeStatus("500 Internal Server Error") only when a status had already been written (a no-op), and otherwise fell through to endStream(true) which defaults to 200 OK. The fix is accompanied by a new test file with two cases: handler throws before any status → expect 500, and handler throws after writeHead(201) + flushHeaders() → expect 201 preserved with the stream cleanly terminated.
Security risks
None. This only affects which HTTP status code is emitted on the error path when a node:http handler throws synchronously. No new data is exposed; if anything, returning 500 instead of a misleading 200 is the safer/more correct behavior.
Level of scrutiny
Low. The change is a single ! and the corrected logic is self-evidently right ("write a 500 status only if no status has been written yet"). I cross-checked the parallel async path Bun__NodeHTTPRequest__onReject in src/runtime/server/NodeHTTPResponse.zig:680, which already uses !isHttpStatusCalled() for the identical purpose — this PR brings the sync path into agreement with it.
Other factors
- The new test exercises both arms of the corrected branch via raw TCP so it asserts the actual wire status line, and CI confirms it passes on all 15+ platforms that ran it.
- No bugs were flagged by the bug-hunting system.
- No outstanding human review comments; CI failures listed by robobun are documented pre-existing flakes unrelated to this change.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/js/node/http/node-http-sync-throw-500.test.ts`:
- Around line 25-35: In the server.listen callback, remove the explicit
process.exit(0) call and instead await server.close() so the server fully closes
before the script naturally exits; update the callback that currently calls
server.close(); process.exit(0) to use await server.close() (or return after
awaiting server.close()) in both embedded scripts so stdout/stderr flush
reliably and the process terminates naturally.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 817439d9-2c2a-460c-b49a-882a91519132
📒 Files selected for processing (2)
src/runtime/server/server.zigtest/js/node/http/node-http-sync-throw-500.test.ts
There was a problem hiding this comment.
LGTM — one-character condition inversion that matches the existing async-rejection handler, with tests covering both branches.
Extended reasoning...
Overview
This PR fixes a single inverted boolean condition in src/runtime/server/server.zig (onNodeHTTPRequestWithUpgradeCtx's exception/rejection cleanup path). The previous code wrote 500 Internal Server Error only when isHttpStatusCalled() was true — i.e., when a status had already been sent and writeStatus is a no-op — and otherwise fell through to endStream(true), which defaults to 200 OK. The fix adds a ! so the 500 is written only when no status line has been sent yet, and the in-flight response is terminated via endStream otherwise.
I verified the PR's claim that the parallel async-rejection handler Bun__NodeHTTPRequest__onReject in src/runtime/server/NodeHTTPResponse.zig:680 already uses !isHttpStatusCalled() with the same write-500 / end-stream split. This change simply brings the sync path to parity with that established pattern.
A new test file exercises both branches: (1) handler throws before any write → expects HTTP/1.1 500 Internal Server Error, and (2) handler throws after writeHead(201) + flushHeaders() → expects the 201 status and header to be preserved while the connection still terminates cleanly. CI confirms the new test passes on all shards; reported failures are pre-existing flakes unrelated to node:http.
Security risks
None. The change only affects which HTTP status code is emitted on the error path of a node:http handler that throws. No new data is exposed, no auth/permission logic is touched, and the error message body remains empty (endWithoutBody).
Level of scrutiny
Low. This is a one-token logic fix whose correctness is self-evident from the semantics of writeStatus (only meaningful before a status has been sent) and is corroborated by an identical existing code path elsewhere in the codebase. The blast radius is limited to the already-broken error-handling branch.
Other factors
- No CODEOWNERS entry covers this file.
- No outstanding human review comments; only bot/CI status comments on the timeline.
- The bug-hunting system found no issues.
- Both code branches are now covered by regression tests that read the raw status line over a TCP socket, so the behavior is pinned.
Reproduction
Root cause
When a
node:httprequest handler throws (or its returned promise rejects) before the response has completed,onNodeHTTPRequestWithUpgradeCtxinsrc/bun.js/api/server.zigcleans up by writing a status and ending the response. The branch that writes"500 Internal Server Error"was guarded byraw_response.state().isHttpStatusCalled()— i.e. it only ran when a status line had already been written, which makeswriteStatusa no-op. When no status had been written, control fell into theelsearm and calledendStream(true), which bottoms out in uWSsendTerminatingChunk()→writeStatus(HTTP_200_OK).The condition is simply inverted; the equivalent rejection handler in
NodeHTTPResponse.Bun__NodeHTTPRequest__onRejectuses!isHttpStatusCalled()correctly.Fix
Invert the condition so that:
writeStatus("500 Internal Server Error")+endWithoutBody(true)endStream(true)to terminate the in-flight chunked responseVerification
New test
test/js/node/http/node-http-sync-throw-500.test.ts:USE_SYSTEM_BUN=1):writeHead(201)case still preserves the 201 status while cleanly terminating the stream.The existing
node-http.test.tssuite passes (the singlerequest via http proxyfailure is pre-existing ECONNREFUSED flake, reproduces without this change).