fix: Handle SSE stream reconnection in Python SDK (#6159)

## Summary
- add a public accessor for the last received SSE event id
- retry async and sync SSE streams using the Location reconnect path and
Last-Event-ID while skipping empty events
- add regression tests that simulate interrupted SSE streams for both
async and sync clients

## Testing
- make format
- make lint
- make test

------
https://chatgpt.com/codex/tasks/task_e_68ca8bfa26cc832d98bcb359884962ec
This commit is contained in:
Nuno Campos
2025-09-17 13:21:35 -04:00
committed by GitHub
parent 328129e5bd
commit 6f45f13952
3 changed files with 350 additions and 60 deletions
+176 -59
View File
@@ -455,36 +455,94 @@ class HttpClient:
if headers:
request_headers.update(headers)
async with self.client.stream(
method, path, headers=request_headers, content=content, params=params
) as res:
if on_response:
on_response(res)
# check status
try:
res.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await res.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
reconnect_headers = {
key: value
for key, value in request_headers.items()
if key.lower() not in {"content-length", "content-type"}
}
last_event_id: str | None = None
reconnect_path: str | None = None
reconnect_attempts = 0
max_reconnect_attempts = 5
while True:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
async with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
try:
res.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await res.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
reconnect_path = reconnect_location
# parse SSE
decoder = SSEDecoder()
try:
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=line.rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
# parse SSE
decoder = SSEDecoder()
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=line.rstrip(b"\n"))
if sse is not None:
yield sse
if sse := decoder.decode(b""):
yield sse
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# decoder.decode(b"") flushes the in-flight event and may
# return an empty placeholder when there is no pending
# message. Skip these no-op events so the stream doesn't
# emit a trailing blank item after reconnects.
yield sse
if retry:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
continue
break
async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
@@ -3642,41 +3700,100 @@ class SyncHttpClient:
on_response: Callable[[httpx.Response], None] | None = None,
) -> Iterator[StreamPart]:
"""Stream the results of a request using SSE."""
request_headers, content = _encode_json(json)
if json is not None:
request_headers, content = _encode_json(json)
else:
request_headers, content = {}, None
request_headers["Accept"] = "text/event-stream"
request_headers["Cache-Control"] = "no-store"
if headers:
request_headers.update(headers)
with self.client.stream(
method, path, headers=request_headers, content=content, params=params
) as res:
if on_response:
on_response(res)
# check status
try:
res.raise_for_status()
except httpx.HTTPStatusError as e:
body = (res.read()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
reconnect_headers = {
key: value
for key, value in request_headers.items()
if key.lower() not in {"content-length", "content-type"}
}
last_event_id: str | None = None
reconnect_path: str | None = None
reconnect_attempts = 0
max_reconnect_attempts = 5
while True:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
try:
res.raise_for_status()
except httpx.HTTPStatusError as e:
body = (res.read()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
reconnect_path = reconnect_location
decoder = SSEDecoder()
try:
for line in iter_lines_raw(res):
sse = decoder.decode(line.rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
# parse SSE
decoder = SSEDecoder()
for line in iter_lines_raw(res):
sse = decoder.decode(line.rstrip(b"\n"))
if sse is not None:
yield sse
if sse := decoder.decode(b""):
yield sse
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# See async stream implementation for rationale on
# skipping empty flush events.
yield sse
if retry:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
continue
break
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
+6
View File
@@ -81,6 +81,12 @@ class SSEDecoder:
self._last_event_id = ""
self._retry: int | None = None
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen."""
return self._last_event_id or None
def decode(self, line: bytes) -> StreamPart | None:
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
+168 -1
View File
@@ -1,4 +1,6 @@
from collections.abc import Iterator
from __future__ import annotations
from collections.abc import Iterator, Sequence
from pathlib import Path
import httpx
@@ -12,6 +14,35 @@ with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
RESPONSE_PAYLOAD = f.read()
class AsyncListByteStream(httpx.AsyncByteStream):
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
self._chunks = list(chunks)
self._exc = exc
async def __aiter__(self): # type: ignore[override]
for chunk in self._chunks:
yield chunk
if self._exc is not None:
raise self._exc
async def aclose(self) -> None:
return None
class ListByteStream(httpx.ByteStream):
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
self._chunks = list(chunks)
self._exc = exc
def __iter__(self): # type: ignore[override]
yield from self._chunks
if self._exc is not None:
raise self._exc
def close(self) -> None:
return None
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
decoder = BytesLineDecoder()
for part in payload:
@@ -61,6 +92,142 @@ async def test_http_client_stream_flushes_trailing_event():
assert parts == [StreamPart(event="foo", data={"bar": 1})]
def test_sync_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
b"id: 1\n",
b"event: values\n",
b'data: {"step": 1}\n\n',
]
second_chunks = [
b"id: 2\n",
b"event: values\n",
b'data: {"step": 2}\n\n',
b"event: end\n",
b"data: null\n\n",
]
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
assert request.method == "POST"
assert request.url.path == "/stream"
assert request.headers["accept"] == "text/event-stream"
assert request.headers["cache-control"] == "no-store"
assert "last-event-id" not in {
k.lower(): v for k, v in request.headers.items()
}
assert request.read()
return httpx.Response(
200,
headers={
"Content-Type": "text/event-stream",
"Location": reconnect_path,
},
stream=ListByteStream(
first_chunks,
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
if call_count == 2:
assert request.method == "GET"
assert request.url.path == reconnect_path
assert request.headers["Last-Event-ID"] == "1"
assert request.read() == b""
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
stream=ListByteStream(second_chunks),
)
raise AssertionError("unexpected request")
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
parts = list(http_client.stream("/stream", "POST", json={"payload": "value"}))
assert call_count == 2
assert parts == [
StreamPart(event="values", data={"step": 1}),
StreamPart(event="values", data={"step": 2}),
StreamPart(event="end", data=None),
]
@pytest.mark.asyncio
async def test_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
b"id: 1\n",
b"event: values\n",
b'data: {"step": 1}\n\n',
]
second_chunks = [
b"id: 2\n",
b"event: values\n",
b'data: {"step": 2}\n\n',
b"event: end\n",
b"data: null\n\n",
]
call_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
assert request.method == "POST"
assert request.url.path == "/stream"
assert request.headers["accept"] == "text/event-stream"
assert request.headers["cache-control"] == "no-store"
assert "last-event-id" not in {
k.lower(): v for k, v in request.headers.items()
}
assert await request.aread()
return httpx.Response(
200,
headers={
"Content-Type": "text/event-stream",
"Location": reconnect_path,
},
stream=AsyncListByteStream(
first_chunks,
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
if call_count == 2:
assert request.method == "GET"
assert request.url.path == reconnect_path
assert request.headers["Last-Event-ID"] == "1"
assert await request.aread() == b""
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
stream=AsyncListByteStream(second_chunks),
)
raise AssertionError("unexpected request")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
parts = [
part
async for part in http_client.stream(
"/stream", "POST", json={"payload": "value"}
)
]
assert call_count == 2
assert parts == [
StreamPart(event="values", data={"step": 1}),
StreamPart(event="values", data={"step": 2}),
StreamPart(event="end", data=None),
]
def test_sync_http_client_stream_flushes_trailing_event():
payload = b'event: foo\ndata: {"bar": 1}\n'