fix(streaming): keep SSE stream alive when the agent is slow#9
Merged
Conversation
The heartbeat in stream_turn used asyncio.wait_for(aiter.__anext__(), timeout=_HEARTBEAT_INTERVAL). On timeout, wait_for cancels the in-flight pull, which propagates CancelledError into the upstream client generator, runs its finally (closing the botocore StreamingBody / httpx stream) and terminates it. The next __anext__() then raises StopAsyncIteration, so the stream ended after a single ': ping' and any event produced once the model unfroze was never delivered. Replace wait_for with a persistent pull task plus asyncio.wait, which returns on timeout WITHOUT cancelling the pull, so heartbeats are emitted while the same upstream read stays alive. Cancel the pending task before closing the generator to avoid 'async generator is already running' on mid-stream client disconnects. Adds a regression test that stalls the fake client past the heartbeat interval and asserts the post-stall SESSION_END is still delivered and persisted. Co-authored-by: Michal Galuszka <michal.galuszka1@gmail.com>
- Add Python 3.14 to the CI fast-tier test matrix (ubuntu + windows)
- Bump requires-python to >=3.11,<3.15
- Add Programming Language :: Python :: 3.14 classifier
- Replace Topic :: Software Development :: Libraries (wrong — this is a
web app, not a library) with accurate trove classifiers:
Framework :: FastAPI
Environment :: Web Environment
Topic :: Internet :: WWW/HTTP
Topic :: Internet :: WWW/HTTP :: Dynamic Content
Topic :: Internet :: WWW/HTTP :: WSGI :: Application
(WSGI :: Application is the closest official classifier to an ASGI app;
no dedicated ASGI :: Application classifier exists in trove-classifiers)
- Bump ruff target-version to py314
…e in heartbeat test - Add Python 3.14 to CI fast-tier matrix (ubuntu + windows) - Add Programming Language :: Python :: 3.14 classifier - Replace Topic :: Software Development :: Libraries with accurate web-app classifiers: Framework :: FastAPI, Environment :: Web Environment, Topic :: Internet :: WWW/HTTP, Topic :: Internet :: WWW/HTTP :: Dynamic Content, Topic :: Internet :: WWW/HTTP :: WSGI :: Application - No <3.15 upper bound on requires-python; ruff target-version stays py313 Per library-testing skill: replace asyncio.sleep delay in the slow-agent regression test with an asyncio.Event gate pair (blocked/gate). The fake signals 'blocked' the moment it parks on the gate; the unblock task waits for that signal before releasing, guaranteeing at least one heartbeat has fired. Clock-free and deterministic.
This is an ASGI app (FastAPI + Uvicorn). There is no official 'ASGI :: Application' trove classifier, so just drop the line.
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.
This pull request was created by @kiro-agent on behalf of @galuszkm 👻
Comment with /kiro fix to address specific feedback or /kiro all to address everything.
Learn about Kiro Web
Description
When an agent is slow to produce the next token (e.g. a Bedrock model that freezes for ~30s mid-turn), the SSE stream to the chat UI is dropped. The last thing the client receives is a single
: pingheartbeat, after which the connection ends and the response the model eventually produces is never delivered.Root cause (chat backend,
agents/streaming.py::stream_turn). The heartbeat was implemented as:On timeout,
asyncio.wait_forcancels the in-flight pull. ThatCancelledErrorpropagates into the upstream client generator (AgentCoreClient.invoke/AsyncLocalClient.invoke), runs itsfinallyblock — which closes the botocoreStreamingBody/ httpx stream — and terminates the generator. The next loop iteration callsaiter.__anext__()on the now-finished generator, getsStopAsyncIteration, andbreaks. Net effect: exactly one: pingis emitted and then the whole stream closes cleanly, so any event produced once the model "unfreezes" is lost. Thestrands-compose-agentcoreclients and ASGI app are not at fault — the bug is entirely in this repo's SSE relay.Fix. Pull the next upstream event with a persistent task and wait on it with
asyncio.wait, which returns on timeout without cancelling the task. Heartbeats are emitted while the same in-flight read stays alive, so a slow/frozen model no longer drops the stream. The pending task is cancelled in afinallybefore the generator is closed, otherwise a mid-stream client disconnect during a heartbeat would raiseRuntimeError: async generator is already running.Before:
['tokenA', ':ping', 'STOP'](stream dropped after one ping).After:
['tokenA', ':ping', ':ping', ':ping', 'tokenB', ':ping', 'SESSION_END'](heartbeats sent, upstream survives, real event delivered).Changes:
wait_forheartbeat loop instream_turnwith a persistent pull task +asyncio.wait, and cancel the pending task beforeaclose().delaysoption to the testFakeAgentClientso it can simulate a stalled model.SESSION_ENDis delivered, and the assistant turn is persisted.Related Issues
Type of Change
Testing
How have you tested the change?
uvx ruff format,uvx ruff check,uvx ty check) —justis not installed in this environment, so the underlying commands were run directly. All pass.uv run pytest -m "not postgres"(the fast test tier) — 255 passed, 7 deselected.test_streaming_slow_agent_sends_heartbeat_without_dropping_stream).examples/still work (not exercised for this backend-only change).Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.