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:
Eric Wang
2026-10-23 19:48:29 +08:00
parent 24e2cfbe10
commit 3caf17494f
9 changed files with 260 additions and 15 deletions
+3 -1
View File
@@ -211,8 +211,10 @@ async def test_delete_clears_business_state_and_is_idempotent():
@pytest.mark.asyncio
async def test_delete_tolerates_missing_checkpoint():
from src.services.repository import CheckpointNotFoundError
async def missing(_session_id):
raise RuntimeError("no such thread")
raise CheckpointNotFoundError("no such thread")
service, _ = make_service()
await service.set_info("s1", "xm1", "王五")
@@ -0,0 +1,99 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from src.core import fastgpt_client as wiring
from src.services.app_service import AccidentAppService
from src.services.repository import CheckpointNotFoundError, InMemorySessionRepository
@pytest.mark.asyncio
@pytest.mark.parametrize('error', [
ConnectionError('storage offline'), TimeoutError('storage timeout'),
RuntimeError('no such thread'), KeyError('storage schema'),
])
async def test_checkpoint_failure_keeps_business_fields_and_allows_retry(error):
repo = InMemorySessionRepository()
service = AccidentAppService(SimpleNamespace(), repo)
await service.set_info('s', 'hphm1', 'test-plate')
drop = AsyncMock(side_effect=error)
with pytest.raises(type(error)):
await service.delete_session('s', drop)
assert await service.get_info('s', 'hphm1') == '"test-plate"'
assert not service._locks._entries
drop.side_effect = None
await service.delete_session('s', drop)
assert await service.get_info('s', 'hphm1') == '""'
@pytest.mark.asyncio
async def test_explicit_missing_checkpoint_is_idempotent():
service = AccidentAppService(SimpleNamespace(), InMemorySessionRepository())
await service.set_info('s', 'hphm1', 'test-plate')
drop = AsyncMock(side_effect=CheckpointNotFoundError())
await service.delete_session('s', drop)
await service.delete_session('s', drop)
assert await service.get_info('s', 'hphm1') == '""'
assert drop.await_count == 2
@pytest.mark.asyncio
async def test_cancelled_checkpoint_cleanup_keeps_business_fields():
service = AccidentAppService(SimpleNamespace(), InMemorySessionRepository())
await service.set_info('s', 'hphm1', 'test-plate')
started = asyncio.Event()
async def drop(_):
started.set()
await asyncio.Future()
task = asyncio.create_task(service.delete_session('s', drop))
await asyncio.wait_for(started.wait(), 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert await service.get_info('s', 'hphm1') == '"test-plate"'
assert not service._locks._entries
@pytest.mark.asyncio
async def test_business_delete_failure_is_not_reported_as_success():
class FailingRepository(InMemorySessionRepository):
def delete(self, _):
raise ConnectionError('business storage offline')
service = AccidentAppService(SimpleNamespace(), FailingRepository())
await service.set_info('s', 'hphm1', 'test-plate')
drop = AsyncMock()
with pytest.raises(ConnectionError):
await service.delete_session('s', drop)
drop.assert_awaited_once_with('s')
assert await service.get_info('s', 'hphm1') == '"test-plate"'
@pytest.mark.asyncio
async def test_checkpoint_cleanup_uses_async_api_and_propagates_errors(monkeypatch):
saver = SimpleNamespace(adelete_thread=AsyncMock(side_effect=ConnectionError()), delete_thread=Mock())
monkeypatch.setattr(wiring, '_langgraph_graph', SimpleNamespace(checkpointer=saver))
with pytest.raises(ConnectionError):
await wiring.delete_chat_session('s')
saver.adelete_thread.assert_awaited_once_with('s')
saver.delete_thread.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('graph', [None, SimpleNamespace(), SimpleNamespace(checkpointer=object())])
async def test_missing_cleanup_capability_fails(monkeypatch, graph):
monkeypatch.setattr(wiring, '_langgraph_graph', graph)
with pytest.raises(RuntimeError):
await wiring.delete_chat_session('s')
@pytest.mark.asyncio
async def test_sync_checkpoint_cleanup_is_supported_and_errors_propagate(monkeypatch):
saver = SimpleNamespace(delete_thread=Mock())
monkeypatch.setattr(wiring, '_langgraph_graph', SimpleNamespace(checkpointer=saver))
await wiring.delete_chat_session('s')
saver.delete_thread.assert_called_once_with('s')
saver.delete_thread.side_effect = ConnectionError()
with pytest.raises(ConnectionError):
await wiring.delete_chat_session('s')