feat: 新增 cancelled 状态区分客户端主动断开连接

- 新增 cancelled 请求状态,与系统失败 (failed) 区分
- 客户端主动断开连接 (HTTP 499) 不计入失败率统计
- 前后端同步更新状态枚举、UI 样式和统计逻辑
This commit is contained in:
fawney19
2026-01-20 15:40:16 +08:00
parent ffca11bc18
commit 4eeba69381
11 changed files with 211 additions and 15 deletions

View File

@@ -246,6 +246,62 @@ class MessageTelemetry:
target_model=target_model,
)
async def record_cancelled(
self,
*,
provider: str,
model: str,
response_time_ms: int,
first_byte_time_ms: Optional[int],
status_code: int,
request_body: Dict[str, Any],
request_headers: Dict[str, Any],
is_stream: bool,
api_format: Optional[str] = None,
provider_request_headers: Optional[Dict[str, Any]] = None,
input_tokens: int = 0,
output_tokens: int = 0,
cache_creation_tokens: int = 0,
cache_read_tokens: int = 0,
response_body: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, Any]] = None,
client_response_headers: Optional[Dict[str, Any]] = None,
target_model: Optional[str] = None,
) -> None:
"""
记录客户端取消的请求
客户端主动断开连接不算系统失败,使用 cancelled 状态。
"""
provider_name = provider or "unknown"
await UsageService.record_usage(
db=self.db,
user=self.user,
api_key=self.api_key,
provider=provider_name,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_tokens,
cache_read_input_tokens=cache_read_tokens,
request_type="chat",
api_format=api_format,
is_stream=is_stream,
response_time_ms=response_time_ms,
first_byte_time_ms=first_byte_time_ms,
status_code=status_code,
status="cancelled",
request_headers=request_headers,
request_body=request_body,
provider_request_headers=provider_request_headers or {},
response_headers=response_headers or {},
client_response_headers=client_response_headers,
response_body=response_body or {},
request_id=self.request_id,
target_model=target_model,
)
@runtime_checkable
class MessageHandlerProtocol(Protocol):

View File

@@ -214,6 +214,10 @@ class StreamContext:
"""检查请求是否成功"""
return self.status_code < 400
def is_client_disconnected(self) -> bool:
"""检查是否因客户端断开连接而结束"""
return self.status_code == 499
def build_response_body(self, response_time_ms: int) -> Dict[str, Any]:
"""
构建响应体元数据

View File

@@ -97,9 +97,15 @@ class StreamTelemetryRecorder:
logger.warning(
f"[{self.request_id}] User or ApiKey not found, updating status directly"
)
if ctx.is_success():
status = "completed"
elif ctx.is_client_disconnected():
status = "cancelled"
else:
status = "failed"
await self._update_usage_status_directly(
bg_db,
status="completed" if ctx.is_success() else "failed",
status=status,
response_time_ms=response_time_ms,
status_code=ctx.status_code,
)
@@ -121,6 +127,15 @@ class StreamTelemetryRecorder:
response_body,
response_time_ms,
)
elif ctx.is_client_disconnected():
await self._record_cancelled(
bg_telemetry,
ctx,
original_headers,
actual_request_body,
response_body,
response_time_ms,
)
else:
await self._record_failure(
bg_telemetry,
@@ -229,6 +244,42 @@ class StreamTelemetryRecorder:
# 对于失败日志,添加缓存信息
logger.info(f"{log_summary} cache:{ctx.cached_tokens}")
async def _record_cancelled(
self,
telemetry: MessageTelemetry,
ctx: StreamContext,
original_headers: Dict[str, str],
actual_request_body: Dict[str, Any],
response_body: Dict[str, Any],
response_time_ms: int,
) -> None:
"""记录客户端取消的请求"""
client_response_headers = ctx.client_response_headers or {"content-type": "application/json"}
await telemetry.record_cancelled(
provider=ctx.provider_name or "unknown",
model=ctx.model,
response_time_ms=response_time_ms,
first_byte_time_ms=ctx.first_byte_time_ms,
status_code=ctx.status_code,
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
api_format=ctx.api_format,
provider_request_headers=ctx.provider_request_headers,
input_tokens=ctx.input_tokens,
output_tokens=ctx.output_tokens,
cache_creation_tokens=ctx.cache_creation_tokens,
cache_read_tokens=ctx.cached_tokens,
response_body=response_body,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
target_model=ctx.mapped_model,
)
logger.debug(f"{self.format_id} 流式响应被客户端取消")
logger.info(ctx.get_log_summary(self.request_id, response_time_ms))
async def _update_candidate_status(
self,
db: Session,
@@ -264,14 +315,21 @@ class StreamTelemetryRecorder:
latency_ms=response_time_ms,
extra_data=extra_data,
)
elif ctx.is_client_disconnected():
RequestCandidateService.mark_candidate_cancelled(
db=db,
candidate_id=ctx.attempt_id,
status_code=ctx.status_code,
latency_ms=response_time_ms,
extra_data=extra_data,
)
else:
error_type = "client_disconnected" if ctx.status_code == 499 else "stream_error"
# 请求链路追踪使用 upstream_response原始响应回退到 error_message友好消息
trace_error_message = ctx.upstream_response or ctx.error_message or f"HTTP {ctx.status_code}"
RequestCandidateService.mark_candidate_failed(
db=db,
candidate_id=ctx.attempt_id,
error_type=error_type,
error_type="stream_error",
error_message=trace_error_message,
status_code=ctx.status_code,
latency_ms=response_time_ms,