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.
This commit is contained in:
@@ -104,3 +104,43 @@ def test_assignment6_acceptance_flow(client):
|
||||
"text": "新对话", "clientMode": "direct",
|
||||
})
|
||||
assert response.json()["code"] == "200"
|
||||
|
||||
|
||||
def test_delete_checkpoint_failure_returns_error_and_retry_cleans_session(client, monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from src.core import fastgpt_client as wiring
|
||||
|
||||
session = "delete-retry"
|
||||
payload = {"sessionId": session, "timeStamp": TS}
|
||||
assert client.post("/set_info", json={
|
||||
**payload, "key": "hphm1", "value": "test-plate",
|
||||
}).json()["code"] == "200"
|
||||
assert client.post("/chat", json={
|
||||
**payload, "text": "新对话",
|
||||
}).json()["nextStageCode"] == "1001"
|
||||
saver = wiring._langgraph_graph.checkpointer
|
||||
config = {"configurable": {"thread_id": session}}
|
||||
assert saver.get_tuple(config) is not None
|
||||
original_delete = saver.adelete_thread
|
||||
failing_delete = AsyncMock(side_effect=ConnectionError("private storage detail"))
|
||||
monkeypatch.setattr(saver, "adelete_thread", failing_delete)
|
||||
|
||||
response = client.request("DELETE", "/delete_session", json=payload)
|
||||
assert response.json()["code"] == "500"
|
||||
assert response.json()["msg"] == "删除会话失败"
|
||||
assert "private storage detail" not in response.text
|
||||
failing_delete.assert_awaited_once_with(session)
|
||||
assert saver.get_tuple(config) is not None
|
||||
assert json.loads(client.post("/get_info", json={
|
||||
**payload, "key": "hphm1",
|
||||
}).json()["value"]) == "test-plate"
|
||||
|
||||
monkeypatch.setattr(saver, "adelete_thread", original_delete)
|
||||
assert client.request("DELETE", "/delete_session", json=payload).json()["code"] == "200"
|
||||
assert saver.get_tuple(config) is None
|
||||
assert json.loads(client.post("/get_info", json={
|
||||
**payload, "key": "hphm1",
|
||||
}).json()["value"]) == ""
|
||||
assert client.post("/chat", json={
|
||||
**payload, "text": "新对话",
|
||||
}).json()["nextStageCode"] == "1001"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
Reference in New Issue
Block a user