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
+7 -1
View File
@@ -415,7 +415,13 @@ async def chat(
f"duration_ms={(time.perf_counter() - stream_started_at) * 1000:.1f} "
f"error={e}"
)
yield create_sse_event("error", {"msg": str(e), "code": "500"})
if isinstance(e, ChatBackendAuthenticationError):
code, msg = "401", "认证失败"
elif isinstance(e, ChatBackendRateLimitError):
code, msg = "429", "请求过于频繁,请稍后重试"
else:
code, msg = "500", "大模型服务器无响应"
yield create_sse_event("error", {"msg": msg, "code": code})
return StreamingResponse(event_generator(), media_type="text/event-stream")
+10 -5
View File
@@ -71,14 +71,19 @@ async def lifespan(app: FastAPI):
async def delete_chat_session(thread_id: str) -> None:
"""langgraph 模式下删除会话:清理图 checkpoint(对照计划作业 6 的
完整语义——业务字段、事件、路由持久化清理在接入 repository 后补齐)。
"""
"""删除图 checkpoint;缺少清理能力或存储出错必须报告失败。"""
if _langgraph_graph is None:
raise RuntimeError("LangGraph backend not initialized")
checkpointer = getattr(_langgraph_graph, "checkpointer", None)
if checkpointer is not None and hasattr(checkpointer, "delete_thread"):
checkpointer.delete_thread(thread_id)
async_delete = getattr(checkpointer, "adelete_thread", None)
if callable(async_delete):
await async_delete(thread_id)
return
sync_delete = getattr(checkpointer, "delete_thread", None)
if callable(sync_delete):
sync_delete(thread_id)
return
raise RuntimeError("LangGraph checkpointer does not support thread deletion")
def get_fastgpt_client() -> AsyncChatClient:
+5 -5
View File
@@ -31,7 +31,7 @@ from .fields_registry import (
parse_field_value,
to_external_string,
)
from .repository import SessionRepository
from .repository import CheckpointNotFoundError, SessionRepository
from .session_locks import SessionLocks
# set_info 对未知/受保护 key 的拒绝码(与现有“入参不规范”文案对齐)
@@ -192,16 +192,16 @@ class AccidentAppService:
(图生命周期归 lifespan 管理,本层不持有图引用)。
与会话锁互斥:删除不与在途 chat/set/get 交错。
释放锁引用时保留等待者使用的锁,最后一个使用者退出后回收。
repository 删除幂等;checkpoint 缺失时静默(幂等语义一致)。
checkpoint 清理成功(或明确不存在)后才删除业务字段。
其他异常向上传播,由 endpoint 返回失败,保留业务字段供重试。
"""
async with self._locks.hold(session_id):
self._repository.delete(session_id)
if drop_checkpoint is not None:
try:
await drop_checkpoint(session_id)
except Exception:
# checkpoint 已不存在视为删除成功的一部分(幂等)
except CheckpointNotFoundError:
pass
self._repository.delete(session_id)
__all__ = ["AccidentAppService", "SET_INFO_INVALID_KEY_MSG"]
+5 -1
View File
@@ -14,6 +14,10 @@
from typing import Protocol
class CheckpointNotFoundError(Exception):
"""存储适配器明确确认线程不存在;不能用一般存储异常代替。"""
class SessionRepository(Protocol):
"""业务字段存储接口(作业 7 由 PostgreSQL 实现替换)。"""
@@ -49,4 +53,4 @@ class InMemorySessionRepository:
self._store.pop(session_id, None)
__all__ = ["SessionRepository", "InMemorySessionRepository"]
__all__ = ["CheckpointNotFoundError", "SessionRepository", "InMemorySessionRepository"]