fix: retry httpx timeout family as transient LLM errors

Live incident: the compatible-mode provider stalled mid-stream after
200 OK (~17s without data) and raised httpx.ReadTimeout, which was not
in the retriable exception-name set, so the call failed after 1
attempt and killed the whole run. Add ReadTimeout/ConnectTimeout/
WriteTimeout/PoolTimeout/TimeoutError/ConnectError to the transient
set in LLMErrorHandlingMiddleware (retry with backoff, then a
transient fallback message instead of generic), and add ReadTimeout/
ConnectTimeout to ai_writing's classify_exception for model
switching.
This commit is contained in:
董宏凯 committed 2026-08-30 15:53:51 +08:00
1 parent 4964967295
commit f7e6ed65bd
4 files changed
+88 -1

No files matched your search

+2
View File
@@ -130,6 +130,8 @@ offline-backend-20260512/backend/tests/*
# Writing-mode first-report pipeline enforcement regressions.
!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py
!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py
# LLM call timeout retry classification regression coverage.
!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py
deploy
.history
@@ -57,7 +57,7 @@ _UPSTREAM_PATTERNS = ("bad_response_status_code", "bad response status",
"expecting ',' delimiter", "expecting value", "expecting property name",
"unexpected character", "unterminated string", "jsondecodeerror")
_TRANSIENT_EXC_NAMES = {"APITimeoutError", "APIConnectionError", "InternalServerError",
"ReadError", "RemoteProtocolError", "TimeoutError"}
"ReadError", "RemoteProtocolError", "ReadTimeout", "ConnectTimeout", "TimeoutError"}
def classify_exception(exc: BaseException) -> str:
@@ -226,6 +226,15 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
"InternalServerError",
"ReadError", # httpx.ReadError: connection dropped mid-stream
"RemoteProtocolError", # httpx: server closed connection unexpectedly
# httpx.TimeoutException family escapes the streaming path unwrapped
# (seen live: httpx.ReadTimeout mid-generation after 200 OK — the
# provider stalled ~17s; that must retry, not fail the whole run).
"ReadTimeout",
"ConnectTimeout",
"WriteTimeout",
"PoolTimeout",
"TimeoutError", # asyncio chunk timeout / builtin timeout
"ConnectError", # httpx: connection refused / DNS blip
}:
return True, "transient"
if status_code in _RETRIABLE_STATUS_CODES:
@@ -0,0 +1,76 @@
"""LLM 错误分类:httpx 超时族必须可重试(瞬时网络故障不能直接终结 run)。
线上实测:兼容模式流式响应中途 200 OK 后停发数据 ~17s,抛 ``httpx.ReadTimeout``。
旧名单只认 ``ReadError`` / ``RemoteProtocolError``,ReadTimeout 被归为 generic
(不可重试),1 次尝试即兜底报错、整个 run 终止。
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import httpx
from langchain_core.messages import AIMessage
from deerflow.agents.middlewares.llm_error_handling_middleware import LLM_ERROR_MARKER, LLMErrorHandlingMiddleware
def _middleware() -> LLMErrorHandlingMiddleware:
config = SimpleNamespace(
circuit_breaker=SimpleNamespace(failure_threshold=3, recovery_timeout_sec=30),
)
middleware = LLMErrorHandlingMiddleware(app_config=config)
middleware.retry_base_delay_ms = 0
middleware.retry_cap_delay_ms = 0
return middleware
def test_httpx_read_timeout_is_transient() -> None:
assert _middleware()._classify_error(httpx.ReadTimeout("read timed out")) == (True, "transient")
def test_httpx_timeout_family_is_transient() -> None:
middleware = _middleware()
for exc in (
httpx.ConnectTimeout("connect timed out"),
httpx.WriteTimeout("write timed out"),
httpx.PoolTimeout("pool timed out"),
httpx.ConnectError("connection refused"),
TimeoutError(),
):
assert middleware._classify_error(exc) == (True, "transient"), exc
def test_value_error_stays_generic() -> None:
assert _middleware()._classify_error(ValueError("boom")) == (False, "generic")
def test_read_timeout_is_retried_and_recovers() -> None:
"""第一次 ReadTimeout、第二次成功:必须重试并返回真实结果。"""
middleware = _middleware()
calls = {"count": 0}
async def handler(_request):
calls["count"] += 1
if calls["count"] == 1:
raise httpx.ReadTimeout("read timed out")
return AIMessage(content="ok")
result = asyncio.run(middleware.awrap_model_call(SimpleNamespace(), handler))
assert calls["count"] == 2
assert result.content == "ok"
def test_persistent_read_timeout_falls_back_with_transient_reason() -> None:
"""连续超时到重试上限:兜底消息 reason=transient(而非 generic)。"""
middleware = _middleware()
async def handler(_request):
raise httpx.ReadTimeout("read timed out")
result = asyncio.run(middleware.awrap_model_call(SimpleNamespace(), handler))
assert isinstance(result, AIMessage)
assert result.additional_kwargs[LLM_ERROR_MARKER] == "transient"