fix: handle httpx.ReadError, WriteError, and ConnectError in LLM streaming clients (#8243)

Adds explicit handling for httpx network errors (ReadError, WriteError,
ConnectError) in AnthropicClient, OpenAIClient, and GoogleVertexClient.
These errors can occur during streaming when the connection is unexpectedly
closed while reading/writing data.

Maps these errors to LLMConnectionError for consistent error handling.

Fixes #8221 (and duplicate #8156)

🤖 Generated with [Letta Code](https://letta.com)

Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: Kian Jones <11655409+kianjones9@users.noreply.github.com>
This commit is contained in:
github-actions[bot]
2026-01-08 11:52:46 -08:00
committed by Caren Thomas
parent e60e8ed670
commit f2171447a8
4 changed files with 99 additions and 1 deletions

View File

@@ -3,7 +3,7 @@ import httpx
import pytest
from letta.adapters.letta_llm_stream_adapter import LettaLLMStreamAdapter
from letta.errors import ContextWindowExceededError, LLMServerError
from letta.errors import ContextWindowExceededError, LLMConnectionError, LLMServerError
from letta.llm_api.anthropic_client import AnthropicClient
from letta.schemas.llm_config import LLMConfig
@@ -91,6 +91,74 @@ async def test_letta_llm_stream_adapter_converts_anthropic_413_request_too_large
pass
@pytest.mark.asyncio
async def test_letta_llm_stream_adapter_converts_httpx_read_error(monkeypatch):
"""Regression: httpx.ReadError raised during streaming should be converted to LLMConnectionError."""
class FakeAsyncStream:
"""Mimics anthropic.AsyncStream enough for AnthropicStreamingInterface (async cm + async iterator)."""
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
def __aiter__(self):
return self
async def __anext__(self):
raise httpx.ReadError("Connection closed unexpectedly")
async def fake_stream_async(self, request_data: dict, llm_config: LLMConfig):
return FakeAsyncStream()
monkeypatch.setattr(AnthropicClient, "stream_async", fake_stream_async, raising=True)
llm_client = AnthropicClient()
llm_config = LLMConfig(model="claude-sonnet-4-5-20250929", model_endpoint_type="anthropic", context_window=200000)
adapter = LettaLLMStreamAdapter(llm_client=llm_client, llm_config=llm_config)
gen = adapter.invoke_llm(request_data={}, messages=[], tools=[], use_assistant_message=True)
with pytest.raises(LLMConnectionError):
async for _ in gen:
pass
@pytest.mark.asyncio
async def test_letta_llm_stream_adapter_converts_httpx_write_error(monkeypatch):
"""Regression: httpx.WriteError raised during streaming should be converted to LLMConnectionError."""
class FakeAsyncStream:
"""Mimics anthropic.AsyncStream enough for AnthropicStreamingInterface (async cm + async iterator)."""
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
def __aiter__(self):
return self
async def __anext__(self):
raise httpx.WriteError("Failed to write to connection")
async def fake_stream_async(self, request_data: dict, llm_config: LLMConfig):
return FakeAsyncStream()
monkeypatch.setattr(AnthropicClient, "stream_async", fake_stream_async, raising=True)
llm_client = AnthropicClient()
llm_config = LLMConfig(model="claude-sonnet-4-5-20250929", model_endpoint_type="anthropic", context_window=200000)
adapter = LettaLLMStreamAdapter(llm_client=llm_client, llm_config=llm_config)
gen = adapter.invoke_llm(request_data={}, messages=[], tools=[], use_assistant_message=True)
with pytest.raises(LLMConnectionError):
async for _ in gen:
pass
def test_anthropic_client_handle_llm_error_413_status_code():
"""Test that handle_llm_error correctly converts 413 status code to ContextWindowExceededError."""
client = AnthropicClient()