Files
ZNJJ-api-server/test/agent/test_llm_error_mapping.py
T
Eric Wang ca73b1e8c5 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)
2026-10-22 21:50:56 +08:00

135 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")
)