diff --git a/adapters/llm.py b/adapters/llm.py index 6af3b3d..4445dae 100644 --- a/adapters/llm.py +++ b/adapters/llm.py @@ -179,7 +179,10 @@ def _is_transient_error(exc: Exception) -> bool: 返回: True 表示可重试,False 表示不可重试。 """ - if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout)): + # 两族基类覆盖断连族:TimeoutException(ConnectTimeout/ReadTimeout/WriteTimeout/PoolTimeout) + # 与 TransportError(ConnectError/ReadError/RemoteProtocolError 等)。 + # 注意 HTTPStatusError 非 TransportError 子类,401/403 致命分支不受影响。 + if isinstance(exc, (httpx.TimeoutException, httpx.TransportError)): return True if isinstance(exc, httpx.HTTPStatusError): return exc.response.status_code in _TRANSIENT_STATUS_CODES diff --git a/tests/unit/test_governed_llm.py b/tests/unit/test_governed_llm.py index c75d2b8..cc483a0 100644 --- a/tests/unit/test_governed_llm.py +++ b/tests/unit/test_governed_llm.py @@ -259,6 +259,16 @@ async def test_qwen_thinking_stripped(): assert thinking2 == "" +def test_transient_covers_disconnect_family(): + """瞬时错误清单覆盖断连族(RemoteProtocolError/ReadError/ConnectTimeout/PoolTimeout)。""" + from adapters.llm import _is_transient_error + + assert _is_transient_error(httpx.RemoteProtocolError("peer reset")) + assert _is_transient_error(httpx.ReadError("read")) + assert _is_transient_error(httpx.ConnectTimeout("ct")) + assert _is_transient_error(httpx.PoolTimeout("pt")) + + @pytest.mark.asyncio async def test_truncated_stream_without_done_raises(): """SSE 流耗尽但未收 [DONE] → _SseAnomaly(进重试,不当成功)。"""