Files
langgraph/libs/sdk-py/tests/streaming/test_stream_errors.py
T
Elior Nataf LackritzandGitHub 9e6fc45c6f fix(sdk-py): surface thread stream errors and stop retrying a rejected stream (#9317)
## 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`.
2026-10-16 15:29:34 -04:00

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