langgraph-sdk 0.2.7__tar.gz → 0.2.9__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.9
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.9"
5
5
 
6
6
  __all__ = ["Auth", "get_client", "get_sync_client"]
@@ -728,7 +728,7 @@ def is_studio_user(
728
728
  return (
729
729
  isinstance(user, types.StudioUser)
730
730
  or isinstance(user, dict)
731
- and user.get("kind") == "StudioUser"
731
+ and user.get("kind") == "StudioUser" # ty: ignore[invalid-argument-type]
732
732
  )
733
733
 
734
734
 
@@ -437,7 +437,7 @@ class HttpClient:
437
437
  logger.error(f"Error from langgraph-api: {body}", exc_info=e)
438
438
  raise e
439
439
 
440
- async def stream(
440
+ async def request_reconnect(
441
441
  self,
442
442
  path: str,
443
443
  method: str,
@@ -446,45 +446,151 @@ class HttpClient:
446
446
  params: QueryParamTypes | None = None,
447
447
  headers: Mapping[str, str] | None = None,
448
448
  on_response: Callable[[httpx.Response], None] | None = None,
449
- ) -> AsyncIterator[StreamPart]:
450
- """Stream results using SSE."""
449
+ reconnect_limit: int = 5,
450
+ ) -> Any:
451
+ """Send a request that automatically reconnects to Location header."""
451
452
  request_headers, content = await _aencode_json(json)
452
- request_headers["Accept"] = "text/event-stream"
453
- request_headers["Cache-Control"] = "no-store"
454
- # Add runtime headers with precedence
455
453
  if headers:
456
454
  request_headers.update(headers)
457
-
458
455
  async with self.client.stream(
459
456
  method, path, headers=request_headers, content=content, params=params
460
- ) as res:
457
+ ) as r:
461
458
  if on_response:
462
- on_response(res)
463
- # check status
459
+ on_response(r)
464
460
  try:
465
- res.raise_for_status()
461
+ r.raise_for_status()
466
462
  except httpx.HTTPStatusError as e:
467
- body = (await res.aread()).decode()
463
+ body = (await r.aread()).decode()
468
464
  if sys.version_info >= (3, 11):
469
465
  e.add_note(body)
470
466
  else:
471
467
  logger.error(f"Error from langgraph-api: {body}", exc_info=e)
472
468
  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}"
469
+ loc = r.headers.get("location")
470
+ if reconnect_limit <= 0 or not loc:
471
+ return await _adecode_json(r)
472
+ try:
473
+ return await _adecode_json(r)
474
+ except httpx.HTTPError:
475
+ warnings.warn(
476
+ f"Request failed, attempting reconnect to Location: {loc}",
477
+ stacklevel=2,
478
+ )
479
+ await r.aclose()
480
+ return await self.request_reconnect(
481
+ loc,
482
+ "GET",
483
+ headers=request_headers,
484
+ # don't pass on_response so it's only called once
485
+ reconnect_limit=reconnect_limit - 1,
479
486
  )
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
487
+
488
+ async def stream(
489
+ self,
490
+ path: str,
491
+ method: str,
492
+ *,
493
+ json: dict[str, Any] | None = None,
494
+ params: QueryParamTypes | None = None,
495
+ headers: Mapping[str, str] | None = None,
496
+ on_response: Callable[[httpx.Response], None] | None = None,
497
+ ) -> AsyncIterator[StreamPart]:
498
+ """Stream results using SSE."""
499
+ request_headers, content = await _aencode_json(json)
500
+ request_headers["Accept"] = "text/event-stream"
501
+ request_headers["Cache-Control"] = "no-store"
502
+ # Add runtime headers with precedence
503
+ if headers:
504
+ request_headers.update(headers)
505
+
506
+ reconnect_headers = {
507
+ key: value
508
+ for key, value in request_headers.items()
509
+ if key.lower() not in {"content-length", "content-type"}
510
+ }
511
+
512
+ last_event_id: str | None = None
513
+ reconnect_path: str | None = None
514
+ reconnect_attempts = 0
515
+ max_reconnect_attempts = 5
516
+
517
+ while True:
518
+ current_headers = dict(
519
+ request_headers if reconnect_path is None else reconnect_headers
520
+ )
521
+ if last_event_id is not None:
522
+ current_headers["Last-Event-ID"] = last_event_id
523
+
524
+ current_method = method if reconnect_path is None else "GET"
525
+ current_content = content if reconnect_path is None else None
526
+ current_params = params if reconnect_path is None else None
527
+
528
+ retry = False
529
+ async with self.client.stream(
530
+ current_method,
531
+ reconnect_path or path,
532
+ headers=current_headers,
533
+ content=current_content,
534
+ params=current_params,
535
+ ) as res:
536
+ if reconnect_path is None and on_response:
537
+ on_response(res)
538
+ # check status
539
+ try:
540
+ res.raise_for_status()
541
+ except httpx.HTTPStatusError as e:
542
+ body = (await res.aread()).decode()
543
+ if sys.version_info >= (3, 11):
544
+ e.add_note(body)
545
+ else:
546
+ logger.error(f"Error from langgraph-api: {body}", exc_info=e)
547
+ raise e
548
+ # check content type
549
+ content_type = res.headers.get("content-type", "").partition(";")[0]
550
+ if "text/event-stream" not in content_type:
551
+ raise httpx.TransportError(
552
+ "Expected response header Content-Type to contain 'text/event-stream', "
553
+ f"got {content_type!r}"
554
+ )
555
+
556
+ reconnect_location = res.headers.get("location")
557
+ if reconnect_location:
558
+ reconnect_path = reconnect_location
559
+
560
+ # parse SSE
561
+ decoder = SSEDecoder()
562
+ try:
563
+ async for line in aiter_lines_raw(res):
564
+ sse = decoder.decode(line=line.rstrip(b"\n"))
565
+ if sse is not None:
566
+ if decoder.last_event_id is not None:
567
+ last_event_id = decoder.last_event_id
568
+ if sse.event or sse.data is not None:
569
+ yield sse
570
+ except httpx.HTTPError:
571
+ # httpx.TransportError inherits from HTTPError, so transient
572
+ # disconnects during streaming land here.
573
+ if reconnect_path is None:
574
+ raise
575
+ retry = True
576
+ else:
577
+ if sse := decoder.decode(b""):
578
+ if decoder.last_event_id is not None:
579
+ last_event_id = decoder.last_event_id
580
+ if sse.event or sse.data is not None:
581
+ # decoder.decode(b"") flushes the in-flight event and may
582
+ # return an empty placeholder when there is no pending
583
+ # message. Skip these no-op events so the stream doesn't
584
+ # emit a trailing blank item after reconnects.
585
+ yield sse
586
+ if retry:
587
+ reconnect_attempts += 1
588
+ if reconnect_attempts > max_reconnect_attempts:
589
+ raise httpx.TransportError(
590
+ "Exceeded maximum SSE reconnection attempts"
591
+ )
592
+ continue
593
+ break
488
594
 
489
595
 
490
596
  async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
@@ -2475,8 +2581,9 @@ class RunsClient:
2475
2581
  if on_run_created and (metadata := _get_run_metadata_from_response(res)):
2476
2582
  on_run_created(metadata)
2477
2583
 
2478
- response = await self.http.post(
2584
+ response = await self.http.request_reconnect(
2479
2585
  endpoint,
2586
+ "POST",
2480
2587
  json={k: v for k, v in payload.items() if v is not None},
2481
2588
  params=params,
2482
2589
  headers=headers,
@@ -2621,12 +2728,20 @@ class RunsClient:
2621
2728
  }
2622
2729
  if params:
2623
2730
  query_params.update(params)
2624
- return await self.http.post(
2625
- f"/threads/{thread_id}/runs/{run_id}/cancel",
2626
- json=None,
2627
- params=query_params,
2628
- headers=headers,
2629
- )
2731
+ if wait:
2732
+ return await self.http.request_reconnect(
2733
+ f"/threads/{thread_id}/runs/{run_id}/cancel",
2734
+ "POST",
2735
+ params=query_params,
2736
+ headers=headers,
2737
+ )
2738
+ else:
2739
+ return await self.http.post(
2740
+ f"/threads/{thread_id}/runs/{run_id}/cancel",
2741
+ json=None,
2742
+ params=query_params,
2743
+ headers=headers,
2744
+ )
2630
2745
 
2631
2746
  async def join(
2632
2747
  self,
@@ -2658,8 +2773,11 @@ class RunsClient:
2658
2773
  ```
2659
2774
 
2660
2775
  """ # noqa: E501
2661
- return await self.http.get(
2662
- f"/threads/{thread_id}/runs/{run_id}/join", headers=headers, params=params
2776
+ return await self.http.request_reconnect(
2777
+ f"/threads/{thread_id}/runs/{run_id}/join",
2778
+ "GET",
2779
+ headers=headers,
2780
+ params=params,
2663
2781
  )
2664
2782
 
2665
2783
  def join_stream(
@@ -3631,7 +3749,7 @@ class SyncHttpClient:
3631
3749
  logger.error(f"Error from langgraph-api: {body}", exc_info=e)
3632
3750
  raise e
3633
3751
 
3634
- def stream(
3752
+ def request_reconnect(
3635
3753
  self,
3636
3754
  path: str,
3637
3755
  method: str,
@@ -3640,43 +3758,150 @@ class SyncHttpClient:
3640
3758
  params: QueryParamTypes | None = None,
3641
3759
  headers: Mapping[str, str] | None = None,
3642
3760
  on_response: Callable[[httpx.Response], None] | None = None,
3643
- ) -> Iterator[StreamPart]:
3644
- """Stream the results of a request using SSE."""
3761
+ reconnect_limit: int = 5,
3762
+ ) -> Any:
3763
+ """Send a request that automatically reconnects to Location header."""
3645
3764
  request_headers, content = _encode_json(json)
3646
- request_headers["Accept"] = "text/event-stream"
3647
- request_headers["Cache-Control"] = "no-store"
3648
3765
  if headers:
3649
3766
  request_headers.update(headers)
3650
3767
  with self.client.stream(
3651
3768
  method, path, headers=request_headers, content=content, params=params
3652
- ) as res:
3769
+ ) as r:
3653
3770
  if on_response:
3654
- on_response(res)
3655
- # check status
3771
+ on_response(r)
3656
3772
  try:
3657
- res.raise_for_status()
3773
+ r.raise_for_status()
3658
3774
  except httpx.HTTPStatusError as e:
3659
- body = (res.read()).decode()
3775
+ body = r.read().decode()
3660
3776
  if sys.version_info >= (3, 11):
3661
3777
  e.add_note(body)
3662
3778
  else:
3663
3779
  logger.error(f"Error from langgraph-api: {body}", exc_info=e)
3664
3780
  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}"
3781
+ loc = r.headers.get("location")
3782
+ if reconnect_limit <= 0 or not loc:
3783
+ return _decode_json(r)
3784
+ try:
3785
+ return _decode_json(r)
3786
+ except httpx.HTTPError:
3787
+ warnings.warn(
3788
+ f"Request failed, attempting reconnect to Location: {loc}",
3789
+ stacklevel=2,
3671
3790
  )
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
3791
+ r.close()
3792
+ return self.request_reconnect(
3793
+ loc,
3794
+ "GET",
3795
+ headers=request_headers,
3796
+ # don't pass on_response so it's only called once
3797
+ reconnect_limit=reconnect_limit - 1,
3798
+ )
3799
+
3800
+ def stream(
3801
+ self,
3802
+ path: str,
3803
+ method: str,
3804
+ *,
3805
+ json: dict[str, Any] | None = None,
3806
+ params: QueryParamTypes | None = None,
3807
+ headers: Mapping[str, str] | None = None,
3808
+ on_response: Callable[[httpx.Response], None] | None = None,
3809
+ ) -> Iterator[StreamPart]:
3810
+ """Stream the results of a request using SSE."""
3811
+ if json is not None:
3812
+ request_headers, content = _encode_json(json)
3813
+ else:
3814
+ request_headers, content = {}, None
3815
+ request_headers["Accept"] = "text/event-stream"
3816
+ request_headers["Cache-Control"] = "no-store"
3817
+ if headers:
3818
+ request_headers.update(headers)
3819
+
3820
+ reconnect_headers = {
3821
+ key: value
3822
+ for key, value in request_headers.items()
3823
+ if key.lower() not in {"content-length", "content-type"}
3824
+ }
3825
+
3826
+ last_event_id: str | None = None
3827
+ reconnect_path: str | None = None
3828
+ reconnect_attempts = 0
3829
+ max_reconnect_attempts = 5
3830
+
3831
+ while True:
3832
+ current_headers = dict(
3833
+ request_headers if reconnect_path is None else reconnect_headers
3834
+ )
3835
+ if last_event_id is not None:
3836
+ current_headers["Last-Event-ID"] = last_event_id
3837
+
3838
+ current_method = method if reconnect_path is None else "GET"
3839
+ current_content = content if reconnect_path is None else None
3840
+ current_params = params if reconnect_path is None else None
3841
+
3842
+ retry = False
3843
+ with self.client.stream(
3844
+ current_method,
3845
+ reconnect_path or path,
3846
+ headers=current_headers,
3847
+ content=current_content,
3848
+ params=current_params,
3849
+ ) as res:
3850
+ if reconnect_path is None and on_response:
3851
+ on_response(res)
3852
+ # check status
3853
+ try:
3854
+ res.raise_for_status()
3855
+ except httpx.HTTPStatusError as e:
3856
+ body = (res.read()).decode()
3857
+ if sys.version_info >= (3, 11):
3858
+ e.add_note(body)
3859
+ else:
3860
+ logger.error(f"Error from langgraph-api: {body}", exc_info=e)
3861
+ raise e
3862
+ # check content type
3863
+ content_type = res.headers.get("content-type", "").partition(";")[0]
3864
+ if "text/event-stream" not in content_type:
3865
+ raise httpx.TransportError(
3866
+ "Expected response header Content-Type to contain 'text/event-stream', "
3867
+ f"got {content_type!r}"
3868
+ )
3869
+
3870
+ reconnect_location = res.headers.get("location")
3871
+ if reconnect_location:
3872
+ reconnect_path = reconnect_location
3873
+
3874
+ decoder = SSEDecoder()
3875
+ try:
3876
+ for line in iter_lines_raw(res):
3877
+ sse = decoder.decode(line.rstrip(b"\n"))
3878
+ if sse is not None:
3879
+ if decoder.last_event_id is not None:
3880
+ last_event_id = decoder.last_event_id
3881
+ if sse.event or sse.data is not None:
3882
+ yield sse
3883
+ except httpx.HTTPError:
3884
+ # httpx.TransportError inherits from HTTPError, so transient
3885
+ # disconnects during streaming land here.
3886
+ if reconnect_path is None:
3887
+ raise
3888
+ retry = True
3889
+ else:
3890
+ if sse := decoder.decode(b""):
3891
+ if decoder.last_event_id is not None:
3892
+ last_event_id = decoder.last_event_id
3893
+ if sse.event or sse.data is not None:
3894
+ # See async stream implementation for rationale on
3895
+ # skipping empty flush events.
3896
+ yield sse
3897
+ if retry:
3898
+ reconnect_attempts += 1
3899
+ if reconnect_attempts > max_reconnect_attempts:
3900
+ raise httpx.TransportError(
3901
+ "Exceeded maximum SSE reconnection attempts"
3902
+ )
3903
+ continue
3904
+ break
3680
3905
 
3681
3906
 
3682
3907
  def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
@@ -5637,8 +5862,9 @@ class SyncRunsClient:
5637
5862
  endpoint = (
5638
5863
  f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
5639
5864
  )
5640
- return self.http.post(
5865
+ return self.http.request_reconnect(
5641
5866
  endpoint,
5867
+ "POST",
5642
5868
  json={k: v for k, v in payload.items() if v is not None},
5643
5869
  params=params,
5644
5870
  headers=headers,
@@ -5761,11 +5987,25 @@ class SyncRunsClient:
5761
5987
  ```
5762
5988
 
5763
5989
  """ # noqa: E501
5990
+ query_params = {
5991
+ "wait": 1 if wait else 0,
5992
+ "action": action,
5993
+ }
5994
+ if params:
5995
+ query_params.update(params)
5996
+ if wait:
5997
+ return self.http.request_reconnect(
5998
+ f"/threads/{thread_id}/runs/{run_id}/cancel",
5999
+ "POST",
6000
+ json=None,
6001
+ params=query_params,
6002
+ headers=headers,
6003
+ )
5764
6004
  return self.http.post(
5765
- f"/threads/{thread_id}/runs/{run_id}/cancel?wait={1 if wait else 0}&action={action}",
6005
+ f"/threads/{thread_id}/runs/{run_id}/cancel",
5766
6006
  json=None,
6007
+ params=query_params,
5767
6008
  headers=headers,
5768
- params=params,
5769
6009
  )
5770
6010
 
5771
6011
  def join(
@@ -5798,8 +6038,11 @@ class SyncRunsClient:
5798
6038
  ```
5799
6039
 
5800
6040
  """ # noqa: E501
5801
- return self.http.get(
5802
- f"/threads/{thread_id}/runs/{run_id}/join", headers=headers, params=params
6041
+ return self.http.request_reconnect(
6042
+ f"/threads/{thread_id}/runs/{run_id}/join",
6043
+ "GET",
6044
+ headers=headers,
6045
+ params=params,
5803
6046
  )
5804
6047
 
5805
6048
  def join_stream(
@@ -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