Skip to content

generate_content_stream fails with UnknownApiResponseError when a streamed text part contains U+2028 / U+2029 / U+0085 (httpx transport) #3243

Open

Description

@jaeminbird

Environment details

  • Programming language: Python
  • OS: Windows 11 (transport-level, not OS-specific)
  • Language runtime version: 3.14
  • Package version: google-genai 2.33.0 (code unchanged on main)

Symptom

If a streamed chunk's JSON contains a raw U+2028 (LINE SEPARATOR), U+2029 or U+0085 (NEL) inside a string, models.generate_content_stream (sync) and aio.models.generate_content_stream with an httpx client raise:

UnknownApiResponseError: Failed to parse response as JSON. Raw response: {"candidates": [{"content": {"role": "model", "parts": [{"text": "line one

The same bytes parse correctly on the aiohttp async path.

Steps to reproduce

Self-contained script with a local SSE server, no API key (below):

sync  (httpx)   ERR UnknownApiResponseError ... "text": "line one
async (httpx)   ERR UnknownApiResponseError ... "text": "line one
async (aiohttp) ['line one
line two', ' done']

The body is valid JSON (RFC 8259 doesn't require escaping these code points), one data: line per event.

repro_genai_linesep.py
"""google-genai: streamed text containing U+2028 / U+2029 / U+0085 breaks SSE parsing (httpx path)."""
import asyncio, json, sys, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import httpx
from google import genai

TEXT = sys.argv[1].encode().decode("unicode_escape") if len(sys.argv) > 1 else "line one\u2028line two"
CHUNKS = [
    {"candidates": [{"content": {"role": "model", "parts": [{"text": TEXT}]}}]},
    {"candidates": [{"content": {"role": "model", "parts": [{"text": " done"}]}, "finishReason": "STOP"}]},
]
# Valid JSON (RFC 8259 does not require escaping these code points); one event per line.
BODY = b"".join(b"data: " + json.dumps(c, ensure_ascii=False).encode() + b"\r\n\r\n" for c in CHUNKS)

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        self.rfile.read(int(self.headers["Content-Length"]))
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Content-Length", str(len(BODY)))
        self.end_headers()
        self.wfile.write(BODY)
    def log_message(self, *a): pass

srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"
print(f"genai {genai.__version__}, text={TEXT!r}")

def run_sync():
    c = genai.Client(api_key="test", http_options={"base_url": base})
    return [ch.text for ch in c.models.generate_content_stream(model="gemini-2.5-flash", contents="hi")]

async def run_async(httpx_client):
    opts = {"base_url": base}
    if httpx_client:
        opts["httpx_async_client"] = httpx.AsyncClient()
    c = genai.Client(api_key="test", http_options=opts)
    return [ch.text async for ch in await c.aio.models.generate_content_stream(model="gemini-2.5-flash", contents="hi")]

for name, fn in [("sync  (httpx)  ", run_sync),
                 ("async (httpx)  ", lambda: asyncio.run(run_async(True))),
                 ("async (aiohttp)", lambda: asyncio.run(run_async(False)))]:
    try:
        print(name, fn())
    except Exception as e:
        print(name, "ERR", type(e).__name__, str(e)[:160].replace("\n", " "))

Root cause

google/genai/_api_client.py:488 (iter_lines()) and :545 (aiter_lines()) use httpx's line decoder, which follows str.splitlines() and also breaks on U+2028/U+2029/U+0085 (and �, , �-�). The SSE spec treats only CRLF, LF and CR as line terminators. The aiohttp branch (:587, readline() on bytes) gets this right.

Suggested fix

Split the byte stream on `

/
/
only (e.g. iterateiter_bytes()/aiter_bytes()` through a small line splitter, as the aiohttp branch effectively does), then decode each line.

Activity

  1. added a commit that references this issue on Oct 29, 2026
    69516b1
  2. added
    priority: p2Moderately-important priority. Fix may not be included in next release.
    type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.
    on Oct 29, 2026
  3. Venkaiahbabuneelam commented on Oct 30, 2026

    @Venkaiahbabuneelam

    Hi @jaeminbird,

    The issue occurs because httpx.Response.iter_lines() and aiter_lines() rely on Python's str.splitlines(), which splits on Unicode line boundary code points such as U+2028, U+2029, and U+0085, breaking the SSE event payload before JSON parsing.

    As a workaround for async workflows, utilizing the default aiohttp transport avoids this issue because it processes line delimiters directly on the byte stream:

    import asyncio
    from google import genai
    
    async def main():
        # Omitting httpx_async_client uses aiohttp for streaming
        client = genai.Client(api_key="your_api_key")
        response_stream = await client.aio.models.generate_content_stream(
            model="gemini-2.5-flash",
            contents="hi",
        )
        async for chunk in response_stream:
            print(chunk.text)
    
    asyncio.run(main())

    Thanks

  4. added 6 commits that reference this issue on Nov 5, 2026
    a0606f8
    d179404
    57dfa4b
    28dfddf
    471d94b
    e6da785
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

priority: p2Moderately-important priority. Fix may not be included in next release.status:awaiting user responsetype: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions