Files
ZNJJ-api-server/test/api/test_stream_error_codes.py
T
Eric Wang 3caf17494f fix(api): report checkpoint deletion failures and classify SSE errors
Delete checkpoints before removing business fields. Suppress only an
explicit CheckpointNotFoundError; propagate storage failures and
cancellation so the endpoint cannot report false success. Prefer async
checkpoint deletion and fail clearly when cleanup is unsupported.

Classify SSE error events as 401 for authentication, 429 for rate limits,
and 500 for other failures, using the same fixed messages as non-stream
responses. Errors terminate the stream without a success done event or
provider exception text in the response.

Validation: 286 offline tests passed (19 added), including failed-delete
HTTP retry with preserved business/checkpoint state, explicit missing
threads, cancellation, missing cleanup capability, and HTTP SSE errors
before and after text output.
2026-10-23 19:48:29 +08:00

77 lines
3.1 KiB
Python

import json
import httpx
import pytest
from fastapi import FastAPI
from src.agent.collect import validated_reply
from src.agent.llm import LLMAPIError, LLMAuthError, LLMRateLimitError
from src.api.endpoints import router
from src.backends.langgraph import LangGraphBackend
from src.core.config import Config
from src.core.fastgpt_client import get_chat_backend
class FailingLLM:
def __init__(self, error_type, partial):
self.error_type = error_type
self.partial = partial
async def complete(self, *args, **kwargs):
raise self.error_type('private provider detail')
async def stream_tokens(self, *args, **kwargs):
if self.partial:
yield '<state>1002</state>'
yield 'partial reply'
raise self.error_type('private provider detail')
class Graph:
async def ainvoke(self, data, *, config, context):
await validated_reply(context.llm, 'system', 'user', '1002')
raise AssertionError('failed turn must not complete')
def sse_events(body):
events = []
for block in body.strip().split('\n\n'):
lines = block.splitlines()
kind = next(line[7:] for line in lines if line.startswith('event: '))
data = json.loads(next(line[6:] for line in lines if line.startswith('data: ')))
events.append((kind, data))
return events
@pytest.mark.asyncio
@pytest.mark.parametrize('partial', [False, True], ids=['before-text', 'after-text'])
@pytest.mark.parametrize('error_type,code,message', [
(LLMAuthError, '401', '认证失败'),
(LLMRateLimitError, '429', '请求过于频繁,请稍后重试'),
(LLMAPIError, '500', '大模型服务器无响应'),
])
async def test_http_sse_errors_match_non_stream_codes_and_terminate(
monkeypatch, partial, error_type, code, message,
):
# 独立协议测试 app:实际 LangGraphBackend/LLM 错误映射,不启动真实 provider。
monkeypatch.setattr(Config, 'AGENT_BACKEND', 'fastgpt')
app = FastAPI()
app.include_router(router)
backend = LangGraphBackend(graph=Graph(), llm=FailingLLM(error_type, partial))
app.dependency_overrides[get_chat_backend] = lambda: backend
payload = {'sessionId': 'error-audit', 'timeStamp': '20261022120000', 'text': 'hello'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
response = await client.post('/chat?stream=true', json=payload)
assert response.status_code == 200 # 错误分类在 SSE data.code,不改已发送的 HTTP 头
assert response.headers['content-type'].startswith('text/event-stream')
events = sse_events(response.text)
assert events[-1] == ('error', {'code': code, 'msg': message})
assert sum(kind == 'error' for kind, _ in events) == 1
assert all(kind != 'done' for kind, _ in events)
assert any(kind == 'text_delta' for kind, _ in events) is partial
assert 'private provider detail' not in response.text
result = await client.post('/chat', json=payload)
assert result.json()['code'] == code
assert result.json()['msg'] == message