[Bugfix] Return the client-disconnect error instead of discarding it in completions_v1 - #4811
[Bugfix] Return the client-disconnect error instead of discarding it in completions_v1#4811ErenAta16 wants to merge 3 commits into
Conversation
generate() and completions_v1() both moved their streaming loop into a nested _inner_call, which returns create_error_response(...) from inside an async with when the client disconnects. Neither call site used that return value. generate() therefore returned the still-None 'response', which FastAPI serialises as 200 with a null body. completions_v1() dropped the errors into a gather result list; the disconnected index also never reaches its 'assert final_res is not None', so the response was assembled from the choices that did finish. Take the return value in both places.
|
Closing this in favour of #4782, which fixes the same issue #4781, was opened seven days earlier, and ships a regression test that drives a real disconnect through a fake engine. I should have found it before opening this one; that is a preflight failure on my side, not a judgement call. The one thing this PR had that #4782 does not is the |
InternLM#4782 fixes the `generate` call site and ships the regression test for it. Its author asked me to keep the `completions_v1` half here rather than fold it in, so this drops the `generate` hunk and leaves only the batched path. `completions_v1` runs its `_inner_call` under `asyncio.gather`, which collects each coroutine's return value into a list that is then discarded. On a mid-batch client disconnect the error response for that entry goes with it, so the request either returns a response assembled from the surviving entries or trips the `assert final_res is not None` above, depending on where in the batch the disconnect lands. Unlike `generate`, the check has to scan the collected results rather than test a single return value.
|
Reopened and narrowed to What is left is the batched path: inner_results = await asyncio.gather(
*[_inner_call(i, generators[i], sessions[i]) for i in range(len(generators))])
for inner_result in inner_results:
if inner_result is not None:
return inner_result
The check has to scan the list rather than test a single value, which is the one structural difference from your fix in #4782. Thanks for the offer to review. I have not added a test here, since the natural place for one is the harness you built in #4782 and duplicating the fake engine setup would be worse than extending it once yours lands. Happy to add a |
|
This PR conflicts with our ongoing api_server refactoring (#4797). Since that refactoring is currently a priority for the project and also already covers the same issue you addressed here, we've decided to close this PR for now to avoid duplication and merge conflicts. |
Fixes #4781.
Motivation
generate()andcompletions_v1()both moved their streaming loop into a nested_inner_call, which signals client disconnect by returningcreate_error_response(HTTPStatus.BAD_REQUEST, 'Client disconnected')from inside anasync with. Neither call site used that return value.generate()— the disconnect branch returns from inside theasync with, so theresponse = GenerateReqOutput(...)assignment below it is never reached, andawait _inner_call()throws the error response away. The endpoint returns the still-Noneresponse, which FastAPI serialises as200with anullbody. Running the pattern in isolation:completions_v1()— same shape, reached throughasyncio.gather, which collects the return values into a list that was discarded. This one degrades more quietly: after the early return,final_resstaysNonefor the disconnected index, soassert final_res is not Nonenever runs for it and the response is built from the choices that did finish.For contrast,
/v1/chat/completionsis correct and shows the intended shape: itsasync withandreturn create_error_response(...)sit directly in the endpoint body, so the return leaves the endpoint.Modification
Take the return value at both call sites.
generate()returns it when it isn'tNone;completions_v1()scans thegatherresults and returns the first non-Noneentry. The normal path is untouched in both — when no client disconnects every_inner_callreturnsNoneand the existingresponseis built and returned as before.Checklist
ruff checkreports oneI001on this file, and reports the same on an unmodified checkout, so it is pre-existing;ruff format --checklikewise wants to reformat the file on both. Neither is touched here. The only line over the 120 limit (line 320) is also pre-existing.tests/for the disconnect branch of these endpoints. The numbers above come from running the control-flow pattern rather than the server, so I would rather say so than add a test I could not run. Happy to add one if you can point me at the right fixture.