Skip to content

fix(client): split SSE lines only on CR, LF and CRLF - #1456

Merged
Iwaniukooo11 merged 4 commits into
a2aproject:mainfrom
vudigapranav:fix/sse-line-endings
Nov 13, 2026
Merged

Iwaniukooo11 merged 4 commits into
a2aproject:mainfrom
vudigapranav:fix/sse-line-endings

Conversation

@vudigapranav

Copy link
Copy Markdown
Contributor

Description

parse_sse_stream read the stream with response.aiter_lines(), which follows str.splitlines() and also ends a line at U+2028, U+2029 and U+0085. The server writes these characters raw inside one data: 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 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 two chunks still ends one line.

Tests:

  • test_parse_sse_stream_ends_lines_only_at_cr_or_lf feeds real httpx.Response byte chunks through the parser:
    • the three separators inside data:
    • a CRLF and a U+2028 byte sequence each split across chunks
    • an event with no closing blank line, which is still not dispatched
  • Transport tests that mocked aiter_lines() now mock aiter_text().

./scripts/lint.sh and uv run pytest pass. Coverage on http_helpers.py is 93%.

Fixes #1432 🦕

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]>
@vudigapranav
vudigapranav requested a review from a team as a code owner November 12, 2026 15:51
Copilot AI balanced review requested due to automatic review settings November 12, 2026 15:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

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 1AbdullahButt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 line x.
  • A lone CR followed by non-LF text still ends the line.
  • "a\r\r" + "\n" gives a and 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_stream only dispatches on an empty line.
  • aiter_text() uses the same decoder as aiter_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 tail

I 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 in parse_sse_stream, and git merge-tree shows the two PRs conflict in http_helpers.py. Whichever lands second will need to move the removeprefix('') onto the first line from _aiter_sse_lines.
  • git merge-tree against main is 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]>
@vudigapranav

Copy link
Copy Markdown
Contributor Author

Thanks, good catch on the long-line cost. Done in afb63ba:

  • _aiter_sse_lines now keeps the unfinished line as a list of pieces and joins it once when its line ending arrives, and only scans the new chunk (same shape as httpx's LineDecoder and your sketch). A trailing CR is still held back for a CRLF split across chunks.
  • Added test_parse_sse_stream_long_data_line_over_many_chunks: a 2 MB data: line in 512-byte chunks.

Verified with ./scripts/lint.sh and uv run pytest (2572 passed). I also ran 20,000 random strings over a b \r \n U+2028 : space, cut at random chunk boundaries, through both the old and new splitter: identical output every time. On a 1 MB line in 512-byte chunks the old version took 6.1 s and the new one 0.01 s.

Noted on #1259: whichever lands second will need to move the BOM strip onto the first line from _aiter_sse_lines.

Fix and reply prepared with Claude Code, on Pranav's behalf.

@Iwaniukooo11 Iwaniukooo11 self-assigned this Nov 13, 2026
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]>
@vudigapranav

Copy link
Copy Markdown
Contributor Author

Thanks @Iwaniukooo11, both done in 16be92b:

  • _aiter_sse_lines: added a comment above the final yield explaining that a CR held at end of stream still ends a line, even an empty one, and that blank line is what dispatches the last event.
  • test_parse_sse_stream_long_data_line_over_many_chunks: dropped the perf_counter timing and the elapsed < 5 assert in favour of @pytest.mark.timeout(10).

Verified with ./scripts/lint.sh and uv run pytest (2572 passed, 207 skipped).

Fix and reply prepared with Claude Code, on Pranav's behalf.

@Iwaniukooo11 Iwaniukooo11 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGMT

@Iwaniukooo11
Iwaniukooo11 merged commit c8ccea9 into a2aproject:main Nov 13, 2026
17 checks passed
mykytanetipa added a commit to coder-abu/a2a-python that referenced this pull request Nov 13, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: SSE client splits data lines on U+2028, U+2029 and U+0085, so streamed events fail to parse

4 participants