mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
fix: Ensure SSE streams flush trailing events (#6155)
## Summary - ensure both async and sync HTTP clients flush the SSE decoder after streaming - add regression tests covering trailing SSE events without a terminating blank line ## Testing - make format - make lint - make test ------ https://chatgpt.com/codex/tasks/task_e_68c9727ca9f8832d9f207323c5e02a72
This commit is contained in:
@@ -483,6 +483,8 @@ class HttpClient:
|
||||
sse = decoder.decode(line=line.rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
yield sse
|
||||
if sse := decoder.decode(b""):
|
||||
yield sse
|
||||
|
||||
|
||||
async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
|
||||
@@ -3673,6 +3675,8 @@ class SyncHttpClient:
|
||||
sse = decoder.decode(line.rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
yield sse
|
||||
if sse := decoder.decode(b""):
|
||||
yield sse
|
||||
|
||||
|
||||
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
||||
|
||||
+239
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk.client import HttpClient, SyncHttpClient
|
||||
from langgraph_sdk.schema import StreamPart
|
||||
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
|
||||
|
||||
with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
|
||||
RESPONSE_PAYLOAD = f.read()
|
||||
|
||||
|
||||
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
|
||||
decoder = BytesLineDecoder()
|
||||
for part in payload:
|
||||
yield from decoder.decode(part)
|
||||
yield from decoder.flush()
|
||||
|
||||
|
||||
def test_stream_see():
|
||||
for groups in (
|
||||
[RESPONSE_PAYLOAD],
|
||||
RESPONSE_PAYLOAD.splitlines(keepends=True),
|
||||
):
|
||||
parts: list[StreamPart] = []
|
||||
|
||||
decoder = SSEDecoder()
|
||||
for line in iter_lines_raw(groups):
|
||||
sse = decoder.decode(line=line.rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
parts.append(sse)
|
||||
if sse := decoder.decode(b""):
|
||||
parts.append(sse)
|
||||
|
||||
assert decoder.decode(b"") is None
|
||||
assert len(parts) == 79
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_client_stream_flushes_trailing_event():
|
||||
payload = b'event: foo\ndata: {"bar": 1}\n'
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["accept"] == "text/event-stream"
|
||||
assert request.headers["cache-control"] == "no-store"
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
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", "GET")]
|
||||
|
||||
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
||||
|
||||
|
||||
def test_sync_http_client_stream_flushes_trailing_event():
|
||||
payload = b'event: foo\ndata: {"bar": 1}\n'
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["accept"] == "text/event-stream"
|
||||
assert request.headers["cache-control"] == "no-store"
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
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", "GET"))
|
||||
|
||||
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
||||
Reference in New Issue
Block a user