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:
@@ -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')
|
||||
Reference in New Issue
Block a user