Fix: forward async route handler rejections in the dev API server - #719
Open
AmaadMartin wants to merge 3 commits into
Open
Fix: forward async route handler rejections in the dev API server#719AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 6, 2026 05:31
Express 4 discards the promise an async route handler returns, so a
rejection outside the handler's own try/catch left the request hanging
and raised a process-level unhandledRejection, which kills the dev
server under node's default mode.
Wrap all 15 async handlers in asyncHandler, replace the floating async
'end' listener on /api/reasoning_engine with an awaited readRawBody,
move the app.listen callback body onto a private method, and register a
terminal error middleware that answers with the {error} JSON shape the
routes already use.
Cover asyncHandler, readRawBody, errorStatus and errorHandler in a new express_utils test that drives a real express app, and add a server describe proving /run, /run_sse and the Reasoning Engine raw-body path answer 500 without leaking an unhandled rejection.
Replace the hand-rolled readRawBody with text() from node:stream/consumers, which node has shipped since 16.7 and which handles the same three edge cases the helper did. Move the A2A mount and the startup banner out of the app.listen callback into start()'s own control flow, so the callback stays void-returning without needing a separate private method.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A
Problem:
dev/src/server/adk_api_server.tsregisters 15asyncexpress route handlers. Express 4 discards the promise a handler returns, so a rejection raised outside the handler's owntry/catchreaches nobody./run,/run_sseand/api/reasoning_engineall have such a gap, and the raw-body branch of/api/reasoning_enginealso passes anasynclistener toreq.on('end'). The request then hangs forever and node raisesunhandledRejection, which kills the dev server and every in-memory session with it.Solution: I added
dev/src/utils/express_utils.tswith anasyncHandlerwrapper that forwards a rejection tonextand a terminal error middleware, and replaced theendlistener withtext()fromnode:stream/consumers. Wrapping all 15 handlers, not just the three broken ones, keeps the file consistent and leaves the follow-up type-aware lint change with nothing to mop up. Express 5 awaits handler promises itself, so this also makes that upgrade a no-op instead of a behaviour change.Notes for the reviewer:
dev/src/server/adk_api_server.ts. None wraps a handler, adds an error middleware, or removes theendlistener. Fix: reject a non-object Reasoning Engine raw body instead of crashing the dev server #621 overlaps: it adds a null/primitive guard to the same raw-body branch. That is validation, a separate concern, so I did not stack on it and I did not add the guard. My test forces the rejection with an injected logger instead of anullbody, so it keeps working whichever way Fix: reject a non-object Reasoning Engine raw body instead of crashing the dev server #621 lands. Whoever merges second resolves a small conflict in that branch.500. Body-parser attachesstatus: 400to a malformed body and413to an oversized one, and a flat500would downgrade both.errorStatuskeeps a status in the 4xx/5xx range and falls back to500, which preserves the spec's "outputs unchanged" invariant.adk_api_server.tsare prettier re-indenting four handler bodies that moved one level deeper. The reviewable content is the new util, the raw-body rewrite,start()and the tests.tryon its first line. That is a property of every handler's current body, not of the route table, and it is exactly the property/runand/run_sselost when someone hoisted agetSessioncall above thetry. Applying the adapter uniformly makes the invariant structural instead of per-handler, and it is what keepsno-misused-promisesat zero for this file when the queued lint change turns the rule on.initA2A()mounts the A2A routes afterinit()has run. Express only searches for an error handler in layers registered after the failing one, so this middleware does not cover the A2A surface. MovinginitA2A()is out of scope.eslint.config.js. The type-aware block the follow-up task needs does not exist onmainyet.Testing Plan
Unit Tests:
New
dev/test/utils/express_utils_test.ts(12 cases) drives a throwaway express app on a real socket, so no part of express is mocked. NewAsync handler failuresblock indev/test/server/adk_api_server_test.ts(3 cases) plus 2 cases covering lines that moved. All 51 pre-existing cases in that file are unmodified and still pass.Coverage of the new module is 100% statements, branches, functions and lines. Every line the diff adds to
adk_api_server.tsis covered. Full gate on the pushed commit:npm run build,npm run lint,npm run format:check,npm run docs:checkall pass. Oneunit:devcase fails,createAgent > Interactive Mode > should handle Vertex AI selection with gcloud defaults; it fails identically on unmodifiedmainand is unrelated to this change.Proving the tests can fail. Each new test was run against mutated source.
asyncHandlerfrom/run_sseTest timed out in 5000msasyncHandlerfrom/runTest timed out in 5000msreq.on('end', async ...)listenerTest timed out in 5000msonListeningrejection handlerTest timed out in 5000msexpected 500 to be 400.catch(next)fromasyncHandlerTest timed out in 5000msasyncHandlerexpected Promise{…} to be undefinedheadersSentbranchexpected Error: Cannot set headers after they are sent to be Error: too lateerrorStatusexpected 500 to be 400errorlistenerTest timed out in 5000msreadRawBodyper chunkexpected '\ufffd\ufffd' to be 'é'The hang is the expected failure mode: express never answers, so
fetchnever resolves.Complexity review. I took two of the three findings.
readRawBodyis gone:text()fromnode:stream/consumersdoes the same job, and I confirmed on node 22 that it matches the helper on all three edge cases the deleted tests pinned. TheonListening()method is gone too, but I did not restore theasynclistencallback, because that callback is one of the 17no-misused-promisessites this change exists to clear; instead the A2A mount and the banner moved intostart()'s own control flow, which removes a nesting level and thetry/rejectbridge. I did not drop the 12 no-op handler wraps, for the reason given above.Empirical lint proof. The two rules at issue are not enabled on
main, so I appended the type-aware block toeslint.config.jstemporarily and rannpx eslint "dev/src/**/*.ts".adk_api_server.tsgoes from 17no-misused-promisesfindings to 0, and there are nono-floating-promisesfindings in it or in the new util. The remaining findings are in other files and belong to the follow-up task:agent_loader.ts(5no-misused-promises),cli_run.ts(1),cli.ts(1no-floating-promises). I revertedeslint.config.jsbefore committing; it is not in the diff.Manual End-to-End (E2E) Tests:
Before this change the server logs
Received Reasoning Engine raw body: nulland exits.curlreports exit 52 (empty reply), and/healththen fails with exit 7 (connection refused). After this change the same request answersHTTP 500with{"error":"Failed to handle POST /api/reasoning_engine: TypeError: Cannot read properties of null (reading 'input')"}, and/healthstill answers200 OK. I also confirmed/run_ssewith an unknown session still answers404 {"error":"Session not found: nope"}, and that a malformed body on/runanswers400rather than express's HTML page.Checklist