mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-19 14:31:47 +02:00
## Summary
Thread streams in `langgraph_sdk` now report why they ended. An `id:
null` error frame becomes the error `thread.output` raises, the sync
client no longer blocks forever when the server closes the stream, and a
stream the server rejects with a 4xx ends at once instead of being
retried.
## Problem
- A server sends `{"type": "error", "id": null, ...}` just before it
closes a thread stream whose read failed. The SDK dropped the frame, so
`thread.output` raised a generic "lifecycle stream ended before terminal
event" (async) or blocked forever (sync: the lifecycle watcher returned
without settling the run).
- A 403 or 404 on the stream request was retried like a network error,
then reported as `RuntimeError("Lifecycle transport failed: ...")` with
no status. A server that rejects a WebSocket after accepting it closes
with 4000 plus the HTTP status (4403, 4404, 4422), since a browser can't
read a refused upgrade's status. The shared stream counted each such
reconnect as a success and reconnected forever.
## Fix
- The lifecycle watcher fails the run with the frame's error on a clean
close (both clients). The shared stream logs the frame instead of
dropping it: projections still end when the stream closes, as before,
and `thread.output` carries the error.
- A 4xx other than 408 and 429, as a status or as a 4000+status close
code, ends the stream without retries. `thread.output` now raises that
`httpx.HTTPStatusError` or `websockets.exceptions.ConnectionClosedError`
instead of the generic `RuntimeError`.
- For a minted `thread_id`, the lifecycle watcher starts after
`run.start` creates the thread. Without this, the 4xx stop would turn a
stream opened under custom auth for a not-yet-created thread from
retried into failed. The sync client starts it under a lock, since
`run.start` can be called from several threads.
- Drops the first of two `SyncStreamController._reconnect_shared_stream`
definitions. It was shadowed by the second and never ran.
Not in this PR: over WebSocket the server closes with 1011 after an
error frame, which is still retried, while over SSE the close is clean
and fails the stream. Reconnecting after a clean close, as
`langchain-ai/langgraphjs#2812` does for the JS SDK, is a separate
change.
## Test plan
- [x] The 10 new behavior tests (async and sync: error frame, clean
close, 404, 4404 close, reconnect on 404, minted `thread_id`) fail on
`main` and pass here; a race test checks that two concurrent `run.start`
calls start one watcher.
- [x] `make test` in `libs/sdk-py`: 541 passed. `make lint` clean,
`check_sdk_methods.py` passes.
- [x] `RemoteGraph` v3 tests in `libs/langgraph`
(`test_remote_graph_v3.py`,
`test_stream_events_v3_kwarg_forwarding.py`): 94 passed against this
SDK.
- [x] Against a local server with custom auth: a thread the caller can't
read fails at once with 404 (SSE) or 4404 (WebSocket), and a new thread
works with a 1 s pause before `run.start`, which fails on `main`.
68 lines
2.3 KiB
Python
68 lines
2.3 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
from websockets.datastructures import Headers
|
|
from websockets.exceptions import ConnectionClosedError, InvalidStatus
|
|
from websockets.frames import Close
|
|
from websockets.http11 import Response
|
|
|
|
from langgraph_sdk.stream.errors import (
|
|
is_non_retryable_stream_error,
|
|
stream_error_frame,
|
|
)
|
|
|
|
|
|
def _http_error(status: int) -> httpx.HTTPStatusError:
|
|
request = httpx.Request("POST", "http://test/threads/t-1/stream/events")
|
|
response = httpx.Response(status, request=request)
|
|
return httpx.HTTPStatusError("scripted", request=request, response=response)
|
|
|
|
|
|
def _ws_close(code: int) -> ConnectionClosedError:
|
|
return ConnectionClosedError(Close(code, "scripted"), None)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "expected"),
|
|
[
|
|
pytest.param(_http_error(403), True, id="http-403"),
|
|
pytest.param(_http_error(404), True, id="http-404"),
|
|
pytest.param(_http_error(408), False, id="http-408"),
|
|
pytest.param(_http_error(429), False, id="http-429"),
|
|
pytest.param(_http_error(503), False, id="http-503"),
|
|
pytest.param(_ws_close(4404), True, id="ws-close-4404"),
|
|
pytest.param(_ws_close(4422), True, id="ws-close-4422"),
|
|
pytest.param(_ws_close(4429), False, id="ws-close-4429"),
|
|
pytest.param(_ws_close(4503), False, id="ws-close-4503"),
|
|
pytest.param(_ws_close(1011), False, id="ws-close-1011"),
|
|
pytest.param(
|
|
InvalidStatus(Response(403, "Forbidden", Headers())),
|
|
True,
|
|
id="ws-upgrade-403",
|
|
),
|
|
pytest.param(RuntimeError("network down"), False, id="no-status"),
|
|
pytest.param(None, False, id="none"),
|
|
],
|
|
)
|
|
def test_is_non_retryable_stream_error(
|
|
error: BaseException | None, expected: bool
|
|
) -> None:
|
|
assert is_non_retryable_stream_error(error) is expected
|
|
|
|
|
|
def test_stream_error_frame_reads_only_unsolicited_error_frames() -> None:
|
|
frame = {
|
|
"type": "error",
|
|
"id": None,
|
|
"error": "unknown_error",
|
|
"message": "Thread event stream failed.",
|
|
}
|
|
|
|
assert (
|
|
str(stream_error_frame(frame))
|
|
== "Protocol error [unknown_error]: Thread event stream failed."
|
|
)
|
|
assert stream_error_frame({**frame, "id": 3}) is None
|
|
assert stream_error_frame({"type": "event", "method": "values"}) is None
|