Repository navigation
fix(client): split SSE lines only on CR, LF and CRLF - #1456
Conversation
parse_sse_stream read the stream with httpx's aiter_lines(), which follows str.splitlines() and also ends a line at U+2028, U+2029 and U+0085. The server writes those characters raw inside a data: line, so the client cut the event short and the JSON-RPC payload failed to parse. Lines now come from aiter_text() and end only at CRLF, LF or CR, as the SSE specification requires. A CR at the end of a chunk is held back so a CRLF split across chunks still ends a single line. Transport tests that mocked aiter_lines() now mock aiter_text(). Fixes a2aproject#1432 Co-Authored-By: Claude Opus 5.5 <[email protected]>
🧪 Code Coverage (vs
|
| Base | PR | Delta | |
|---|---|---|---|
| src/a2a/client/transports/http_helpers.py | 92.13% | 93.51% | 🟢 +1.38% |
| Total | 93.60% | 93.62% | 🟢 +0.02% |
Generated by coverage-comment.yml
1AbdullahButt
left a comment
There was a problem hiding this comment.
The splitting is correct. One cost issue, though, on long data: lines.
What I checked in _aiter_sse_lines (http_helpers.py:72-87).
- A CRLF split across chunks (
"x\r"+"\ny") gives one linex. - A lone CR followed by non-LF text still ends the line.
"a\r\r"+"\n"givesaand then an empty line.- A stream ending in
"...\n\r"yields a final"", so the last event is dispatched. That's right, since the CR terminates the empty line. - An unterminated last line is yielded but never dispatched, because
parse_sse_streamonly dispatches on an empty line. aiter_text()uses the same decoder asaiter_lines()did, so charset handling doesn't change.
Long lines are re-copied and re-scanned on every chunk. text = pending + chunk followed by _SSE_LINE_END.split(text) means a line that spans N chunks costs about N²/2 chunk-lengths of copying and regex scanning. A2A messages put whole file parts (base64 bytes) into a single data: line. So a 20 MB artifact read in 64 KB chunks is around 300 chunks, and about 3 GB of copying for that one line. httpx's own LineDecoder, which aiter_lines() used, avoids this by buffering the pieces in a list and joining them once (httpx _decoders.py, LineDecoder.decode). The same shape works here, and it only scans the new chunk:
async def _aiter_sse_lines(response: httpx.Response) -> AsyncGenerator[str, None]:
buffer: list[str] = []
held_cr = False
async for chunk in response.aiter_text():
if held_cr:
chunk, held_cr = '\r' + chunk, False
if chunk.endswith('\r'):
chunk, held_cr = chunk[:-1], True
*lines, rest = _SSE_LINE_END.split(chunk)
if lines:
lines[0] = ''.join(buffer) + lines[0]
buffer = []
for line in lines:
yield line
if rest:
buffer.append(rest)
tail = ''.join(buffer)
if held_cr or tail:
yield tailI checked this version with a standalone script of my own (not repo code). It ran 20,000 random strings over a b \r \n U+2028 : space, cut at random chunk boundaries. Its output matched re.split(r'\r\n|\r|\n') on the joined text (terminated lines, plus a non-empty remainder) every time. A test with one multi-megabyte data: line over many small chunks would guard against the slowdown coming back.
Other notes:
- #1259 (leading BOM) also edits the
aiter_lines()loop inparse_sse_stream, andgit merge-treeshows the two PRs conflict inhttp_helpers.py. Whichever lands second will need to move theremoveprefix('')onto the first line from_aiter_sse_lines. git merge-treeagainstmainis clean.
I didn't run the repo's tests.
Reviewed with help from Claude Code.
_aiter_sse_lines prepended the unfinished line to every new chunk and re-ran the line-ending regex over the whole thing. A data: line that spans N chunks therefore cost about N^2/2 chunk-lengths of copying and scanning. A2A puts whole file parts into one data: line, so a large artifact read in small chunks got very slow. The unfinished line is now kept as a list of pieces and joined once when its line ending arrives, and only the new chunk is scanned, as httpx's own LineDecoder does. A trailing CR is still held back so a CRLF split across chunks ends a single line. Added a test that reads a 2 MB data: line in 512-byte chunks. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks, good catch on the long-line cost. Done in afb63ba:
Verified with Noted on #1259: whichever lands second will need to move the BOM strip onto the first line from Fix and reply prepared with Claude Code, on Pranav's behalf. |
The long data: line test timed itself with perf_counter and asserted under 5 seconds. It now uses @pytest.mark.timeout(10), as the reviewer asked, which the repo already depends on via pytest-timeout. Also added a comment on why _aiter_sse_lines still yields an empty tail when the stream ends on a CR: that CR ends a blank line, and the blank line is what dispatches the last event. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks @Iwaniukooo11, both done in 16be92b:
Verified with Fix and reply prepared with Claude Code, on Pranav's behalf. |
Resolve the conflict in test_rest_client.py and switch the new stream error tests to mock aiter_text, since a2aproject#1456 changed the SSE reader from aiter_lines to aiter_text.
Description
parse_sse_streamread the stream withresponse.aiter_lines(), which followsstr.splitlines()and also ends a line at U+2028, U+2029 and U+0085. The server writes these characters raw inside onedata:line, so the client cut the event short and the payload failed to parse.As suggested in #1432, the fix is in
parse_sse_stream. Lines now come fromaiter_text()and end only at CRLF, LF or CR, as the SSE specification requires. A CR at the end of a chunk is held back, so a CRLF split across two chunks still ends one line.Tests:
test_parse_sse_stream_ends_lines_only_at_cr_or_lffeeds realhttpx.Responsebyte chunks through the parser:data:aiter_lines()now mockaiter_text()../scripts/lint.shanduv run pytestpass. Coverage onhttp_helpers.pyis 93%.CONTRIBUTINGGuide.bash scripts/format.shfrom the repository root to format)Fixes #1432 🦕