fix(start-server-core): return 499 when the client disconnects mid-request - #8134
fix(start-server-core): return 499 when the client disconnects mid-request#8134naoya7076 wants to merge 2 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe request resolver now returns HTTP 499 when the caught error matches the request signal’s abort reason. Tests cover SSR, middleware, route handlers, route work, and server functions. ChangesClient disconnect handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to Client disconnects will be reported as 499 instead of logged 500 responses, without changing the response seen by clients. No actionable merge-blocking risk remains beyond normal checks and review. Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
2dc9b0c to
6f47d9c
Compare
…quest When a client disconnects, the abort reason is re-thrown internally to unwind in-flight work and dispose abandoned results. Previously it then escaped the request boundary, so h3 flagged it as an unhandled error and logged a 500 — with no way for the app to opt out (executeMiddleware's terminal check discards any response produced after the abort). Catch the abort at the outermost request boundary and classify it as 499 Client Closed Request instead. The check uses identity comparison against request.signal.reason so unrelated AbortErrors thrown by app code are not swallowed. Fixes TanStack#7991
54ea3c7 to
d5b344a
Compare
|
Thanks for putting this together. We've been hitting this exact issue repeatedly in Playwright E2E: routine client disconnects produce a wall of |
Fixes #7991.
Problem
startRequestResolverincreateStartHandlerhas nocatcharound itstry/finally. When a client disconnects, the abort is re-thrown internally assignal.reasonso that in-flight work unwinds and gets disposed. That rejection then escapes the request boundary. h3 marks it as unhandled, logs it withconsole.error, and responds 500.The app has no way to opt out:
toResponse(value, h3Event)without a config, so h3'ssilent/onErroroptions never apply.executeMiddlewarere-throwssignal.reasonat the end when the signal is aborted.More background in my comment on the issue: #7991 (comment)
Change
Catch the abort at the outermost request boundary and return
499 Client Closed Request:throw signal.reasoncalls are untouched. They are still needed to drive cleanup. Only the final classification changes.request.signal.reasoninstead of matchingerr.name === 'AbortError', so unrelatedAbortErrors thrown by app code still propagate as real errors.@tanstack/start-server-coreis included.Why 499
Per RFC 9110 §15.6, 5xx means the server itself failed. A disconnect is the client withdrawing the request, so 500 misclassifies it. There is no standard code for this case because the response never reaches the client; it only matters for logs and middleware. nginx's non-standard 499 (
NGX_HTTP_CLIENT_CLOSED_REQUEST) became the de facto convention for it.The adjacent layers already handle it this way: h3's
proxy()returns 499 whenevent.req.signal.aborted(since 2.0.1-rc.23), and srvx's node adapter suppressesconsole.errorfor aborted requests. Start is currently the only layer in the stack that turns a disconnect into an unhandled 500.Behavioral change
Disconnects that previously produced a 500 and an error log now produce a 499 and no log. If you alert on 5xx rates, expect fewer entries. The client never sees either response, so nothing changes on the wire.
Tests
13 existing assertions in the
createStartHandler request cancellationsuite expectedstatus === 500afterrequestController.abort(...). They now expect 499. That is the whole extent of the behavior change. The side-effect assertions in those tests (cleanup runs once, streams disposed with the abort reason, render never called) are unchanged. The one 500 assertion that is not abort-related (middleware throwing a real error) is also unchanged.Three new tests assert that a disconnect returns
499 Client Closed Requeston each request path:To run locally:
test:unit(33 passed),test:types, andtest:eslintpass on the package.Verification
I applied this diff with
pnpm patchto@tanstack/start-server-core@1.169.26in a production Start app (server routes proxying Connect RPC to a backend), and compared the same screens with and without it:AbortErrorlogsRelated PRs
If you prefer a different status code, or want to settle the policy discussion in #7991 first, I'm happy to adjust.
Summary by CodeRabbit