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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-sdk
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: SDK for interacting with LangGraph API
5
5
  Project-URL: Repository, https://www.github.com/langchain-ai/langgraph
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  from langgraph_sdk.auth import Auth
2
2
  from langgraph_sdk.client import get_client, get_sync_client
3
3
 
4
- __version__ = "0.2.7"
4
+ __version__ = "0.2.8"
5
5
 
6
6
  __all__ = ["Auth", "get_client", "get_sync_client"]
@@ -455,36 +455,94 @@ class HttpClient:
455
455
  if headers:
456
456
  request_headers.update(headers)
457
457
 
458
- async with self.client.stream(
459
- method, path, headers=request_headers, content=content, params=params
460
- ) as res:
461
- if on_response:
462
- on_response(res)
463
- # check status
464
- try:
465
- res.raise_for_status()
466
- except httpx.HTTPStatusError as e:
467
- body = (await res.aread()).decode()
468
- if sys.version_info >= (3, 11):
469
- e.add_note(body)
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
- logger.error(f"Error from langgraph-api: {body}", exc_info=e)
472
- raise e
473
- # check content type
474
- content_type = res.headers.get("content-type", "").partition(";")[0]
475
- if "text/event-stream" not in content_type:
476
- raise httpx.TransportError(
477
- "Expected response header Content-Type to contain 'text/event-stream', "
478
- f"got {content_type!r}"
479
- )
480
- # parse SSE
481
- decoder = SSEDecoder()
482
- async for line in aiter_lines_raw(res):
483
- sse = decoder.decode(line=line.rstrip(b"\n"))
484
- if sse is not None:
485
- yield sse
486
- if sse := decoder.decode(b""):
487
- yield sse
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
- request_headers, content = _encode_json(json)
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
- with self.client.stream(
3651
- method, path, headers=request_headers, content=content, params=params
3652
- ) as res:
3653
- if on_response:
3654
- on_response(res)
3655
- # check status
3656
- try:
3657
- res.raise_for_status()
3658
- except httpx.HTTPStatusError as e:
3659
- body = (res.read()).decode()
3660
- if sys.version_info >= (3, 11):
3661
- e.add_note(body)
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
- logger.error(f"Error from langgraph-api: {body}", exc_info=e)
3664
- raise e
3665
- # check content type
3666
- content_type = res.headers.get("content-type", "").partition(";")[0]
3667
- if "text/event-stream" not in content_type:
3668
- raise httpx.TransportError(
3669
- "Expected response header Content-Type to contain 'text/event-stream', "
3670
- f"got {content_type!r}"
3671
- )
3672
- # parse SSE
3673
- decoder = SSEDecoder()
3674
- for line in iter_lines_raw(res):
3675
- sse = decoder.decode(line.rstrip(b"\n"))
3676
- if sse is not None:
3677
- yield sse
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