from __future__ import annotations import time from fastapi import FastAPI, Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.requests import ClientDisconnect from services.protocol.error_response import anthropic_error_response, openai_error_response from utils.sensitive_data import sanitize_sensitive_data from utils.log import logger def _is_openai_compatible_path(path: str) -> bool: return path == "/v1" or path.startswith("/v1/") def _is_anthropic_messages_path(path: str) -> bool: return path == "/v1/messages" def _compatible_error_response( request: Request, detail: object, status_code: int, headers: dict[str, str] | None = None, ) -> JSONResponse: if _is_anthropic_messages_path(request.url.path): return anthropic_error_response(detail, status_code, headers=headers) return openai_error_response(detail, status_code, headers=headers) def install_exception_handlers(app: FastAPI) -> None: @app.exception_handler(ClientDisconnect) async def client_disconnect_handler(request: Request, exc: ClientDisconnect) -> JSONResponse: # A disconnected upload is not a server crash. Do not translate/record # this expected transport outcome in the unconfigured-error ledger. metrics = getattr(request.state, "image_upload_metrics", {}) length = request.headers.get("content-length", "") expected_bytes = int(length) if length.isascii() and length.isdigit() and len(length) <= 20 else None started = metrics.get("started_at") logger.info({ "event": "request_body_disconnected", "route": getattr(request.scope.get("route"), "path", "unknown"), "body_format": metrics.get("body_format", "unknown"), "expected_bytes": expected_bytes, "received_bytes": metrics.get("received_bytes"), "duration_ms": round((time.monotonic() - started) * 1000) if started is not None else None, }) message = ( "图片上传连接已中断,请检查网络后重新上传;请先确认任务列表中没有已提交的任务,避免重复提交。" if metrics else "请求数据传输已中断,请检查网络后重试。" ) if _is_anthropic_messages_path(request.url.path): content = {"type": "error", "error": {"type": "invalid_request_error", "message": message}} elif _is_openai_compatible_path(request.url.path): content = {"error": {"message": message, "type": "invalid_request_error", "code": "client_disconnected", "param": None}} else: content = {"detail": {"error": message, "code": "client_disconnected"}} # The peer is usually already gone: 499 describes our server outcome, # not a promise that the disconnected browser can receive this body. return JSONResponse(status_code=499, content=content, headers={"Cache-Control": "no-store"}) @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: if _is_openai_compatible_path(request.url.path): return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers) return JSONResponse( status_code=exc.status_code, content={ "detail": sanitize_sensitive_data( jsonable_encoder(exc.detail), drop_personal_fields=True, ) }, headers=exc.headers, ) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: if _is_openai_compatible_path(request.url.path): return _compatible_error_response(request, exc.errors(), 422) return JSONResponse( status_code=422, content={ "detail": sanitize_sensitive_data( jsonable_encoder(exc.errors()), drop_personal_fields=True, ) }, )