Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-langgraph-sdk for
openSUSE:Factory checked in at 2026-10-31 23:10:43
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langgraph-sdk (Old)
and /work/SRC/openSUSE:Factory/.python-langgraph-sdk.new.1255 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-langgraph-sdk"
Sat Oct 31 23:10:43 2026 rev:11 rq:1394126 version:0.4.11
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-langgraph-sdk/python-langgraph-sdk.changes
2026-10-27 16:58:32.471551411 +0100
+++
/work/SRC/openSUSE:Factory/.python-langgraph-sdk.new.1255/python-langgraph-sdk.changes
2026-10-31 23:13:38.935075157 +0100
@@ -1,0 +2,13 @@
+Sat Oct 31 13:50:12 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.4.11:
+ * Retry failed stream reconnects with backoff; retry
+ runs.wait, join and cancel(wait=True) reconnects
+ * Preserve SSE data and cursor state across chunks; reset
+ the reconnect cursor on every empty id
+ * Warn when a stream reconnects without a Last-Event-ID
+ * Match the Auth.on overload to optional resources
+ * No new runtime dependencies
+ * Spec-cleaner modernization: %files %python_files
+
+-------------------------------------------------------------------
Old:
----
langgraph_sdk-0.4.10.tar.gz
New:
----
langgraph_sdk-0.4.11.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-langgraph-sdk.spec ++++++
--- /var/tmp/diff_new_pack.MgMZcg/_old 2026-10-31 23:13:40.224129429 +0100
+++ /var/tmp/diff_new_pack.MgMZcg/_new 2026-10-31 23:13:40.226129513 +0100
@@ -18,7 +18,7 @@
%{?sle15_python_module_pythons}
Name: python-langgraph-sdk
-Version: 0.4.10
+Version: 0.4.11
Release: 0
Summary: SDK for interacting with the LangGraph API
License: MIT
@@ -73,7 +73,7 @@
# still imported at collection time, so skip the directory outright.
%pytest tests --ignore=tests/integration
-%files %{python_files}
+%files %python_files
%doc README.md
%license LICENSE
%{python_sitelib}/langgraph_sdk
++++++ langgraph_sdk-0.4.10.tar.gz -> langgraph_sdk-0.4.11.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/PKG-INFO
new/langgraph_sdk-0.4.11/PKG-INFO
--- old/langgraph_sdk-0.4.10/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.5
Name: langgraph-sdk
-Version: 0.4.10
+Version: 0.4.11
Summary: SDK for interacting with LangGraph API
Project-URL: Source,
https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py
Project-URL: Twitter, https://x.com/langchain_oss
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/langgraph_sdk/__init__.py
new/langgraph_sdk-0.4.11/langgraph_sdk/__init__.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/__init__.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/__init__.py 2020-02-02
01:00:00.000000000 +0100
@@ -5,7 +5,7 @@
from langgraph_sdk.priority import Priority
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
-__version__ = "0.4.10"
+__version__ = "0.4.11"
__all__ = [
"Auth",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/langgraph_sdk/_async/http.py
new/langgraph_sdk-0.4.11/langgraph_sdk/_async/http.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/_async/http.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/_async/http.py 2020-02-02
01:00:00.000000000 +0100
@@ -14,9 +14,14 @@
from langgraph_sdk._shared.utilities import (
_orjson_default,
+ _reconnect_delay,
_validate_reconnect_location,
)
-from langgraph_sdk.errors import _araise_for_status_typed
+from langgraph_sdk.errors import (
+ InternalServerError,
+ RateLimitError,
+ _araise_for_status_typed,
+)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
@@ -146,7 +151,8 @@
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
- """Send a request that automatically reconnects to Location header."""
+ """Send a request that reconnects to its Location header if the
response is
+ cut off, with backoff between attempts."""
request_headers, content = await _aencode_json(json)
if headers:
request_headers.update(headers)
@@ -176,13 +182,27 @@
stacklevel=2,
)
await r.aclose()
- return await self.request_reconnect(
- loc,
- "GET",
- headers=headers,
- # don't pass on_response so it's only called once
- reconnect_limit=reconnect_limit - 1,
- )
+ for attempt in range(reconnect_limit):
+ await asyncio.sleep(_reconnect_delay(attempt, base=1.0,
cap=5.0))
+ # A restart or a deploy looks like a refused connection, a
+ # dropped body, a 429 or a 5xx; other errors won't go away.
+ try:
+ return await self.request_reconnect(
+ loc,
+ "GET",
+ headers=headers,
+ # don't pass on_response so it's only called once
+ reconnect_limit=0,
+ )
+ except httpx.HTTPStatusError as err:
+ status = err.response.status_code
+ if attempt == reconnect_limit - 1 or (
+ status != 429 and status < 500
+ ):
+ raise
+ except httpx.TransportError:
+ if attempt == reconnect_limit - 1:
+ raise
async def stream(
self,
@@ -217,72 +237,104 @@
current_headers = dict(
request_headers if reconnect_path is None else
reconnect_headers
)
+ if last_event_id is not None:
+ current_headers = {
+ key: value
+ for key, value in current_headers.items()
+ if key.lower() != "last-event-id"
+ }
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
+ if reconnect_path is not None and not any(
+ key.lower() == "last-event-id" and value
+ for key, value in current_headers.items()
+ ):
+ warnings.warn(
+ "Reconnecting without a Last-Event-ID, so events sent
while "
+ "disconnected are not replayed. Only runs created with "
+ "stream_resumable=True send the event IDs a reconnect
resumes "
+ "from.",
+ stacklevel=2,
+ )
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
- await _araise_for_status_typed(res)
- # 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:
- _validate_reconnect_location(
- self.client.base_url, reconnect_location
- )
- reconnect_path = reconnect_location
-
- # parse SSE
- decoder = SSEDecoder()
- try:
- async for line in aiter_lines_raw(res):
- sse = decoder.decode(line=cast("bytes",
line).rstrip(b"\n"))
- if sse is not None:
+ reconnect_cause: httpx.HTTPError | None = None
+ try:
+ 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
+ await _araise_for_status_typed(res)
+ # 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:
+ _validate_reconnect_location(
+ self.client.base_url, reconnect_location
+ )
+ reconnect_path = reconnect_location
+
+ # parse SSE
+ decoder = SSEDecoder(last_event_id=last_event_id)
+ try:
+ async for line in aiter_lines_raw(res):
+ sse = decoder.decode(line=cast("bytes",
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 decoder.has_data:
+ # Progress since the last drop earns a
fresh
+ # set of reconnect attempts.
+ reconnect_attempts = 0
+ yield sse
+ except httpx.HTTPError as err:
+ # httpx.TransportError inherits from HTTPError, so
transient
+ # disconnects during streaming land here.
+ if reconnect_path is None:
+ raise
+ reconnect_cause = err
+ else:
+ 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:
+ if sse.event or decoder.has_data:
+ # 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
- except httpx.HTTPError:
- # httpx.TransportError inherits from HTTPError, so
transient
- # disconnects during streaming land here.
- if reconnect_path is None:
- raise
- retry = True
- else:
- 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:
+ except (httpx.TransportError, RateLimitError, InternalServerError)
as err:
+ # A reconnect that can't open (connection refused, 429, 5xx or
a
+ # non-SSE body) usually means the server is restarting or
+ # mid-deploy, so retry it like a drop. Before the server sends
a
+ # Location there is nothing to reconnect to, so raise.
+ if reconnect_path is None:
+ raise
+ reconnect_cause = err
+ if reconnect_cause is not None:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
- )
+ ) from reconnect_cause
+ await asyncio.sleep(
+ _reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0)
+ )
continue
break
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/langgraph_sdk/_async/stream.py
new/langgraph_sdk-0.4.11/langgraph_sdk/_async/stream.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/_async/stream.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/_async/stream.py 2020-02-02
01:00:00.000000000 +0100
@@ -16,7 +16,6 @@
import asyncio
import contextlib
import logging
-import random
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypedDict, cast
@@ -25,7 +24,7 @@
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
-from langgraph_sdk._shared.utilities import _quote_path_param
+from langgraph_sdk._shared.utilities import _quote_path_param, _reconnect_delay
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
@@ -1691,11 +1690,13 @@
async def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff and jitter for reconnect attempt
`attempt`."""
- base = self._shared_reconnect_backoff_base
- cap = self._shared_reconnect_backoff_cap
- delay = min(cap, base * (2**attempt))
- jitter = random.uniform(0, delay * 0.25)
- await asyncio.sleep(delay + jitter)
+ await asyncio.sleep(
+ _reconnect_delay(
+ attempt,
+ base=self._shared_reconnect_backoff_base,
+ cap=self._shared_reconnect_backoff_cap,
+ )
+ )
async def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a post-ready transport
drop.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langgraph_sdk-0.4.10/langgraph_sdk/_shared/utilities.py
new/langgraph_sdk-0.4.11/langgraph_sdk/_shared/utilities.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/_shared/utilities.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/_shared/utilities.py 2020-02-02
01:00:00.000000000 +0100
@@ -4,6 +4,7 @@
import functools
import os
+import random
import re
from collections.abc import Mapping
from datetime import tzinfo
@@ -194,6 +195,15 @@
return location
+def _reconnect_delay(attempt: int, *, base: float, cap: float) -> float:
+ """Seconds to wait before reconnect attempt `attempt` (0-based).
+
+ Exponential backoff capped at `cap`, plus up to 25% jitter.
+ """
+ delay = min(cap, base * (2**attempt))
+ return delay + random.uniform(0, delay * 0.25)
+
+
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/langgraph_sdk/_sync/http.py
new/langgraph_sdk-0.4.11/langgraph_sdk/_sync/http.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/_sync/http.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/_sync/http.py 2020-02-02
01:00:00.000000000 +0100
@@ -4,6 +4,7 @@
import logging
import sys
+import time
import warnings
from collections.abc import Callable, Iterator, Mapping
from typing import Any, cast
@@ -13,9 +14,14 @@
from langgraph_sdk._shared.utilities import (
_orjson_default,
+ _reconnect_delay,
_validate_reconnect_location,
)
-from langgraph_sdk.errors import _raise_for_status_typed
+from langgraph_sdk.errors import (
+ InternalServerError,
+ RateLimitError,
+ _raise_for_status_typed,
+)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, iter_lines_raw
@@ -146,7 +152,8 @@
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
- """Send a request that automatically reconnects to Location header."""
+ """Send a request that reconnects to its Location header if the
response is
+ cut off, with backoff between attempts."""
request_headers, content = _encode_json(json)
if headers:
request_headers.update(headers)
@@ -176,13 +183,27 @@
stacklevel=2,
)
r.close()
- return self.request_reconnect(
- loc,
- "GET",
- headers=headers,
- # don't pass on_response so it's only called once
- reconnect_limit=reconnect_limit - 1,
- )
+ for attempt in range(reconnect_limit):
+ time.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
+ # A restart or a deploy looks like a refused connection, a
+ # dropped body, a 429 or a 5xx; other errors won't go away.
+ try:
+ return self.request_reconnect(
+ loc,
+ "GET",
+ headers=headers,
+ # don't pass on_response so it's only called once
+ reconnect_limit=0,
+ )
+ except httpx.HTTPStatusError as err:
+ status = err.response.status_code
+ if attempt == reconnect_limit - 1 or (
+ status != 429 and status < 500
+ ):
+ raise
+ except httpx.TransportError:
+ if attempt == reconnect_limit - 1:
+ raise
def stream(
self,
@@ -219,69 +240,99 @@
current_headers = dict(
request_headers if reconnect_path is None else
reconnect_headers
)
+ if last_event_id is not None:
+ current_headers = {
+ key: value
+ for key, value in current_headers.items()
+ if key.lower() != "last-event-id"
+ }
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
+ if reconnect_path is not None and not any(
+ key.lower() == "last-event-id" and value
+ for key, value in current_headers.items()
+ ):
+ warnings.warn(
+ "Reconnecting without a Last-Event-ID, so events sent
while "
+ "disconnected are not replayed. Only runs created with "
+ "stream_resumable=True send the event IDs a reconnect
resumes "
+ "from.",
+ stacklevel=2,
+ )
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
- _raise_for_status_typed(res)
- # 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:
- _validate_reconnect_location(
- self.client.base_url, reconnect_location
- )
- reconnect_path = reconnect_location
-
- decoder = SSEDecoder()
- try:
- for line in iter_lines_raw(res):
- sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
- if sse is not None:
+ reconnect_cause: httpx.HTTPError | None = None
+ try:
+ 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
+ _raise_for_status_typed(res)
+ # 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:
+ _validate_reconnect_location(
+ self.client.base_url, reconnect_location
+ )
+ reconnect_path = reconnect_location
+
+ decoder = SSEDecoder(last_event_id=last_event_id)
+ try:
+ for line in iter_lines_raw(res):
+ sse = decoder.decode(cast(bytes,
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 decoder.has_data:
+ # Progress since the last drop earns a
fresh
+ # set of reconnect attempts.
+ reconnect_attempts = 0
+ yield sse
+ except httpx.HTTPError as err:
+ # httpx.TransportError inherits from HTTPError, so
transient
+ # disconnects during streaming land here.
+ if reconnect_path is None:
+ raise
+ reconnect_cause = err
+ else:
+ 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:
+ if sse.event or decoder.has_data:
+ # See async stream implementation for
rationale on
+ # skipping empty flush events.
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:
- 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:
+ except (httpx.TransportError, RateLimitError, InternalServerError)
as err:
+ # A reconnect that can't open (connection refused, 429, 5xx or
a
+ # non-SSE body) usually means the server is restarting or
+ # mid-deploy, so retry it like a drop. Before the server sends
a
+ # Location there is nothing to reconnect to, so raise.
+ if reconnect_path is None:
+ raise
+ reconnect_cause = err
+ if reconnect_cause is not None:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
- )
+ ) from reconnect_cause
+ time.sleep(_reconnect_delay(reconnect_attempts - 1, base=1.0,
cap=5.0))
continue
break
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/langgraph_sdk/auth/__init__.py
new/langgraph_sdk-0.4.11/langgraph_sdk/auth/__init__.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/auth/__init__.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/auth/__init__.py 2020-02-02
01:00:00.000000000 +0100
@@ -811,7 +811,7 @@
def __call__(
self,
*,
- resources: str | Sequence[str],
+ resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
) -> Callable[[AHO], AHO]: ...
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/langgraph_sdk/sse.py
new/langgraph_sdk-0.4.11/langgraph_sdk/sse.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/sse.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/sse.py 2020-02-02
01:00:00.000000000 +0100
@@ -30,18 +30,14 @@
# See https://docs.python.org/3/glossary.html#term-universal-newlines
NEWLINE_CHARS = b"\n\r"
- # We always push a trailing `\r` into the next decode iteration.
- if self.trailing_cr:
- text = b"\r" + text
- self.trailing_cr = False
- if text.endswith(b"\r"):
- self.trailing_cr = True
- text = text[:-1]
+ if not text:
+ return []
+ if self.trailing_cr and text.startswith(b"\n"):
+ text = text[1:]
+ self.trailing_cr = text.endswith(b"\r")
if not text:
- # NOTE: the edge case input of empty text doesn't occur in
practice,
- # because other httpx internals filter out this value
- return [] # pragma: no cover
+ return []
trailing_newline = text[-1] in NEWLINE_CHARS
lines = cast(list[BytesLike], text.splitlines())
@@ -66,7 +62,7 @@
return lines
def flush(self) -> list[BytesLike]:
- if not self.buffer and not self.trailing_cr:
+ if not self.buffer:
return []
lines: list[BytesLike] = [self.buffer]
@@ -76,18 +72,25 @@
class SSEDecoder:
- def __init__(self) -> None:
+ def __init__(self, *, last_event_id: str | None = None) -> None:
self._event = ""
self._data = bytearray()
- self._last_event_id: str | None = None
+ self._last_event_id = last_event_id
self._retry: int | None = None
self._at_stream_start = True
+ self._has_data = False
+ self._id_changed = False
+
+ @property
+ def has_data(self) -> bool:
+ """Whether the last decoded block contained a data field."""
+ return self._has_data
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen.
- `None` until the stream sends an `id` field, and `""` after an empty
+ `None` until an ID is supplied or received, and `""` after an empty
one, which resets the reconnect cursor.
"""
@@ -101,17 +104,26 @@
line = line.removeprefix(b"\xef\xbb\xbf")
if not line:
+ self._has_data = bool(self._data)
if (
not self._event
and not self._data
- and not self._last_event_id
+ and not self._id_changed
and self._retry is None
):
return None
+ body = self._data[:-1]
+ data = None
+ if body and not body.isspace():
+ try:
+ data = orjson.loads(body)
+ except orjson.JSONDecodeError:
+ data = orjson.loads(body.decode(errors="replace"))
+
sse = StreamPart(
event=self._event,
- data=orjson.loads(self._data) if self._data else None, # ty:
ignore[invalid-argument-type]
+ data=data, # ty: ignore[invalid-argument-type]
id=self._last_event_id or None,
)
@@ -119,6 +131,7 @@
self._event = ""
self._data = bytearray()
self._retry = None
+ self._id_changed = False
return sse
@@ -131,16 +144,16 @@
value = value[1:]
if fieldname == b"event":
- self._event = value.decode()
+ self._event = value.decode(errors="replace")
elif fieldname == b"data":
- if self._data:
- self._data.extend(b"\n")
self._data.extend(value)
+ self._data.extend(b"\n")
elif fieldname == b"id":
if b"\0" in value:
pass
else:
- self._last_event_id = value.decode()
+ self._last_event_id = value.decode(errors="replace")
+ self._id_changed = True
elif fieldname == b"retry":
with contextlib.suppress(TypeError, ValueError):
self._retry = int(value)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langgraph_sdk-0.4.10/langgraph_sdk/stream/controller.py
new/langgraph_sdk-0.4.11/langgraph_sdk/stream/controller.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/stream/controller.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/stream/controller.py 2020-02-02
01:00:00.000000000 +0100
@@ -15,7 +15,6 @@
import asyncio
import contextlib
import logging
-import random
from collections import OrderedDict
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field
@@ -23,6 +22,7 @@
from langchain_protocol import Event, SubscribeParams
+from langgraph_sdk._shared.utilities import _reconnect_delay
from langgraph_sdk.stream.transport import AsyncProtocolTransport,
EventStreamHandle
# ---------------------------------------------------------------------------
@@ -275,11 +275,13 @@
async def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff and jitter for reconnect attempt
*attempt*."""
- base = self._reconnect_backoff_base
- cap = self._reconnect_backoff_cap
- delay = min(cap, base * (2**attempt))
- jitter = random.uniform(0, delay * 0.25)
- await asyncio.sleep(delay + jitter)
+ await asyncio.sleep(
+ _reconnect_delay(
+ attempt,
+ base=self._reconnect_backoff_base,
+ cap=self._reconnect_backoff_cap,
+ )
+ )
async def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a transport drop.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langgraph_sdk-0.4.10/langgraph_sdk/stream/sync_controller.py
new/langgraph_sdk-0.4.11/langgraph_sdk/stream/sync_controller.py
--- old/langgraph_sdk-0.4.10/langgraph_sdk/stream/sync_controller.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/langgraph_sdk/stream/sync_controller.py
2020-02-02 01:00:00.000000000 +0100
@@ -4,7 +4,6 @@
import contextlib
import logging
-import random
import threading
import time
from dataclasses import dataclass, field
@@ -13,6 +12,7 @@
from langchain_protocol import Event, SubscribeParams
+from langgraph_sdk._shared.utilities import _reconnect_delay
from langgraph_sdk.stream.errors import (
is_non_retryable_stream_error,
stream_error_frame,
@@ -282,11 +282,13 @@
def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff + jitter before a reconnect
attempt."""
- base = self._reconnect_backoff_base
- cap = self._reconnect_backoff_cap
- delay = min(cap, base * (2**attempt))
- jitter = random.uniform(0, delay * 0.25)
- time.sleep(delay + jitter)
+ time.sleep(
+ _reconnect_delay(
+ attempt,
+ base=self._reconnect_backoff_base,
+ cap=self._reconnect_backoff_cap,
+ )
+ )
def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a transport drop.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/tests/conftest.py
new/langgraph_sdk-0.4.11/tests/conftest.py
--- old/langgraph_sdk-0.4.10/tests/conftest.py 1970-01-01 01:00:00.000000000
+0100
+++ new/langgraph_sdk-0.4.11/tests/conftest.py 2020-02-02 01:00:00.000000000
+0100
@@ -0,0 +1,17 @@
+import asyncio
+import time
+
+import pytest
+
+
[email protected]
+def no_sleep(monkeypatch):
+ """Record backoff sleeps without actually waiting."""
+ sleeps: list[float] = []
+
+ async def fake_async_sleep(d):
+ sleeps.append(d)
+
+ monkeypatch.setattr(asyncio, "sleep", fake_async_sleep)
+ monkeypatch.setattr(time, "sleep", lambda d: sleeps.append(d))
+ return sleeps
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/tests/test_auth.py
new/langgraph_sdk-0.4.11/tests/test_auth.py
--- old/langgraph_sdk-0.4.10/tests/test_auth.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langgraph_sdk-0.4.11/tests/test_auth.py 2020-02-02 01:00:00.000000000
+0100
@@ -1,8 +1,49 @@
+import ast
+import inspect
+
import pytest
from langgraph_sdk import Auth
[email protected](
+ "kwargs",
+ [{}, {"actions": "read"}, {"resources": None}],
+)
+def test_global_handler_factory_accepts_optional_resources(kwargs) -> None:
+ auth = Auth()
+ tree = ast.parse(inspect.getsource(type(auth.on)))
+ cls = tree.body[0]
+ assert isinstance(cls, ast.ClassDef)
+ factory_overload = next(
+ node
+ for node in cls.body
+ if isinstance(node, ast.FunctionDef)
+ and node.name == "__call__"
+ and node.decorator_list
+ and any(arg.arg == "resources" for arg in node.args.kwonlyargs)
+ )
+ resources = next(
+ arg for arg in factory_overload.args.kwonlyargs if arg.arg ==
"resources"
+ )
+ assert resources.annotation is not None
+ assert ast.unparse(resources.annotation) == "str | Sequence[str] | None"
+ default = factory_overload.args.kw_defaults[0]
+ assert isinstance(default, ast.Constant) and default.value is None
+
+ @auth.on(**kwargs)
+ async def handler(ctx, value):
+ del ctx, value
+ return None
+
+ if kwargs.get("actions"):
+ assert auth._handlers == {("*", "read"): [handler]}
+ assert auth._global_handlers == []
+ else:
+ assert auth._global_handlers == [handler]
+ assert auth._handlers == {}
+
+
def test_handler_multiple_resources_and_actions() -> None:
auth = Auth()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/tests/test_client_stream.py
new/langgraph_sdk-0.4.11/tests/test_client_stream.py
--- old/langgraph_sdk-0.4.10/tests/test_client_stream.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/tests/test_client_stream.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import Iterator, Sequence
+from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
from typing import Any
@@ -13,6 +13,7 @@
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
from langgraph_sdk.client import HttpClient, SyncHttpClient
+from langgraph_sdk.errors import InternalServerError, NotFoundError
from langgraph_sdk.schema import (
CheckpointPayload,
CheckpointsStreamPart,
@@ -130,6 +131,110 @@
assert decoder.decode(b"") == StreamPart(event="values", data=1, id=None)
# ty: ignore[invalid-argument-type]
+def test_control_blocks_do_not_repeat_after_a_cursor_update():
+ decoder = SSEDecoder()
+ decoder.decode(b"id: cursor")
+ assert decoder.decode(b"") == StreamPart(event="", data=None, id="cursor")
# ty: ignore[invalid-argument-type]
+ decoder.decode(b": heartbeat")
+ assert decoder.decode(b"") is None
+ assert decoder.decode(b"") is None
+ decoder.decode(b"id:")
+ assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) #
ty: ignore[invalid-argument-type]
+ assert decoder.last_event_id == ""
+ assert decoder.decode(b"") is None
+
+
+def test_cr_lines_are_emitted_without_waiting_for_another_chunk():
+ decoder = BytesLineDecoder()
+ assert decoder.decode(b"data: 1\r\r") == [b"data: 1", b""]
+ assert decoder.decode(b"\n") == []
+ assert decoder.decode(b"data: 2\r") == [b"data: 2"]
+ assert decoder.decode(b"") == []
+ assert decoder.decode(b"\n\r") == [b""]
+ assert decoder.flush() == []
+
+
+def test_empty_data_lines_preserve_their_newlines():
+ decoder = SSEDecoder()
+ for line in (b"data", b"data: 1", b"data:"):
+ decoder.decode(line)
+ assert decoder._data == b"\n1\n\n"
+ assert decoder.decode(b"") == StreamPart(event="", data=1, id=None) # ty:
ignore[invalid-argument-type]
+ decoder.decode(b"data:")
+ decoder.decode(b"data:")
+ assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) #
ty: ignore[invalid-argument-type]
+ assert decoder.has_data
+ assert decoder.decode(b"") is None
+ assert not decoder.has_data
+
+
+def test_malformed_utf8_is_replaced_in_fields_and_json_strings():
+ decoder = SSEDecoder()
+ for line in (b"event: \xff", b"id: \xff", b'data: "\xff"'):
+ decoder.decode(line)
+ assert decoder.decode(b"") == StreamPart(event="\ufffd", data="\ufffd",
id="\ufffd") # ty: ignore[invalid-argument-type]
+
+
[email protected]("asynchronous", [False, True])
+async def test_null_and_empty_data_are_not_dropped(asynchronous: bool):
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ headers={"Content-Type": "text/event-stream"},
+ content=b"data: null\n\ndata:\n\n",
+ )
+
+ transport = httpx.MockTransport(handler)
+ if asynchronous:
+ async with httpx.AsyncClient(
+ transport=transport, base_url="https://example.com"
+ ) as client:
+ parts = [part async for part in
HttpClient(client).stream("/stream", "GET")]
+ else:
+ with httpx.Client(
+ transport=transport, base_url="https://example.com"
+ ) as client:
+ parts = list(SyncHttpClient(client).stream("/stream", "GET"))
+ assert parts == [StreamPart(event="", data=None), StreamPart(event="",
data=None)] # ty: ignore[invalid-argument-type]
+
+
[email protected]("asynchronous", [False, True])
+async def test_cursor_is_inherited_after_reconnect(asynchronous: bool):
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ if len(requests) == 1:
+ stream_cls = AsyncListByteStream if asynchronous else
ListByteStream
+ return httpx.Response(
+ 200,
+ headers={"Content-Type": "text/event-stream", "Location":
"/reconnect"},
+ stream=stream_cls(
+ [b"id: cursor\nevent: values\ndata: 1\n\n"],
+ httpx.RemoteProtocolError("incomplete chunked read"),
+ ),
+ )
+ assert (b"Last-Event-ID", b"cursor") in request.headers.raw
+ return httpx.Response(
+ 200,
+ headers={"Content-Type": "text/event-stream"},
+ content=b"event: values\ndata: 2\n\n",
+ )
+
+ transport = httpx.MockTransport(handler)
+ if asynchronous:
+ async with httpx.AsyncClient(
+ transport=transport, base_url="https://example.com"
+ ) as client:
+ parts = [part async for part in
HttpClient(client).stream("/stream", "GET")]
+ else:
+ with httpx.Client(
+ transport=transport, base_url="https://example.com"
+ ) as client:
+ parts = list(SyncHttpClient(client).stream("/stream", "GET"))
+ assert [part.id for part in parts] == ["cursor", "cursor"]
+
+
# --- HTTP client streaming ---
@@ -176,6 +281,7 @@
assert parts == [StreamPart(event="foo", data={"bar": 1})]
[email protected]("no_sleep")
def test_sync_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
@@ -241,6 +347,7 @@
@pytest.mark.asyncio
[email protected]("no_sleep")
async def test_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
@@ -312,18 +419,14 @@
]
-def _drop_after_an_empty_id(stream_cls: type, reconnects: list[httpx.Request]):
+def _drop_after(chunks: list[bytes], stream_cls: type, reconnects:
list[httpx.Request]):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream", "Location":
"/reconnect"},
stream=stream_cls(
- [
- b"id: 1\nevent: values\ndata: 1\n\n",
- b"id:\nevent: values\ndata: 2\n\n",
- ],
- httpx.RemoteProtocolError("incomplete chunked read"),
+ chunks, httpx.RemoteProtocolError("incomplete chunked
read")
),
)
reconnects.append(request)
@@ -336,26 +439,409 @@
return handler
-def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id():
+def _sync_reconnect(
+ chunks: list[bytes], headers: dict[str, str] | None
+) -> httpx.Request:
reconnects: list[httpx.Request] = []
- transport = httpx.MockTransport(_drop_after_an_empty_id(ListByteStream,
reconnects))
+ transport = httpx.MockTransport(_drop_after(chunks, ListByteStream,
reconnects))
with httpx.Client(transport=transport, base_url="https://example.com") as
client:
- list(SyncHttpClient(client).stream("/stream", "POST", json={}))
-
- assert "last-event-id" not in reconnects[0].headers
+ list(SyncHttpClient(client).stream("/stream", "POST", json={},
headers=headers))
+ return reconnects[0]
-async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id():
+async def _async_reconnect(
+ chunks: list[bytes], headers: dict[str, str] | None
+) -> httpx.Request:
reconnects: list[httpx.Request] = []
transport = httpx.MockTransport(
- _drop_after_an_empty_id(AsyncListByteStream, reconnects)
+ _drop_after(chunks, AsyncListByteStream, reconnects)
)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
- [part async for part in HttpClient(client).stream("/stream", "POST",
json={})]
+ [
+ part
+ async for part in HttpClient(client).stream(
+ "/stream", "POST", json={}, headers=headers
+ )
+ ]
+ return reconnects[0]
+
+
+_EMPTY_IDS = pytest.mark.parametrize(
+ "empty_id",
+ [b"id:\nevent: values\ndata: 2\n\n", b"id:\n\n"],
+ ids=["with-an-event", "alone"],
+)
+_CALLER_CURSORS = pytest.mark.parametrize(
+ "headers",
+ [None, {"Last-Event-ID": "5"}, {"last-event-id": "5"}],
+ ids=["no-caller-cursor", "caller-cursor", "lowercase-caller-cursor"],
+)
+
+
+@_EMPTY_IDS
+@_CALLER_CURSORS
[email protected]("no_sleep")
+def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id(
+ empty_id: bytes, headers: dict[str, str] | None
+):
+ with pytest.warns(UserWarning, match="Last-Event-ID"):
+ reconnect = _sync_reconnect(
+ [b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
+ )
+
+ assert "last-event-id" not in reconnect.headers
+
+
+@_EMPTY_IDS
+@_CALLER_CURSORS
[email protected]("no_sleep")
+async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id(
+ empty_id: bytes, headers: dict[str, str] | None
+):
+ with pytest.warns(UserWarning, match="Last-Event-ID"):
+ reconnect = await _async_reconnect(
+ [b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
+ )
+
+ assert "last-event-id" not in reconnect.headers
+
+
[email protected]("no_sleep")
+def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
+ reconnect = _sync_reconnect(
+ [b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
+ )
+
+ assert reconnect.headers.get_list("last-event-id") == ["1"]
+
+
[email protected]("no_sleep")
+async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
+ reconnect = await _async_reconnect(
+ [b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
+ )
+
+ assert reconnect.headers.get_list("last-event-id") == ["1"]
+
+
[email protected]("no_sleep")
+def test_sync_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
+ reconnect = _sync_reconnect([b"event: values\ndata: 1\n\n"],
{"Last-Event-ID": "5"})
+
+ assert reconnect.headers.get_list("last-event-id") == ["5"]
+
+
[email protected]("no_sleep")
+async def
test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
+ reconnect = await _async_reconnect(
+ [b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}
+ )
+
+ assert reconnect.headers.get_list("last-event-id") == ["5"]
+
+
+# A reconnect that sends no Last-Event-ID can't replay what was sent while it
was
+# disconnected, so it warns.
+_RECONNECT_CURSORS = pytest.mark.parametrize(
+ ("chunks", "headers", "warns"),
+ [
+ ([b"event: values\ndata: 1\n\n"], None, True),
+ ([b"id: 1\nevent: values\ndata: 1\n\n"], None, False),
+ ([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}, False),
+ ],
+ ids=["no-cursor", "stream-cursor", "caller-cursor"],
+)
+
+
+@_RECONNECT_CURSORS
[email protected]("no_sleep")
+def test_sync_reconnect_warns_only_without_a_cursor(
+ chunks: list[bytes],
+ headers: dict[str, str] | None,
+ warns: bool,
+ recwarn: pytest.WarningsRecorder,
+):
+ _sync_reconnect(chunks, headers)
+
+ assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
+
+
+@_RECONNECT_CURSORS
[email protected]("no_sleep")
+async def test_async_reconnect_warns_only_without_a_cursor(
+ chunks: list[bytes],
+ headers: dict[str, str] | None,
+ warns: bool,
+ recwarn: pytest.WarningsRecorder,
+):
+ await _async_reconnect(chunks, headers)
+
+ assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
+
+
+# --- reconnects survive a server restart or a deploy ---
+
+SSE = {"Content-Type": "text/event-stream"}
+DROP = httpx.RemoteProtocolError("incomplete chunked read")
+
+
+def _event(i: int) -> bytes:
+ return f"id: {i}\nevent: values\ndata: {i}\n\n".encode()
+
+
+def _scripted(steps: list[Callable[[httpx.Request], httpx.Response]]):
+ """Handler that plays one step per request and records each request."""
+ seen: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ if len(seen) > len(steps):
+ raise AssertionError("unexpected request")
+ return steps[len(seen) - 1](request)
+
+ return handler, seen
+
+
+def _ok(stream_cls, chunks, *, drop=False, location=None):
+ headers = dict(SSE)
+ if location:
+ headers["Location"] = location
+
+ def step(_request):
+ return httpx.Response(
+ 200, headers=headers, stream=stream_cls(chunks, DROP if drop else
None)
+ )
+
+ return step
+
+
+def _status(code):
+ return lambda request: httpx.Response(
+ code, json={"detail": "upstream"}, request=request
+ )
+
+
+def _connect_error(request):
+ raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
+
+
+def _not_sse(request):
+ return httpx.Response(
+ 200, headers={"Content-Type": "text/html"}, text="<html>",
request=request
+ )
+
+
+def _transient_steps(stream_cls, failure):
+ """POST drops after event 1; first reconnect fails; second succeeds."""
+ return [
+ _ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
+ failure,
+ _ok(stream_cls, [_event(2), b"event: end\ndata: null\n\n"]),
+ ]
+
+
+def _long_stream_steps(stream_cls, drops):
+ """POST + `drops` reconnects, each delivering one event then dropping."""
+ steps = [_ok(stream_cls, [_event(0)], drop=True, location="/reconnect")]
+ for i in range(1, drops):
+ steps.append(_ok(stream_cls, [_event(i)], drop=True))
+ steps.append(_ok(stream_cls, [_event(drops), b"event: end\ndata:
null\n\n"]))
+ return steps
+
+
+def _run_sync(steps):
+ handler, seen = _scripted(steps)
+ with httpx.Client(
+ transport=httpx.MockTransport(handler), base_url="https://example.com"
+ ) as client:
+ parts = list(SyncHttpClient(client).stream("/stream", "POST", json={}))
+ return parts, seen
+
+
+async def _run_async(steps):
+ sync_handler, seen = _scripted(steps)
+
+ async def handler(request):
+ return sync_handler(request)
+
+ async with httpx.AsyncClient(
+ transport=httpx.MockTransport(handler), base_url="https://example.com"
+ ) as client:
+ parts = [p async for p in HttpClient(client).stream("/stream", "POST",
json={})]
+ return parts, seen
+
+
+FAILURES = {
+ "429": _status(429),
+ "502": _status(502),
+ "503": _status(503),
+ "connect_error": _connect_error,
+ "not_sse": _not_sse,
+}
+
+
+# A reconnect that hits a 429 / 502 / 503 / connect error / non-SSE body, then
+# succeeds, should recover.
+
+
[email protected]("no_sleep")
[email protected]("failure", FAILURES.values(), ids=FAILURES.keys())
+def test_sync_reconnect_recovers_from_transient_failure(failure):
+ parts, seen = _run_sync(_transient_steps(ListByteStream, failure))
+ assert [p.event for p in parts] == ["values", "values", "end"]
+ assert len(seen) == 3
+
+
[email protected]("no_sleep")
[email protected]("failure", FAILURES.values(), ids=FAILURES.keys())
+async def test_async_reconnect_recovers_from_transient_failure(failure):
+ parts, seen = await _run_async(_transient_steps(AsyncListByteStream,
failure))
+ assert [p.event for p in parts] == ["values", "values", "end"]
+ assert len(seen) == 3
+
+
+# Any other 4xx on a reconnect won't go away by retrying, so it raises.
+
+
+def _client_error_steps(stream_cls):
+ return [
+ _ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
+ _status(404),
+ ]
+
+
[email protected]("no_sleep")
+def test_sync_reconnect_does_not_retry_a_client_error():
+ with pytest.raises(NotFoundError):
+ _run_sync(_client_error_steps(ListByteStream))
+
+
[email protected]("no_sleep")
+async def test_async_reconnect_does_not_retry_a_client_error():
+ with pytest.raises(NotFoundError):
+ await _run_async(_client_error_steps(AsyncListByteStream))
+
+
+# The first request isn't a reconnect, so it raises even when it names a
Location.
+
+
+def _first_request_503(request):
+ return httpx.Response(
+ 503,
+ headers={"Location": "/reconnect"},
+ json={"detail": "upstream"},
+ request=request,
+ )
+
+
[email protected]("no_sleep")
+def test_sync_first_request_failure_is_not_retried():
+ with pytest.raises(InternalServerError):
+ _run_sync([_first_request_503])
+
+
[email protected]("no_sleep")
+async def test_async_first_request_failure_is_not_retried():
+ with pytest.raises(InternalServerError):
+ await _run_async([_first_request_503])
+
+
+# A server that stays down gets five reconnects, backing off 1, 2, 4, 5 and 5s
+# (each plus up to 25% jitter), so about 20s, before the stream gives up. The
+# error it gives up with is caused by the last failure, so callers see why.
+
+GIVE_UP = "Exceeded maximum SSE reconnection attempts"
+
+
+def _server_down_steps(stream_cls):
+ return [
+ _ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
+ *[_status(502)] * 5,
+ ]
+
+
+def _assert_backed_off_for_about_20s(sleeps: list[float]) -> None:
+ assert len(sleeps) == 5
+ for slept, delay in zip(sleeps, [1, 2, 4, 5, 5], strict=True):
+ assert delay <= slept <= delay * 1.25
+
+
+def test_sync_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
+ with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
+ _run_sync(_server_down_steps(ListByteStream))
+ assert isinstance(exc_info.value.__cause__, InternalServerError)
+ _assert_backed_off_for_about_20s(no_sleep)
+
+
+async def
test_async_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
+ with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
+ await _run_async(_server_down_steps(AsyncListByteStream))
+ assert isinstance(exc_info.value.__cause__, InternalServerError)
+ _assert_backed_off_for_about_20s(no_sleep)
+
+
+# A long stream that drops 6 times, each reconnect succeeding and making
+# progress, should not run out of attempts.
+
+
[email protected]("no_sleep")
+def test_sync_long_stream_survives_many_spaced_out_drops():
+ parts, _ = _run_sync(_long_stream_steps(ListByteStream, drops=6))
+ assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
+
+
[email protected]("no_sleep")
+async def test_async_long_stream_survives_many_spaced_out_drops():
+ parts, _ = await _run_async(_long_stream_steps(AsyncListByteStream,
drops=6))
+ assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
+
+
+# Each reconnect resumes after the newest event the stream got, so a reconnect
+# loop on a resumable run replays only what it missed. A failed reconnect keeps
+# the cursor for the next one.
+RECONNECT_LOOPS = {
+ "progress_between_drops": (
+ lambda stream_cls: _long_stream_steps(stream_cls, drops=3),
+ [None, "0", "1", "2"],
+ ),
+ "failed_reconnect": (
+ lambda stream_cls: _transient_steps(stream_cls, _status(502)),
+ [None, "1", "1"],
+ ),
+}
+
+
[email protected]("no_sleep")
[email protected](
+ ("steps", "cursors"), RECONNECT_LOOPS.values(), ids=RECONNECT_LOOPS.keys()
+)
+def test_sync_reconnect_loop_resumes_after_the_newest_event(steps, cursors):
+ _, seen = _run_sync(steps(ListByteStream))
+ assert [r.headers.get("last-event-id") for r in seen] == cursors
+
+
[email protected]("no_sleep")
[email protected](
+ ("steps", "cursors"), RECONNECT_LOOPS.values(), ids=RECONNECT_LOOPS.keys()
+)
+async def test_async_reconnect_loop_resumes_after_the_newest_event(steps,
cursors):
+ _, seen = await _run_async(steps(AsyncListByteStream))
+ assert [r.headers.get("last-event-id") for r in seen] == cursors
+
+
+# Reconnects should back off rather than hammer the server.
+
+
+def test_sync_reconnect_backs_off(no_sleep):
+ _run_sync(_long_stream_steps(ListByteStream, drops=2))
+ assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
+
- assert "last-event-id" not in reconnects[0].headers
+async def test_async_reconnect_backs_off(no_sleep):
+ await _run_async(_long_stream_steps(AsyncListByteStream, drops=2))
+ assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
# --- _sse_to_v2_dict conversion ---
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/tests/test_runs_client.py
new/langgraph_sdk-0.4.11/tests/test_runs_client.py
--- old/langgraph_sdk-0.4.10/tests/test_runs_client.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/tests/test_runs_client.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,7 +1,8 @@
from __future__ import annotations
import json
-from collections.abc import AsyncIterator, Iterator
+from collections.abc import AsyncIterator, Callable, Iterator, Sequence
+from typing import Any
import httpx
import pytest
@@ -24,7 +25,16 @@
raise httpx.ReadError("connection dropped")
-def _wait_handler(requests: list[httpx.Request]):
+def _done(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"done": True})
+
+
+def _wait_handler(
+ requests: list[httpx.Request],
+ reconnects: Sequence[Callable[[httpx.Request], httpx.Response]] = (_done,),
+):
+ """POST drops mid-body; the reconnects answer from `reconnects` in
order."""
+
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.method == "POST":
@@ -33,7 +43,7 @@
headers={"Location": "/threads/t1/runs/r1/join"},
stream=_BodyDropsMidRead(),
)
- return httpx.Response(200, json={"done": True})
+ return reconnects[len(requests) - 2](request)
return handler
@@ -46,6 +56,7 @@
assert request.read() == b""
[email protected]("no_sleep")
def test_sync_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
@@ -58,6 +69,7 @@
_assert_bodyless_get(requests[1])
[email protected]("no_sleep")
async def test_async_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
@@ -72,6 +84,89 @@
_assert_bodyless_get(requests[1])
+def _connect_error(request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
+
+
+async def _wait(
+ handler: Callable[[httpx.Request], httpx.Response], *, asynchronous: bool
+) -> Any:
+ transport = httpx.MockTransport(handler)
+ if asynchronous:
+ async with httpx.AsyncClient(
+ transport=transport, base_url="https://example.com"
+ ) as client:
+ return await RunsClient(HttpClient(client)).wait("t1", "agent",
input={})
+ with httpx.Client(transport=transport, base_url="https://example.com") as
client:
+ return SyncRunsClient(SyncHttpClient(client)).wait("t1", "agent",
input={})
+
+
+# What a server restart or a deploy looks like to the reconnect GET: each one
is
+# retried after a backoff.
+RECONNECT_FAILURES = {
+ "429": lambda request: httpx.Response(429, request=request),
+ "503": lambda request: httpx.Response(503, request=request),
+ "connect_error": _connect_error,
+ "body_drops": lambda _request: httpx.Response(
+ 200,
+ headers={"Location": "/threads/t1/runs/r1/join"},
+ stream=_BodyDropsMidRead(),
+ ),
+}
+
+
[email protected]("asynchronous", [False, True])
[email protected](
+ "failure", RECONNECT_FAILURES.values(), ids=RECONNECT_FAILURES.keys()
+)
+async def test_wait_retries_a_failed_reconnect_after_a_backoff(
+ failure, asynchronous, no_sleep
+):
+ requests: list[httpx.Request] = []
+ with pytest.warns(UserWarning, match="attempting reconnect"):
+ result = await _wait(
+ _wait_handler(requests, [failure, _done]),
asynchronous=asynchronous
+ )
+
+ assert result == {"done": True}
+ assert len(requests) == 3
+ assert len(no_sleep) == 2 and min(no_sleep) >= 1, no_sleep
+
+
[email protected]("no_sleep")
[email protected]("asynchronous", [False, True])
+async def test_wait_raises_after_five_failed_reconnects(asynchronous):
+ requests: list[httpx.Request] = []
+ with (
+ pytest.warns(UserWarning, match="attempting reconnect"),
+ pytest.raises(httpx.HTTPStatusError, match="503"),
+ ):
+ await _wait(
+ _wait_handler(requests, [RECONNECT_FAILURES["503"]] * 5),
+ asynchronous=asynchronous,
+ )
+
+ assert len(requests) == 6
+
+
[email protected]("no_sleep")
[email protected]("asynchronous", [False, True])
+async def test_wait_does_not_retry_a_client_error(asynchronous):
+ requests: list[httpx.Request] = []
+ with (
+ pytest.warns(UserWarning, match="attempting reconnect"),
+ pytest.raises(httpx.HTTPStatusError, match="404"),
+ ):
+ await _wait(
+ _wait_handler(
+ requests, [lambda request: httpx.Response(404,
request=request)]
+ ),
+ asynchronous=asynchronous,
+ )
+
+ assert len(requests) == 2
+
+
def test_sync_wait_raises_the_run_error():
requests: list[httpx.Request] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langgraph_sdk-0.4.10/uv.lock
new/langgraph_sdk-0.4.11/uv.lock
--- old/langgraph_sdk-0.4.10/uv.lock 2020-02-02 01:00:00.000000000 +0100
+++ new/langgraph_sdk-0.4.11/uv.lock 2020-02-02 01:00:00.000000000 +0100
@@ -301,7 +301,7 @@
[[package]]
name = "langgraph"
-version = "1.2.18"
+version = "1.2.19"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },