langgraph-sdk 0.2.7__tar.gz → 0.2.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/PKG-INFO +1 -1
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/__init__.py +1 -1
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/client.py +176 -59
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/sse.py +6 -0
- langgraph_sdk-0.2.8/tests/test_client_stream.py +248 -0
- langgraph_sdk-0.2.7/tests/test_client_stream.py +0 -81
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/.gitignore +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/LICENSE +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/Makefile +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/README.md +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/auth/__init__.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/auth/exceptions.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/auth/types.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/py.typed +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/langgraph_sdk/schema.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/pyproject.toml +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/tests/fixtures/response.txt +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/tests/test_api_parity.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/tests/test_select_fields_sync.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.8}/uv.lock +0 -0
|
@@ -455,36 +455,94 @@ class HttpClient:
|
|
|
455
455
|
if headers:
|
|
456
456
|
request_headers.update(headers)
|
|
457
457
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
458
|
+
reconnect_headers = {
|
|
459
|
+
key: value
|
|
460
|
+
for key, value in request_headers.items()
|
|
461
|
+
if key.lower() not in {"content-length", "content-type"}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
last_event_id: str | None = None
|
|
465
|
+
reconnect_path: str | None = None
|
|
466
|
+
reconnect_attempts = 0
|
|
467
|
+
max_reconnect_attempts = 5
|
|
468
|
+
|
|
469
|
+
while True:
|
|
470
|
+
current_headers = dict(
|
|
471
|
+
request_headers if reconnect_path is None else reconnect_headers
|
|
472
|
+
)
|
|
473
|
+
if last_event_id is not None:
|
|
474
|
+
current_headers["Last-Event-ID"] = last_event_id
|
|
475
|
+
|
|
476
|
+
current_method = method if reconnect_path is None else "GET"
|
|
477
|
+
current_content = content if reconnect_path is None else None
|
|
478
|
+
current_params = params if reconnect_path is None else None
|
|
479
|
+
|
|
480
|
+
retry = False
|
|
481
|
+
async with self.client.stream(
|
|
482
|
+
current_method,
|
|
483
|
+
reconnect_path or path,
|
|
484
|
+
headers=current_headers,
|
|
485
|
+
content=current_content,
|
|
486
|
+
params=current_params,
|
|
487
|
+
) as res:
|
|
488
|
+
if reconnect_path is None and on_response:
|
|
489
|
+
on_response(res)
|
|
490
|
+
# check status
|
|
491
|
+
try:
|
|
492
|
+
res.raise_for_status()
|
|
493
|
+
except httpx.HTTPStatusError as e:
|
|
494
|
+
body = (await res.aread()).decode()
|
|
495
|
+
if sys.version_info >= (3, 11):
|
|
496
|
+
e.add_note(body)
|
|
497
|
+
else:
|
|
498
|
+
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
499
|
+
raise e
|
|
500
|
+
# check content type
|
|
501
|
+
content_type = res.headers.get("content-type", "").partition(";")[0]
|
|
502
|
+
if "text/event-stream" not in content_type:
|
|
503
|
+
raise httpx.TransportError(
|
|
504
|
+
"Expected response header Content-Type to contain 'text/event-stream', "
|
|
505
|
+
f"got {content_type!r}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
reconnect_location = res.headers.get("location")
|
|
509
|
+
if reconnect_location:
|
|
510
|
+
reconnect_path = reconnect_location
|
|
511
|
+
|
|
512
|
+
# parse SSE
|
|
513
|
+
decoder = SSEDecoder()
|
|
514
|
+
try:
|
|
515
|
+
async for line in aiter_lines_raw(res):
|
|
516
|
+
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
517
|
+
if sse is not None:
|
|
518
|
+
if decoder.last_event_id is not None:
|
|
519
|
+
last_event_id = decoder.last_event_id
|
|
520
|
+
if sse.event or sse.data is not None:
|
|
521
|
+
yield sse
|
|
522
|
+
except httpx.HTTPError:
|
|
523
|
+
# httpx.TransportError inherits from HTTPError, so transient
|
|
524
|
+
# disconnects during streaming land here.
|
|
525
|
+
if reconnect_path is None:
|
|
526
|
+
raise
|
|
527
|
+
retry = True
|
|
470
528
|
else:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
529
|
+
if sse := decoder.decode(b""):
|
|
530
|
+
if decoder.last_event_id is not None:
|
|
531
|
+
last_event_id = decoder.last_event_id
|
|
532
|
+
if sse.event or sse.data is not None:
|
|
533
|
+
# decoder.decode(b"") flushes the in-flight event and may
|
|
534
|
+
# return an empty placeholder when there is no pending
|
|
535
|
+
# message. Skip these no-op events so the stream doesn't
|
|
536
|
+
# emit a trailing blank item after reconnects.
|
|
537
|
+
yield sse
|
|
538
|
+
if retry:
|
|
539
|
+
reconnect_attempts += 1
|
|
540
|
+
if reconnect_attempts > max_reconnect_attempts:
|
|
541
|
+
raise httpx.TransportError(
|
|
542
|
+
"Exceeded maximum SSE reconnection attempts"
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
break
|
|
488
546
|
|
|
489
547
|
|
|
490
548
|
async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
|
|
@@ -3642,41 +3700,100 @@ class SyncHttpClient:
|
|
|
3642
3700
|
on_response: Callable[[httpx.Response], None] | None = None,
|
|
3643
3701
|
) -> Iterator[StreamPart]:
|
|
3644
3702
|
"""Stream the results of a request using SSE."""
|
|
3645
|
-
|
|
3703
|
+
if json is not None:
|
|
3704
|
+
request_headers, content = _encode_json(json)
|
|
3705
|
+
else:
|
|
3706
|
+
request_headers, content = {}, None
|
|
3646
3707
|
request_headers["Accept"] = "text/event-stream"
|
|
3647
3708
|
request_headers["Cache-Control"] = "no-store"
|
|
3648
3709
|
if headers:
|
|
3649
3710
|
request_headers.update(headers)
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3711
|
+
|
|
3712
|
+
reconnect_headers = {
|
|
3713
|
+
key: value
|
|
3714
|
+
for key, value in request_headers.items()
|
|
3715
|
+
if key.lower() not in {"content-length", "content-type"}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
last_event_id: str | None = None
|
|
3719
|
+
reconnect_path: str | None = None
|
|
3720
|
+
reconnect_attempts = 0
|
|
3721
|
+
max_reconnect_attempts = 5
|
|
3722
|
+
|
|
3723
|
+
while True:
|
|
3724
|
+
current_headers = dict(
|
|
3725
|
+
request_headers if reconnect_path is None else reconnect_headers
|
|
3726
|
+
)
|
|
3727
|
+
if last_event_id is not None:
|
|
3728
|
+
current_headers["Last-Event-ID"] = last_event_id
|
|
3729
|
+
|
|
3730
|
+
current_method = method if reconnect_path is None else "GET"
|
|
3731
|
+
current_content = content if reconnect_path is None else None
|
|
3732
|
+
current_params = params if reconnect_path is None else None
|
|
3733
|
+
|
|
3734
|
+
retry = False
|
|
3735
|
+
with self.client.stream(
|
|
3736
|
+
current_method,
|
|
3737
|
+
reconnect_path or path,
|
|
3738
|
+
headers=current_headers,
|
|
3739
|
+
content=current_content,
|
|
3740
|
+
params=current_params,
|
|
3741
|
+
) as res:
|
|
3742
|
+
if reconnect_path is None and on_response:
|
|
3743
|
+
on_response(res)
|
|
3744
|
+
# check status
|
|
3745
|
+
try:
|
|
3746
|
+
res.raise_for_status()
|
|
3747
|
+
except httpx.HTTPStatusError as e:
|
|
3748
|
+
body = (res.read()).decode()
|
|
3749
|
+
if sys.version_info >= (3, 11):
|
|
3750
|
+
e.add_note(body)
|
|
3751
|
+
else:
|
|
3752
|
+
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
3753
|
+
raise e
|
|
3754
|
+
# check content type
|
|
3755
|
+
content_type = res.headers.get("content-type", "").partition(";")[0]
|
|
3756
|
+
if "text/event-stream" not in content_type:
|
|
3757
|
+
raise httpx.TransportError(
|
|
3758
|
+
"Expected response header Content-Type to contain 'text/event-stream', "
|
|
3759
|
+
f"got {content_type!r}"
|
|
3760
|
+
)
|
|
3761
|
+
|
|
3762
|
+
reconnect_location = res.headers.get("location")
|
|
3763
|
+
if reconnect_location:
|
|
3764
|
+
reconnect_path = reconnect_location
|
|
3765
|
+
|
|
3766
|
+
decoder = SSEDecoder()
|
|
3767
|
+
try:
|
|
3768
|
+
for line in iter_lines_raw(res):
|
|
3769
|
+
sse = decoder.decode(line.rstrip(b"\n"))
|
|
3770
|
+
if sse is not None:
|
|
3771
|
+
if decoder.last_event_id is not None:
|
|
3772
|
+
last_event_id = decoder.last_event_id
|
|
3773
|
+
if sse.event or sse.data is not None:
|
|
3774
|
+
yield sse
|
|
3775
|
+
except httpx.HTTPError:
|
|
3776
|
+
# httpx.TransportError inherits from HTTPError, so transient
|
|
3777
|
+
# disconnects during streaming land here.
|
|
3778
|
+
if reconnect_path is None:
|
|
3779
|
+
raise
|
|
3780
|
+
retry = True
|
|
3662
3781
|
else:
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
if sse := decoder.decode(b""):
|
|
3679
|
-
yield sse
|
|
3782
|
+
if sse := decoder.decode(b""):
|
|
3783
|
+
if decoder.last_event_id is not None:
|
|
3784
|
+
last_event_id = decoder.last_event_id
|
|
3785
|
+
if sse.event or sse.data is not None:
|
|
3786
|
+
# See async stream implementation for rationale on
|
|
3787
|
+
# skipping empty flush events.
|
|
3788
|
+
yield sse
|
|
3789
|
+
if retry:
|
|
3790
|
+
reconnect_attempts += 1
|
|
3791
|
+
if reconnect_attempts > max_reconnect_attempts:
|
|
3792
|
+
raise httpx.TransportError(
|
|
3793
|
+
"Exceeded maximum SSE reconnection attempts"
|
|
3794
|
+
)
|
|
3795
|
+
continue
|
|
3796
|
+
break
|
|
3680
3797
|
|
|
3681
3798
|
|
|
3682
3799
|
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
|
@@ -81,6 +81,12 @@ class SSEDecoder:
|
|
|
81
81
|
self._last_event_id = ""
|
|
82
82
|
self._retry: int | None = None
|
|
83
83
|
|
|
84
|
+
@property
|
|
85
|
+
def last_event_id(self) -> str | None:
|
|
86
|
+
"""Return the last event identifier that was seen."""
|
|
87
|
+
|
|
88
|
+
return self._last_event_id or None
|
|
89
|
+
|
|
84
90
|
def decode(self, line: bytes) -> StreamPart | None:
|
|
85
91
|
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
|
|
86
92
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from langgraph_sdk.client import HttpClient, SyncHttpClient
|
|
10
|
+
from langgraph_sdk.schema import StreamPart
|
|
11
|
+
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
|
|
12
|
+
|
|
13
|
+
with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
|
|
14
|
+
RESPONSE_PAYLOAD = f.read()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncListByteStream(httpx.AsyncByteStream):
|
|
18
|
+
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
|
|
19
|
+
self._chunks = list(chunks)
|
|
20
|
+
self._exc = exc
|
|
21
|
+
|
|
22
|
+
async def __aiter__(self): # type: ignore[override]
|
|
23
|
+
for chunk in self._chunks:
|
|
24
|
+
yield chunk
|
|
25
|
+
if self._exc is not None:
|
|
26
|
+
raise self._exc
|
|
27
|
+
|
|
28
|
+
async def aclose(self) -> None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ListByteStream(httpx.ByteStream):
|
|
33
|
+
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
|
|
34
|
+
self._chunks = list(chunks)
|
|
35
|
+
self._exc = exc
|
|
36
|
+
|
|
37
|
+
def __iter__(self): # type: ignore[override]
|
|
38
|
+
yield from self._chunks
|
|
39
|
+
if self._exc is not None:
|
|
40
|
+
raise self._exc
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
|
|
47
|
+
decoder = BytesLineDecoder()
|
|
48
|
+
for part in payload:
|
|
49
|
+
yield from decoder.decode(part)
|
|
50
|
+
yield from decoder.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_stream_see():
|
|
54
|
+
for groups in (
|
|
55
|
+
[RESPONSE_PAYLOAD],
|
|
56
|
+
RESPONSE_PAYLOAD.splitlines(keepends=True),
|
|
57
|
+
):
|
|
58
|
+
parts: list[StreamPart] = []
|
|
59
|
+
|
|
60
|
+
decoder = SSEDecoder()
|
|
61
|
+
for line in iter_lines_raw(groups):
|
|
62
|
+
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
63
|
+
if sse is not None:
|
|
64
|
+
parts.append(sse)
|
|
65
|
+
if sse := decoder.decode(b""):
|
|
66
|
+
parts.append(sse)
|
|
67
|
+
|
|
68
|
+
assert decoder.decode(b"") is None
|
|
69
|
+
assert len(parts) == 79
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_http_client_stream_flushes_trailing_event():
|
|
74
|
+
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
75
|
+
|
|
76
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
77
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
78
|
+
assert request.headers["cache-control"] == "no-store"
|
|
79
|
+
return httpx.Response(
|
|
80
|
+
200,
|
|
81
|
+
headers={"Content-Type": "text/event-stream"},
|
|
82
|
+
content=payload,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
transport = httpx.MockTransport(handler)
|
|
86
|
+
async with httpx.AsyncClient(
|
|
87
|
+
transport=transport, base_url="https://example.com"
|
|
88
|
+
) as client:
|
|
89
|
+
http_client = HttpClient(client)
|
|
90
|
+
parts = [part async for part in http_client.stream("/stream", "GET")]
|
|
91
|
+
|
|
92
|
+
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_sync_http_client_stream_recovers_after_disconnect():
|
|
96
|
+
reconnect_path = "/reconnect"
|
|
97
|
+
first_chunks = [
|
|
98
|
+
b"id: 1\n",
|
|
99
|
+
b"event: values\n",
|
|
100
|
+
b'data: {"step": 1}\n\n',
|
|
101
|
+
]
|
|
102
|
+
second_chunks = [
|
|
103
|
+
b"id: 2\n",
|
|
104
|
+
b"event: values\n",
|
|
105
|
+
b'data: {"step": 2}\n\n',
|
|
106
|
+
b"event: end\n",
|
|
107
|
+
b"data: null\n\n",
|
|
108
|
+
]
|
|
109
|
+
call_count = 0
|
|
110
|
+
|
|
111
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
112
|
+
nonlocal call_count
|
|
113
|
+
call_count += 1
|
|
114
|
+
if call_count == 1:
|
|
115
|
+
assert request.method == "POST"
|
|
116
|
+
assert request.url.path == "/stream"
|
|
117
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
118
|
+
assert request.headers["cache-control"] == "no-store"
|
|
119
|
+
assert "last-event-id" not in {
|
|
120
|
+
k.lower(): v for k, v in request.headers.items()
|
|
121
|
+
}
|
|
122
|
+
assert request.read()
|
|
123
|
+
return httpx.Response(
|
|
124
|
+
200,
|
|
125
|
+
headers={
|
|
126
|
+
"Content-Type": "text/event-stream",
|
|
127
|
+
"Location": reconnect_path,
|
|
128
|
+
},
|
|
129
|
+
stream=ListByteStream(
|
|
130
|
+
first_chunks,
|
|
131
|
+
httpx.RemoteProtocolError("incomplete chunked read"),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
if call_count == 2:
|
|
135
|
+
assert request.method == "GET"
|
|
136
|
+
assert request.url.path == reconnect_path
|
|
137
|
+
assert request.headers["Last-Event-ID"] == "1"
|
|
138
|
+
assert request.read() == b""
|
|
139
|
+
return httpx.Response(
|
|
140
|
+
200,
|
|
141
|
+
headers={"Content-Type": "text/event-stream"},
|
|
142
|
+
stream=ListByteStream(second_chunks),
|
|
143
|
+
)
|
|
144
|
+
raise AssertionError("unexpected request")
|
|
145
|
+
|
|
146
|
+
transport = httpx.MockTransport(handler)
|
|
147
|
+
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
|
148
|
+
http_client = SyncHttpClient(client)
|
|
149
|
+
parts = list(http_client.stream("/stream", "POST", json={"payload": "value"}))
|
|
150
|
+
|
|
151
|
+
assert call_count == 2
|
|
152
|
+
assert parts == [
|
|
153
|
+
StreamPart(event="values", data={"step": 1}),
|
|
154
|
+
StreamPart(event="values", data={"step": 2}),
|
|
155
|
+
StreamPart(event="end", data=None),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_http_client_stream_recovers_after_disconnect():
|
|
161
|
+
reconnect_path = "/reconnect"
|
|
162
|
+
first_chunks = [
|
|
163
|
+
b"id: 1\n",
|
|
164
|
+
b"event: values\n",
|
|
165
|
+
b'data: {"step": 1}\n\n',
|
|
166
|
+
]
|
|
167
|
+
second_chunks = [
|
|
168
|
+
b"id: 2\n",
|
|
169
|
+
b"event: values\n",
|
|
170
|
+
b'data: {"step": 2}\n\n',
|
|
171
|
+
b"event: end\n",
|
|
172
|
+
b"data: null\n\n",
|
|
173
|
+
]
|
|
174
|
+
call_count = 0
|
|
175
|
+
|
|
176
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
177
|
+
nonlocal call_count
|
|
178
|
+
call_count += 1
|
|
179
|
+
if call_count == 1:
|
|
180
|
+
assert request.method == "POST"
|
|
181
|
+
assert request.url.path == "/stream"
|
|
182
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
183
|
+
assert request.headers["cache-control"] == "no-store"
|
|
184
|
+
assert "last-event-id" not in {
|
|
185
|
+
k.lower(): v for k, v in request.headers.items()
|
|
186
|
+
}
|
|
187
|
+
assert await request.aread()
|
|
188
|
+
return httpx.Response(
|
|
189
|
+
200,
|
|
190
|
+
headers={
|
|
191
|
+
"Content-Type": "text/event-stream",
|
|
192
|
+
"Location": reconnect_path,
|
|
193
|
+
},
|
|
194
|
+
stream=AsyncListByteStream(
|
|
195
|
+
first_chunks,
|
|
196
|
+
httpx.RemoteProtocolError("incomplete chunked read"),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
if call_count == 2:
|
|
200
|
+
assert request.method == "GET"
|
|
201
|
+
assert request.url.path == reconnect_path
|
|
202
|
+
assert request.headers["Last-Event-ID"] == "1"
|
|
203
|
+
assert await request.aread() == b""
|
|
204
|
+
return httpx.Response(
|
|
205
|
+
200,
|
|
206
|
+
headers={"Content-Type": "text/event-stream"},
|
|
207
|
+
stream=AsyncListByteStream(second_chunks),
|
|
208
|
+
)
|
|
209
|
+
raise AssertionError("unexpected request")
|
|
210
|
+
|
|
211
|
+
transport = httpx.MockTransport(handler)
|
|
212
|
+
async with httpx.AsyncClient(
|
|
213
|
+
transport=transport, base_url="https://example.com"
|
|
214
|
+
) as client:
|
|
215
|
+
http_client = HttpClient(client)
|
|
216
|
+
parts = [
|
|
217
|
+
part
|
|
218
|
+
async for part in http_client.stream(
|
|
219
|
+
"/stream", "POST", json={"payload": "value"}
|
|
220
|
+
)
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
assert call_count == 2
|
|
224
|
+
assert parts == [
|
|
225
|
+
StreamPart(event="values", data={"step": 1}),
|
|
226
|
+
StreamPart(event="values", data={"step": 2}),
|
|
227
|
+
StreamPart(event="end", data=None),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_sync_http_client_stream_flushes_trailing_event():
|
|
232
|
+
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
233
|
+
|
|
234
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
235
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
236
|
+
assert request.headers["cache-control"] == "no-store"
|
|
237
|
+
return httpx.Response(
|
|
238
|
+
200,
|
|
239
|
+
headers={"Content-Type": "text/event-stream"},
|
|
240
|
+
content=payload,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
transport = httpx.MockTransport(handler)
|
|
244
|
+
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
|
245
|
+
http_client = SyncHttpClient(client)
|
|
246
|
+
parts = list(http_client.stream("/stream", "GET"))
|
|
247
|
+
|
|
248
|
+
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
from collections.abc import Iterator
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import httpx
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from langgraph_sdk.client import HttpClient, SyncHttpClient
|
|
8
|
-
from langgraph_sdk.schema import StreamPart
|
|
9
|
-
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
|
|
10
|
-
|
|
11
|
-
with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
|
|
12
|
-
RESPONSE_PAYLOAD = f.read()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
|
|
16
|
-
decoder = BytesLineDecoder()
|
|
17
|
-
for part in payload:
|
|
18
|
-
yield from decoder.decode(part)
|
|
19
|
-
yield from decoder.flush()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_stream_see():
|
|
23
|
-
for groups in (
|
|
24
|
-
[RESPONSE_PAYLOAD],
|
|
25
|
-
RESPONSE_PAYLOAD.splitlines(keepends=True),
|
|
26
|
-
):
|
|
27
|
-
parts: list[StreamPart] = []
|
|
28
|
-
|
|
29
|
-
decoder = SSEDecoder()
|
|
30
|
-
for line in iter_lines_raw(groups):
|
|
31
|
-
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
32
|
-
if sse is not None:
|
|
33
|
-
parts.append(sse)
|
|
34
|
-
if sse := decoder.decode(b""):
|
|
35
|
-
parts.append(sse)
|
|
36
|
-
|
|
37
|
-
assert decoder.decode(b"") is None
|
|
38
|
-
assert len(parts) == 79
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@pytest.mark.asyncio
|
|
42
|
-
async def test_http_client_stream_flushes_trailing_event():
|
|
43
|
-
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
44
|
-
|
|
45
|
-
async def handler(request: httpx.Request) -> httpx.Response:
|
|
46
|
-
assert request.headers["accept"] == "text/event-stream"
|
|
47
|
-
assert request.headers["cache-control"] == "no-store"
|
|
48
|
-
return httpx.Response(
|
|
49
|
-
200,
|
|
50
|
-
headers={"Content-Type": "text/event-stream"},
|
|
51
|
-
content=payload,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
transport = httpx.MockTransport(handler)
|
|
55
|
-
async with httpx.AsyncClient(
|
|
56
|
-
transport=transport, base_url="https://example.com"
|
|
57
|
-
) as client:
|
|
58
|
-
http_client = HttpClient(client)
|
|
59
|
-
parts = [part async for part in http_client.stream("/stream", "GET")]
|
|
60
|
-
|
|
61
|
-
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_sync_http_client_stream_flushes_trailing_event():
|
|
65
|
-
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
66
|
-
|
|
67
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
68
|
-
assert request.headers["accept"] == "text/event-stream"
|
|
69
|
-
assert request.headers["cache-control"] == "no-store"
|
|
70
|
-
return httpx.Response(
|
|
71
|
-
200,
|
|
72
|
-
headers={"Content-Type": "text/event-stream"},
|
|
73
|
-
content=payload,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
transport = httpx.MockTransport(handler)
|
|
77
|
-
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
|
78
|
-
http_client = SyncHttpClient(client)
|
|
79
|
-
parts = list(http_client.stream("/stream", "GET"))
|
|
80
|
-
|
|
81
|
-
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|