feat(backends): map LLM auth/rate-limit errors to backend-neutral errors
Closes the error-code gap between LangGraph and FastGPT backends: the endpoint's 401/429 branches previously could never trigger under the LangGraph backend because all provider failures collapsed into LLMAPIError (500). - agent/llm: LLMAuthError / LLMRateLimitError as LLMAPIError subclasses; OpenAILLMService raises them from openai AuthenticationError (401, no retry) and RateLimitError (429, after bounded retries exhausted) - backends/langgraph: _complete_turn translates LLMAuthError -> ChatBackendAuthenticationError and LLMRateLimitError -> ChatBackendRateLimitError; stream reuses complete() so the mapping applies to both paths; other errors (timeout etc.) propagate unchanged - endpoint untouched: existing 401/429 response branches now fire for both backends Verified: 212 unit tests (7 new error-mapping tests)
This commit is contained in:
+24
-1
@@ -27,6 +27,16 @@ class LLMAPIError(LLMError):
|
|||||||
"""provider 返回错误或无效响应。"""
|
"""provider 返回错误或无效响应。"""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMAuthError(LLMAPIError):
|
||||||
|
"""provider 拒绝凭证(401)。不可重试,需区分上报(对照
|
||||||
|
FastGPT 后端的 ChatBackendAuthenticationError 语义)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMRateLimitError(LLMAPIError):
|
||||||
|
"""provider 限流(429)。重试耗尽后抛出,需区分上报(对照
|
||||||
|
FastGPT 后端的 ChatBackendRateLimitError 语义)。"""
|
||||||
|
|
||||||
|
|
||||||
class LLMService(Protocol):
|
class LLMService(Protocol):
|
||||||
"""节点依赖的最小 LLM 接口。"""
|
"""节点依赖的最小 LLM 接口。"""
|
||||||
|
|
||||||
@@ -128,7 +138,12 @@ class OpenAILLMService:
|
|||||||
) -> str:
|
) -> str:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from openai import APIConnectionError, APITimeoutError, RateLimitError
|
from openai import (
|
||||||
|
APIConnectionError,
|
||||||
|
APITimeoutError,
|
||||||
|
AuthenticationError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
messages = [
|
messages = [
|
||||||
@@ -163,7 +178,15 @@ class OpenAILLMService:
|
|||||||
retries += 1
|
retries += 1
|
||||||
await asyncio.sleep(min(2 ** retries, 10))
|
await asyncio.sleep(min(2 ** retries, 10))
|
||||||
continue
|
continue
|
||||||
|
if isinstance(exc, RateLimitError):
|
||||||
|
# 限流耗尽:区分上报,API 层映射为 429(两后端错误码等价)
|
||||||
|
raise LLMRateLimitError(
|
||||||
|
f"LLM provider 限流(重试 {self._max_retries} 次后仍失败)"
|
||||||
|
) from exc
|
||||||
raise LLMAPIError(f"LLM provider 不可用:{type(exc).__name__}") from exc
|
raise LLMAPIError(f"LLM provider 不可用:{type(exc).__name__}") from exc
|
||||||
|
except AuthenticationError as exc:
|
||||||
|
# 401 鉴权失败:不可重试,区分上报,API 层映射为 401
|
||||||
|
raise LLMAuthError("LLM provider 认证失败") from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# 其他错误(如 4xx 参数错)不可重试
|
# 其他错误(如 4xx 参数错)不可重试
|
||||||
raise LLMAPIError(f"LLM 调用失败:{type(exc).__name__}") from exc
|
raise LLMAPIError(f"LLM 调用失败:{type(exc).__name__}") from exc
|
||||||
|
|||||||
+27
-10
@@ -15,8 +15,11 @@
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from ..agent.graph import GraphDeps, build_graph, workflow_fake_llm
|
from ..agent.graph import GraphDeps, build_graph, workflow_fake_llm
|
||||||
|
from ..agent.llm import LLMAuthError, LLMRateLimitError
|
||||||
from .chat import (
|
from .chat import (
|
||||||
ChatBackend,
|
ChatBackend,
|
||||||
|
ChatBackendAuthenticationError,
|
||||||
|
ChatBackendRateLimitError,
|
||||||
ChatInput,
|
ChatInput,
|
||||||
ChatResult,
|
ChatResult,
|
||||||
ChatStreamEvent,
|
ChatStreamEvent,
|
||||||
@@ -49,6 +52,20 @@ class LangGraphBackend(ChatBackend):
|
|||||||
- 本阶段不处理 ``need_form_update`` 的真实表单差量
|
- 本阶段不处理 ``need_form_update`` 的真实表单差量
|
||||||
(作业 6 起实现)。
|
(作业 6 起实现)。
|
||||||
"""
|
"""
|
||||||
|
result = await self._complete_turn(chat_input)
|
||||||
|
return ChatResult(
|
||||||
|
content = result["response_text"],
|
||||||
|
status_code = None,
|
||||||
|
form_update = result.get("turn_patch") or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _complete_turn(self, chat_input: ChatInput) -> dict:
|
||||||
|
"""执行一轮图,把 LLM 层错误翻译为后端中立错误。
|
||||||
|
|
||||||
|
错误码等价(对照 FastGPT 后端):
|
||||||
|
LLMAuthError -> ChatBackendAuthenticationError(API 层 401);
|
||||||
|
LLMRateLimitError -> ChatBackendRateLimitError(API 层 429)。
|
||||||
|
"""
|
||||||
graph_input: dict = {
|
graph_input: dict = {
|
||||||
"session_id": chat_input.session_id,
|
"session_id": chat_input.session_id,
|
||||||
"input_text": chat_input.text,
|
"input_text": chat_input.text,
|
||||||
@@ -58,16 +75,16 @@ class LangGraphBackend(ChatBackend):
|
|||||||
# 使 set_info 写入后本轮 chat 立即可见(输入覆盖 checkpoint 同键)。
|
# 使 set_info 写入后本轮 chat 立即可见(输入覆盖 checkpoint 同键)。
|
||||||
if chat_input.injected_fields:
|
if chat_input.injected_fields:
|
||||||
graph_input.update(chat_input.injected_fields)
|
graph_input.update(chat_input.injected_fields)
|
||||||
result = await self._graph.ainvoke(
|
try:
|
||||||
graph_input,
|
return await self._graph.ainvoke(
|
||||||
config={"configurable": {"thread_id": chat_input.session_id}},
|
graph_input,
|
||||||
context=GraphDeps(llm=self._llm),
|
config={"configurable": {"thread_id": chat_input.session_id}},
|
||||||
)
|
context=GraphDeps(llm=self._llm),
|
||||||
return ChatResult(
|
)
|
||||||
content = result["response_text"],
|
except LLMAuthError as exc:
|
||||||
status_code = None,
|
raise ChatBackendAuthenticationError(str(exc)) from exc
|
||||||
form_update = result.get("turn_patch") or {}
|
except LLMRateLimitError as exc:
|
||||||
)
|
raise ChatBackendRateLimitError(str(exc)) from exc
|
||||||
|
|
||||||
async def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]:
|
async def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]:
|
||||||
"""流式:产生中立流事件。
|
"""流式:产生中立流事件。
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""LLM 错误分类测试:鉴权/限流可区分上报(两后端错误码等价的关键)。
|
||||||
|
|
||||||
|
对照 FastGPT 后端:401 -> ChatBackendAuthenticationError、
|
||||||
|
429 -> ChatBackendRateLimitError(endpoint 已有对应响应分支)。
|
||||||
|
LangGraph 链路由 OpenAILLMService 抛出可区分子类、
|
||||||
|
LangGraphBackend 翻译为 ChatBackend* 错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from openai import AuthenticationError, RateLimitError
|
||||||
|
|
||||||
|
from src.agent.llm import (
|
||||||
|
LLMAPIError,
|
||||||
|
LLMAuthError,
|
||||||
|
LLMRateLimitError,
|
||||||
|
OpenAILLMService,
|
||||||
|
)
|
||||||
|
from src.backends.chat import (
|
||||||
|
ChatBackendAuthenticationError,
|
||||||
|
ChatBackendRateLimitError,
|
||||||
|
ChatInput,
|
||||||
|
)
|
||||||
|
from src.backends.langgraph import LangGraphBackend
|
||||||
|
|
||||||
|
|
||||||
|
def _status_error(cls, status: int) -> Exception:
|
||||||
|
request = httpx.Request("POST", "http://llm.local/v1/chat/completions")
|
||||||
|
response = httpx.Response(status, request=request)
|
||||||
|
return cls("error", response=response, body=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_with_failing_llm(exc: Exception, max_retries: int = 0):
|
||||||
|
"""complete 固定抛 exc 的 OpenAILLMService(client 打桩)。"""
|
||||||
|
llm = OpenAILLMService(model="test", api_key="test", max_retries=max_retries)
|
||||||
|
create = AsyncMock(side_effect=exc)
|
||||||
|
llm._client = SimpleNamespace(
|
||||||
|
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||||
|
)
|
||||||
|
return llm, create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authentication_error_raises_llm_auth_error_without_retry():
|
||||||
|
llm, create = _service_with_failing_llm(
|
||||||
|
_status_error(AuthenticationError, 401), max_retries=2
|
||||||
|
)
|
||||||
|
with pytest.raises(LLMAuthError):
|
||||||
|
await llm.complete("系统", "输入")
|
||||||
|
# 401 不可重试:只调用一次
|
||||||
|
assert create.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_error_retries_then_raises_llm_rate_limit_error():
|
||||||
|
llm, create = _service_with_failing_llm(
|
||||||
|
_status_error(RateLimitError, 429), max_retries=2
|
||||||
|
)
|
||||||
|
with pytest.raises(LLMRateLimitError):
|
||||||
|
await llm.complete("系统", "输入")
|
||||||
|
assert create.call_count == 3 # 首次 + 2 次重试
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_error_subclasses_are_api_errors():
|
||||||
|
"""子类兼容既有 LLMAPIError 捕获(不破坏现有 except 顺序)。"""
|
||||||
|
assert issubclass(LLMAuthError, LLMAPIError)
|
||||||
|
assert issubclass(LLMRateLimitError, LLMAPIError)
|
||||||
|
|
||||||
|
|
||||||
|
class _GraphRaising:
|
||||||
|
"""ainvoke 固定抛指定异常的图替身。"""
|
||||||
|
|
||||||
|
def __init__(self, exc: Exception):
|
||||||
|
self._exc = exc
|
||||||
|
|
||||||
|
async def ainvoke(self, *args, **kwargs):
|
||||||
|
raise self._exc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_langgraph_backend_maps_auth_error_to_backend_error():
|
||||||
|
backend = LangGraphBackend(
|
||||||
|
graph=_GraphRaising(LLMAuthError("bad key")),
|
||||||
|
llm=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
with pytest.raises(ChatBackendAuthenticationError):
|
||||||
|
await backend.complete(
|
||||||
|
ChatInput(session_id="s1", text="你好", client_mode="direct")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_langgraph_backend_maps_rate_limit_to_backend_error():
|
||||||
|
backend = LangGraphBackend(
|
||||||
|
graph=_GraphRaising(LLMRateLimitError("限流")),
|
||||||
|
llm=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
with pytest.raises(ChatBackendRateLimitError):
|
||||||
|
await backend.complete(
|
||||||
|
ChatInput(session_id="s1", text="你好", client_mode="direct")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_langgraph_backend_stream_propagates_mapped_errors():
|
||||||
|
"""stream 经由 complete 实现,映射自动生效。"""
|
||||||
|
backend = LangGraphBackend(
|
||||||
|
graph=_GraphRaising(LLMAuthError("bad key")),
|
||||||
|
llm=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
with pytest.raises(ChatBackendAuthenticationError):
|
||||||
|
async for _ in backend.stream(
|
||||||
|
ChatInput(session_id="s1", text="你好", client_mode="direct")
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_langgraph_backend_other_llm_errors_still_raise_api_error():
|
||||||
|
"""其余 LLM 错误保持原样上抛(endpoint 落 500 通用分支)。"""
|
||||||
|
from src.agent.llm import LLMAPIError, LLMTimeoutError
|
||||||
|
|
||||||
|
backend = LangGraphBackend(
|
||||||
|
graph=_GraphRaising(LLMTimeoutError("超时")),
|
||||||
|
llm=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
with pytest.raises(LLMTimeoutError):
|
||||||
|
await backend.complete(
|
||||||
|
ChatInput(session_id="s1", text="你好", client_mode="direct")
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user