Repository navigation
Conversation
…ror chunk then [DONE]; messages: error event; responses: response.failed), not a second HTTP response in the body or a silent end GpuBusy and EngineStuck no longer reach _post's handlers once a stream has started; EngineStarting and ModelBusy get the same branch, though no raise of them after the start was found. Any other exception gets the same frame with a plain sentence, and its traceback goes to the log. ClientGone and OSError keep their paths, and a stream that raises nothing is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W53iURzvDQMjnYemag8uvv
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.
Problem and result
When the engine keeps refusing a slot after a streamed request has got its 200, the server writes a second HTTP response into the SSE body. I reproduced it with the repo's fake engine process, nothing patched: the
BUSYALLprompt with 2 slots, the trigger oftest_bgen_refused_for_good_is_a_retryable_503_and_frees_the_slots.Before, the raw chat body ended like this (line endings shown as line breaks):
The messages and responses bodies ended with the same block. The openai and anthropic SDKs raised nothing, and the caller saw an empty turn.
Any other exception in a started stream ended the body with no last frame at all. I showed this only by injecting a RuntimeError into MockEngine.
After this change, both cases end with the frame the stream loops already use for other errors:
[DONE].errorevent,overloaded_errorfor GpuBusy andapi_errorfor the others.response.failed.The SDKs now see the failure. openai chat raises
APIError, anthropic raisesAPIStatusError, and the openai responses stream ends onresponse.failed.What the specs and this repo say:
openapi.yaml, chat streaming: "If a failure occurs after streaming has started, a data frame may contain a JSON object with anerrorfield instead of a completion chunk."_responsesdocstring in this repo: "once the stream has started they arrive as a response.failed event."Related discussion
No scope agreement was sought: this is a bug fix and no default changes.
answer_startedis false and re-raises otherwise, so a started stream still gets no frame.Approach
Only the stream loops of
_openai,_anthropicand_responseschange, plus three small helpers. The new except branches come after the existing ones. The_posthandlers, the non-streaming paths and errors before the stream are not touched, and a stream that raises nothing never reaches the new code.disconnected. Its state stayserror, because a server error did happen. The responses path keeps_responses_failed, which ignores that write failure.Validation
Revision: c62d5e3 plus this one commit. Linux, Python 3.12.3, openai 3.42.0, anthropic 2.3.0. The unit tests, the differential and the fake-engine SDK checks use MockEngine and the repo's fake engine process, with no GPU. The real-engine runs are marked as such below.
Passed:
New tests, with this change:
python -m unittest serve.test_stream_errors serve.test_parallel.ParallelService.test_bgen_refused_for_good_in_a_stream_is_each_apis_error_frameran 6 tests, OK.
The same command on c62d5e3, with
serve/test_stream_errors.pyandserve/test_parallel.pycopied in:FAILED (failures=10, errors=1), counting subtests. The error is one test calling a helper that does not exist there. The two tests that pass there are guards: a client that left gets no frame, and a clean stream ends as before.Test set:
python -m unittest serve.test_server serve.test_responses serve.test_anthropic_base_url serve.test_chat_tool_results serve.test_context_overflow serve.test_images serve.test_stream_errors serve.test_parallel serve.test_quiet_cancel serve.test_request_accounting serve.test_fatal_recovery serve.test_queue_keepaliveran 506 tests in 188 s:
FAILED (failures=1, skipped=1). The one failure is listed below.Differential against c62d5e3, a local script that is not in this PR. It sends 13 requests through MockEngine: 10 normal ones across the three APIs, stream and non-stream, and 3 streamed ones refused with a 400 before the stream. With ids, timestamps and timings normalized and Date, Server and Content-Length left out, status, headers and bodies were byte-identical.
SDK checks, local scripts that are not in this PR, on both trees:
BUSYALL, nothing patched. Before: no exception from any SDK. After:APIError,APIStatusErrorwithoverloaded_error, and eventsresponse.created,response.in_progress,response.failed.APIError,APIStatusErrorwithapi_error, and a stream ending onresponse.failed.Real engine, nothing patched. RTX 5080 16 GB, engine 0.1.55 from release v0.2.9 (the tarball's sha256 matches the release's
SHA256SUMS.txt), Qwen3.8-Flash-Next IQ3_S, context 65536. Before is c62d5e3, after is 6f201ab, on the same day.The trigger: the config sets
min_free_vram_mibto 15800 and another program holds 300 MiB of VRAM. Request A is running and request B is queued with its 200 already sent when the engine getskill -9. On B's turnensure_loadedfinds too little free VRAM and raises GpuBusy. Each API ran once with a raw client and once with its SDK, so 6 kills on each side:HTTP/1.0 503block[DONE], no HTTP blockAPIErrorwith the GPU messageHTTP/1.0 503blockerrorevent,overloaded_error, no HTTP blockAPIStatusError, typeoverloaded_errorHTTP/1.0 503blockresponse.failed, no HTTP blockresponse.in_progressresponse.failedwith codeserver_errorand the GPU messageThe Responses stream does not raise: the SDK hands
response.failedto the caller as an event.On both sides, all 6 times: A ended with its existing error frame, and a normal request right after reloaded the engine and returned 200 with finish reason
stop. With this change the server log has no traceback from these runs.Normal streams on both sides, before the kills and after them: the 3 SDKs and 3 raw clients, 12 streams per side. Chat ended on
stopand[DONE], Messages onmessage_stop, Responses onresponse.completed, and no raw body held an HTTP block.Real engine,
--parallel 2, after only: 488 streams with half or more dropped mid-body (details under Not run). Of the 182 streams read to the end, none held an HTTP block or a refusal message, and the 120 short chat ones ended on[DONE]. I did not check the last frame of the 62 long ones. 6 normal streams after each of the 2 runs ended properly, 12 in all.Failed:
serve.test_server.GpuChoice.test_hip_ordinal:AssertionError: '0,1' != '1,0'. It fails the same way on c62d5e3, so this change does not cause it.Not run:
BUSYALLresult above comes from the fake engine only. On 6f201ab with--parallel 2, I sent 240 short streams with half dropped, then 248 long streams on all three APIs with 186 dropped mid-generation. The log shows 0 refused slots. It takes more than 6 refusals for one request to reach this path. These runs were not repeated on c62d5e3, since nothing was refused to compare.Limits
--parallelof 2 or more and more than 6 refusals for one request. The real-engine one needs an engine crash with a request queued behind it, and too little free VRAM for the restart.ensure_loadedinsideService.runis confirmed by the real-engine runs. Unconfirmed static analysis: EngineStuck can come from there too.Retry-Aftercannot follow a 200, so that wait does not reach a streaming client. The slot refusal's message does say "try again in a moment".overloaded_errorinside a stream. Before the stream it is still a 503 with typeserver_error, as it was. This PR does not change that, so the two disagree.🤖 Generated with Claude Code
https://claude.ai/code/session_01W53iURzvDQMjnYemag8uvv