From c94f9810c4fe395c2ba24a2192f38010ba313c99 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Fri, 27 Feb 2026 10:16:54 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Implement=20global=20exception?= =?UTF-8?q?=20handling=20with=20AppException=20for=20better=20error=20mana?= =?UTF-8?q?gement=20across=20applications.=20Introduced=20AppException=20f?= =?UTF-8?q?or=20specific=20error=20codes=20and=20messages,=20and=20added?= =?UTF-8?q?=20middleware=20for=20centralized=20error=20handling.=20Updated?= =?UTF-8?q?=20existing=20services=20to=20utilize=20AppException=20for=20er?= =?UTF-8?q?ror=20reporting.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/config_app.py | 21 +- backend/apps/dify_app.py | 31 +-- backend/apps/northbound_base_app.py | 19 +- backend/apps/runtime_app.py | 28 ++- backend/consts/error_code.py | 174 +++++++++++++++ backend/consts/error_message.py | 146 +++++++++++++ backend/consts/exceptions.py | 167 ++++++++------- backend/middleware/__init__.py | 1 + backend/middleware/exception_handler.py | 161 ++++++++++++++ backend/services/dify_service.py | 50 ++++- frontend/const/errorCode.ts | 174 +++++++++++++++ frontend/const/errorMessage.ts | 180 ++++++++++++++++ frontend/const/errorMessageI18n.ts | 235 +++++++++++++++++++++ frontend/hooks/useErrorHandler.ts | 167 +++++++++++++++ frontend/hooks/useKnowledgeBaseSelector.ts | 18 +- frontend/public/locales/en/common.json | 80 ++++++- frontend/public/locales/zh/common.json | 99 ++++++++- frontend/services/api.ts | 23 +- frontend/services/knowledgeBaseService.ts | 55 ++--- 19 files changed, 1675 insertions(+), 154 deletions(-) create mode 100644 backend/consts/error_code.py create mode 100644 backend/consts/error_message.py create mode 100644 backend/middleware/__init__.py create mode 100644 backend/middleware/exception_handler.py create mode 100644 frontend/const/errorCode.ts create mode 100644 frontend/const/errorMessage.ts create mode 100644 frontend/const/errorMessageI18n.ts create mode 100644 frontend/hooks/useErrorHandler.ts diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index 8691b15e0..8041651c3 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -25,6 +25,7 @@ from apps.user_app import router as user_router from apps.invitation_app import router as invitation_router from consts.const import IS_SPEED_MODE +from consts.exceptions import AppException # Import monitoring utilities from utils.monitoring import monitoring_manager @@ -84,9 +85,27 @@ async def http_exception_handler(request, exc): ) -# Global exception handler for all uncaught exceptions +# Global exception handler for AppException +@app.exception_handler(AppException) +async def app_exception_handler(request, exc): + logger.error(f"AppException: {exc.error_code.value} - {exc.message}") + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "details": exc.details if exc.details else None + }, + ) + + +# Global exception handler for all uncaught exceptions (excluding AppException) @app.exception_handler(Exception) async def generic_exception_handler(request, exc): + # Don't catch AppException - it has its own handler + if isinstance(exc, AppException): + return await app_exception_handler(request, exc) + logger.error(f"Generic Exception: {exc}") return JSONResponse( status_code=500, diff --git a/backend/apps/dify_app.py b/backend/apps/dify_app.py index c7d9321af..99ce8d86e 100644 --- a/backend/apps/dify_app.py +++ b/backend/apps/dify_app.py @@ -13,6 +13,8 @@ from fastapi import APIRouter, Header, HTTPException, Query from fastapi.responses import JSONResponse +from consts.error_code import ErrorCode +from consts.exceptions import AppException from services.dify_service import fetch_dify_datasets_impl from utils.auth_utils import get_current_user_id @@ -35,18 +37,10 @@ async def fetch_dify_datasets_api( # Normalize URL by removing trailing slash dify_api_base = dify_api_base.rstrip('/') except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) + logger.error(f"Invalid Dify configuration: {e}") + raise AppException(ErrorCode.DIFY_CONFIG_INVALID, + f"Invalid URL format: {str(e)}") - try: - _, tenant_id = get_current_user_id(authorization) - except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) try: result = fetch_dify_datasets_impl( @@ -57,15 +51,10 @@ async def fetch_dify_datasets_api( status_code=HTTPStatus.OK, content=result ) - except ValueError as e: - logger.warning(f"Invalid Dify configuration: {e}") - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(e) - ) + except AppException: + # Re-raise AppException to be handled by global middleware + raise except Exception as e: logger.error(f"Failed to fetch Dify datasets: {e}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + f"Failed to fetch Dify datasets: {str(e)}") diff --git a/backend/apps/northbound_base_app.py b/backend/apps/northbound_base_app.py index ff51e0e71..7850c5f7b 100644 --- a/backend/apps/northbound_base_app.py +++ b/backend/apps/northbound_base_app.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from .northbound_app import router as northbound_router -from consts.exceptions import LimitExceededError, UnauthorizedError, SignatureValidationError +from consts.exceptions import AppException logger = logging.getLogger("northbound_base_app") @@ -38,8 +38,25 @@ async def northbound_http_exception_handler(request, exc): ) +@northbound_app.exception_handler(AppException) +async def northbound_app_exception_handler(request, exc): + logger.error(f"Northbound AppException: {exc.error_code.value} - {exc.message}") + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "details": exc.details if exc.details else None + }, + ) + + @northbound_app.exception_handler(Exception) async def northbound_generic_exception_handler(request, exc): + # Don't catch AppException - it has its own handler + if isinstance(exc, AppException): + return await northbound_app_exception_handler(request, exc) + logger.error(f"Northbound Generic Exception: {exc}") return JSONResponse( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, diff --git a/backend/apps/runtime_app.py b/backend/apps/runtime_app.py index 6db480ab6..242f8eb58 100644 --- a/backend/apps/runtime_app.py +++ b/backend/apps/runtime_app.py @@ -12,6 +12,9 @@ # Import monitoring utilities from utils.monitoring import monitoring_manager +# Import exception handling +from consts.exceptions import AppException +from middleware.exception_handler import ExceptionHandlerMiddleware # Create logger instance logger = logging.getLogger("runtime_app") @@ -26,6 +29,9 @@ allow_headers=["*"], ) +# Add global exception handler middleware +app.add_middleware(ExceptionHandlerMiddleware) + app.include_router(agent_router) app.include_router(conversation_management_router) app.include_router(memory_config_router) @@ -46,13 +52,29 @@ async def http_exception_handler(request, exc): ) -# Global exception handler for all uncaught exceptions +# Global exception handler for AppException +@app.exception_handler(AppException) +async def app_exception_handler(request, exc): + logger.error(f"AppException: {exc.error_code.value} - {exc.message}") + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "details": exc.details if exc.details else None + }, + ) + + +# Global exception handler for all uncaught exceptions (excluding AppException) @app.exception_handler(Exception) async def generic_exception_handler(request, exc): + # Don't catch AppException - it has its own handler + if isinstance(exc, AppException): + return await app_exception_handler(request, exc) + logger.error(f"Generic Exception: {exc}") return JSONResponse( status_code=500, content={"message": "Internal server error, please try again later."}, ) - - diff --git a/backend/consts/error_code.py b/backend/consts/error_code.py new file mode 100644 index 000000000..e234b8a3d --- /dev/null +++ b/backend/consts/error_code.py @@ -0,0 +1,174 @@ +""" +Error code definitions for the application. + +Format: XYYZZZ +- X: Error level (1=System, 2=Auth, 3=Business, 4=External) +- YY: Module number (01-99) +- ZZZ: Error sequence (001-999) + +Module Numbers: +- 01: System +- 02: Auth +- 03: User +- 04: Tenant +- 05: Agent +- 06: Tool/MCP +- 07: Conversation +- 08: Memory +- 09: Knowledge +- 10: Model +- 11: Voice +- 12: File +- 13: Invitation +- 14: Group +- 15: Data +- 16: External +- 20: Validation +- 21: Resource +- 22: RateLimit +""" + +from enum import Enum + + +class ErrorCode(int, Enum): + """Business error codes.""" + + # ==================== System Level Errors (10xxxx) ==================== + UNKNOWN_ERROR = 101001 + SERVICE_UNAVAILABLE = 101002 + DATABASE_ERROR = 101003 + TIMEOUT = 101004 + INTERNAL_ERROR = 101005 + + # ==================== Auth Level Errors (102xxx) ==================== + UNAUTHORIZED = 102001 + TOKEN_EXPIRED = 102002 + TOKEN_INVALID = 102003 + SIGNATURE_INVALID = 102004 + FORBIDDEN = 102005 + + # ==================== User Module Errors (103xxx) ==================== + USER_NOT_FOUND = 103001 + USER_REGISTRATION_FAILED = 103002 + USER_ALREADY_EXISTS = 103003 + INVALID_CREDENTIALS = 103004 + + # ==================== Tenant Module Errors (104xxx) ==================== + TENANT_NOT_FOUND = 104001 + TENANT_DISABLED = 104002 + TENANT_CONFIG_ERROR = 104003 + + # ==================== Agent Module Errors (105xxx) ==================== + AGENT_NOT_FOUND = 105001 + AGENT_RUN_FAILED = 105002 + AGENT_NAME_DUPLICATE = 105003 + AGENT_DISABLED = 105004 + AGENT_VERSION_NOT_FOUND = 105005 + + # ==================== Tool/MCP Module Errors (106xxx) ==================== + TOOL_NOT_FOUND = 106001 + TOOL_EXECUTION_FAILED = 106002 + TOOL_CONFIG_INVALID = 106003 + + # MCP specific errors (1061xx) + MCP_CONNECTION_FAILED = 106101 + MCP_NAME_ILLEGAL = 106102 + MCP_CONTAINER_ERROR = 106103 + + # ==================== Conversation Module Errors (107xxx) ==================== + CONVERSATION_NOT_FOUND = 107001 + CONVERSATION_SAVE_FAILED = 107002 + MESSAGE_NOT_FOUND = 107003 + CONVERSATION_TITLE_GENERATION_FAILED = 107004 + + # ==================== Memory Module Errors (108xxx) ==================== + MEMORY_NOT_FOUND = 108001 + MEMORY_PREPARATION_FAILED = 108002 + MEMORY_CONFIG_INVALID = 108003 + + # ==================== Knowledge Module Errors (109xxx) ==================== + KNOWLEDGE_NOT_FOUND = 109001 + KNOWLEDGE_SYNC_FAILED = 109002 + INDEX_NOT_FOUND = 109003 + KNOWLEDGE_SEARCH_FAILED = 109004 + KNOWLEDGE_UPLOAD_FAILED = 109005 + + # ==================== Model Module Errors (110xxx) ==================== + MODEL_NOT_FOUND = 110001 + MODEL_CONFIG_INVALID = 110002 + MODEL_HEALTH_CHECK_FAILED = 110003 + MODEL_PROVIDER_ERROR = 110004 + + # ==================== Voice Module Errors (111xxx) ==================== + VOICE_SERVICE_ERROR = 111001 + STT_CONNECTION_FAILED = 111002 + TTS_CONNECTION_FAILED = 111003 + VOICE_CONFIG_INVALID = 111004 + + # ==================== File Module Errors (112xxx) ==================== + FILE_NOT_FOUND = 112001 + FILE_UPLOAD_FAILED = 112002 + FILE_TOO_LARGE = 112003 + FILE_TYPE_NOT_ALLOWED = 112004 + FILE_PREPROCESS_FAILED = 112005 + + # ==================== Invitation Module Errors (113xxx) ==================== + INVITE_CODE_NOT_FOUND = 113001 + INVITE_CODE_INVALID = 113002 + INVITE_CODE_EXPIRED = 113003 + + # ==================== Group Module Errors (114xxx) ==================== + GROUP_NOT_FOUND = 114001 + GROUP_ALREADY_EXISTS = 114002 + MEMBER_NOT_IN_GROUP = 114003 + + # ==================== Data Process Module Errors (115xxx) ==================== + DATA_PROCESS_FAILED = 115001 + DATA_PARSE_FAILED = 115002 + + # ==================== External Service Errors (116xxx) ==================== + ME_CONNECTION_FAILED = 116001 + DATAMATE_CONNECTION_FAILED = 116002 + DIFY_SERVICE_ERROR = 116003 + EXTERNAL_API_ERROR = 116004 + + # Dify specific errors (1161xx) - simplified + DIFY_CONFIG_INVALID = 116101 + DIFY_CONNECTION_ERROR = 116102 + DIFY_AUTH_ERROR = 116103 + DIFY_RATE_LIMIT = 116104 + DIFY_RESPONSE_ERROR = 116105 + + # ==================== Validation Errors (120xxx) ==================== + VALIDATION_ERROR = 120001 + PARAMETER_INVALID = 120002 + MISSING_REQUIRED_FIELD = 120003 + + # ==================== Resource Errors (121xxx) ==================== + RESOURCE_NOT_FOUND = 121001 + RESOURCE_ALREADY_EXISTS = 121002 + RESOURCE_DISABLED = 121003 + + # ==================== Rate Limit Errors (122xxx) ==================== + RATE_LIMIT_EXCEEDED = 122001 + + +# HTTP status code mapping +ERROR_CODE_HTTP_STATUS = { + ErrorCode.UNAUTHORIZED: 401, + ErrorCode.TOKEN_EXPIRED: 401, + ErrorCode.TOKEN_INVALID: 401, + ErrorCode.SIGNATURE_INVALID: 401, + ErrorCode.FORBIDDEN: 403, + ErrorCode.RATE_LIMIT_EXCEEDED: 429, + ErrorCode.DIFY_RATE_LIMIT: 429, + ErrorCode.VALIDATION_ERROR: 400, + ErrorCode.PARAMETER_INVALID: 400, + ErrorCode.MISSING_REQUIRED_FIELD: 400, + ErrorCode.FILE_TOO_LARGE: 413, + ErrorCode.DIFY_CONFIG_INVALID: 400, + ErrorCode.DIFY_AUTH_ERROR: 400, + ErrorCode.DIFY_CONNECTION_ERROR: 502, + ErrorCode.DIFY_RESPONSE_ERROR: 502, +} diff --git a/backend/consts/error_message.py b/backend/consts/error_message.py new file mode 100644 index 000000000..46cbf00d9 --- /dev/null +++ b/backend/consts/error_message.py @@ -0,0 +1,146 @@ +""" +Error message mappings for error codes. + +This module provides default English error messages. +Frontend should use i18n for localized messages. +""" + +from .error_code import ErrorCode + + +class ErrorMessage: + """Error code to message mapping.""" + + _MESSAGES = { + # ==================== System Level Errors ==================== + ErrorCode.UNKNOWN_ERROR: "An unknown error occurred. Please try again later.", + ErrorCode.SERVICE_UNAVAILABLE: "Service is temporarily unavailable. Please try again later.", + ErrorCode.DATABASE_ERROR: "Database operation failed. Please try again later.", + ErrorCode.TIMEOUT: "Operation timed out. Please try again later.", + ErrorCode.INTERNAL_ERROR: "Internal server error. Please try again later.", + + # ==================== Auth Level Errors ==================== + ErrorCode.UNAUTHORIZED: "You are not authorized to perform this action.", + ErrorCode.TOKEN_EXPIRED: "Your session has expired. Please login again.", + ErrorCode.TOKEN_INVALID: "Invalid token. Please login again.", + ErrorCode.SIGNATURE_INVALID: "Request signature verification failed.", + ErrorCode.FORBIDDEN: "Access forbidden.", + + # ==================== User Module Errors ==================== + ErrorCode.USER_NOT_FOUND: "User not found.", + ErrorCode.USER_REGISTRATION_FAILED: "User registration failed. Please try again later.", + ErrorCode.USER_ALREADY_EXISTS: "User already exists.", + ErrorCode.INVALID_CREDENTIALS: "Invalid username or password.", + + # ==================== Tenant Module Errors ==================== + ErrorCode.TENANT_NOT_FOUND: "Tenant not found.", + ErrorCode.TENANT_DISABLED: "Tenant is disabled.", + ErrorCode.TENANT_CONFIG_ERROR: "Tenant configuration error.", + + # ==================== Agent Module Errors ==================== + ErrorCode.AGENT_NOT_FOUND: "Agent not found.", + ErrorCode.AGENT_RUN_FAILED: "Failed to run agent. Please try again later.", + ErrorCode.AGENT_NAME_DUPLICATE: "Agent name already exists.", + ErrorCode.AGENT_DISABLED: "Agent is disabled.", + ErrorCode.AGENT_VERSION_NOT_FOUND: "Agent version not found.", + + # ==================== Tool/MCP Module Errors ==================== + ErrorCode.TOOL_NOT_FOUND: "Tool not found.", + ErrorCode.TOOL_EXECUTION_FAILED: "Tool execution failed.", + ErrorCode.TOOL_CONFIG_INVALID: "Tool configuration is invalid.", + ErrorCode.MCP_CONNECTION_FAILED: "Failed to connect to MCP service.", + ErrorCode.MCP_NAME_ILLEGAL: "MCP name contains invalid characters.", + ErrorCode.MCP_CONTAINER_ERROR: "MCP container operation failed.", + + # ==================== Conversation Module Errors ==================== + ErrorCode.CONVERSATION_NOT_FOUND: "Conversation not found.", + ErrorCode.CONVERSATION_SAVE_FAILED: "Failed to save conversation.", + ErrorCode.MESSAGE_NOT_FOUND: "Message not found.", + ErrorCode.CONVERSATION_TITLE_GENERATION_FAILED: "Failed to generate conversation title.", + + # ==================== Memory Module Errors ==================== + ErrorCode.MEMORY_NOT_FOUND: "Memory not found.", + ErrorCode.MEMORY_PREPARATION_FAILED: "Failed to prepare memory.", + ErrorCode.MEMORY_CONFIG_INVALID: "Memory configuration is invalid.", + + # ==================== Knowledge Module Errors ==================== + ErrorCode.KNOWLEDGE_NOT_FOUND: "Knowledge base not found.", + ErrorCode.KNOWLEDGE_SYNC_FAILED: "Failed to sync knowledge base.", + ErrorCode.INDEX_NOT_FOUND: "Search index not found.", + ErrorCode.KNOWLEDGE_SEARCH_FAILED: "Knowledge search failed.", + ErrorCode.KNOWLEDGE_UPLOAD_FAILED: "Failed to upload knowledge.", + + # ==================== Model Module Errors ==================== + ErrorCode.MODEL_NOT_FOUND: "Model not found.", + ErrorCode.MODEL_CONFIG_INVALID: "Model configuration is invalid.", + ErrorCode.MODEL_HEALTH_CHECK_FAILED: "Model health check failed.", + ErrorCode.MODEL_PROVIDER_ERROR: "Model provider error.", + + # ==================== Voice Module Errors ==================== + ErrorCode.VOICE_SERVICE_ERROR: "Voice service error.", + ErrorCode.STT_CONNECTION_FAILED: "Failed to connect to speech recognition service.", + ErrorCode.TTS_CONNECTION_FAILED: "Failed to connect to speech synthesis service.", + ErrorCode.VOICE_CONFIG_INVALID: "Voice configuration is invalid.", + + # ==================== File Module Errors ==================== + ErrorCode.FILE_NOT_FOUND: "File not found.", + ErrorCode.FILE_UPLOAD_FAILED: "Failed to upload file.", + ErrorCode.FILE_TOO_LARGE: "File size exceeds limit.", + ErrorCode.FILE_TYPE_NOT_ALLOWED: "File type not allowed.", + ErrorCode.FILE_PREPROCESS_FAILED: "File preprocessing failed.", + + # ==================== Invitation Module Errors ==================== + ErrorCode.INVITE_CODE_NOT_FOUND: "Invite code not found.", + ErrorCode.INVITE_CODE_INVALID: "Invalid invite code.", + ErrorCode.INVITE_CODE_EXPIRED: "Invite code has expired.", + + # ==================== Group Module Errors ==================== + ErrorCode.GROUP_NOT_FOUND: "Group not found.", + ErrorCode.GROUP_ALREADY_EXISTS: "Group already exists.", + ErrorCode.MEMBER_NOT_IN_GROUP: "Member is not in the group.", + + # ==================== Data Process Module Errors ==================== + ErrorCode.DATA_PROCESS_FAILED: "Data processing failed.", + ErrorCode.DATA_PARSE_FAILED: "Data parsing failed.", + + # ==================== External Service Errors ==================== + ErrorCode.ME_CONNECTION_FAILED: "Failed to connect to ME service.", + ErrorCode.DATAMATE_CONNECTION_FAILED: "Failed to connect to DataMate service.", + ErrorCode.DIFY_SERVICE_ERROR: "Dify service error.", + ErrorCode.EXTERNAL_API_ERROR: "External API error.", + + # ==================== Dify Specific Errors ==================== + ErrorCode.DIFY_CONFIG_INVALID: "Dify configuration invalid. Please check URL and API key format.", + ErrorCode.DIFY_CONNECTION_ERROR: "Failed to connect to Dify. Please check network connection and URL.", + ErrorCode.DIFY_RESPONSE_ERROR: "Failed to parse Dify response. Please check API URL.", + ErrorCode.DIFY_AUTH_ERROR: "Dify authentication failed. Please check your API key.", + ErrorCode.DIFY_RATE_LIMIT: "Dify API rate limit exceeded. Please try again later.", + + # ==================== Validation Errors ==================== + ErrorCode.VALIDATION_ERROR: "Validation failed.", + ErrorCode.PARAMETER_INVALID: "Invalid parameter.", + ErrorCode.MISSING_REQUIRED_FIELD: "Required field is missing.", + + # ==================== Resource Errors ==================== + ErrorCode.RESOURCE_NOT_FOUND: "Resource not found.", + ErrorCode.RESOURCE_ALREADY_EXISTS: "Resource already exists.", + ErrorCode.RESOURCE_DISABLED: "Resource is disabled.", + + # ==================== Rate Limit Errors ==================== + ErrorCode.RATE_LIMIT_EXCEEDED: "Too many requests. Please try again later.", + } + + @classmethod + def get_message(cls, error_code: ErrorCode) -> str: + """Get error message by error code.""" + return cls._MESSAGES.get(error_code, "An error occurred. Please try again later.") + + @classmethod + def get_message_with_code(cls, error_code: ErrorCode) -> tuple[int, str]: + """Get error code and message as tuple.""" + return (error_code.value, cls.get_message(error_code)) + + @classmethod + def get_all_messages(cls) -> dict: + """Get all error code to message mappings.""" + return {code.value: msg for code, msg in cls._MESSAGES.items()} diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 94b1f770d..4b21ee465 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -1,114 +1,113 @@ """ Custom exception classes for the application. -""" - - -class AgentRunException(Exception): - """Exception raised when agent run fails.""" - pass - - -class LimitExceededError(Exception): - """Raised when an outer platform calling too frequently""" - pass - - -class UnauthorizedError(Exception): - """Raised when a user from outer platform is unauthorized.""" - pass - - -class SignatureValidationError(Exception): - """Raised when X-Signature header is missing or does not match the expected HMAC value.""" - pass - -class MemoryPreparationException(Exception): - """Raised when memory preprocessing or retrieval fails prior to agent run.""" - pass - - -class MCPConnectionError(Exception): - """Raised when MCP connection fails.""" - pass - - -class MCPNameIllegal(Exception): - """Raised when MCP name is illegal.""" - pass - - -class NoInviteCodeException(Exception): - """Raised when invite code is not found.""" - pass - - -class IncorrectInviteCodeException(Exception): - """Raised when invite code is incorrect.""" - pass +This module provides a unified exception framework using ErrorCode enum. +Use AppException directly with ErrorCode to create exceptions. +Usage: + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + raise AppException(ErrorCode.AGENT_NOT_FOUND) + raise AppException(ErrorCode.MCP_CONNECTION_FAILED, "Connection timeout", details={"host": "localhost"}) +""" -class UserRegistrationException(Exception): - """Raised when user registration fails.""" - pass +from .error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from .error_message import ErrorMessage -class TimeoutException(Exception): - """Raised when timeout occurs.""" - pass +class AppException(Exception): + """Base application exception with error code.""" + def __init__(self, error_code: ErrorCode, message: str = None, details: dict = None): + self.error_code = error_code + self.message = message or ErrorMessage.get_message(error_code) + self.details = details or {} + super().__init__(self.message) + def to_dict(self) -> dict: + return { + "code": self.error_code.value, + "message": self.message, + "details": self.details if self.details else None + } -class ValidationError(Exception): - """Raised when validation fails.""" - pass + @property + def http_status(self) -> int: + return ERROR_CODE_HTTP_STATUS.get(self.error_code, 500) -class NotFoundException(Exception): - """Raised when not found exception occurs.""" - pass +# Backward compatible aliases - these are just AppException with different names +# Usage: AppException(ErrorCode.NOT_FOUND) or NotFoundException(ErrorCode.NOT_FOUND) +# ==================== Common Aliases ==================== +NotFoundException = AppException +UnauthorizedError = AppException +ValidationError = AppException +ParameterInvalidError = AppException +ForbiddenError = AppException +ServiceUnavailableError = AppException +DatabaseError = AppException +TimeoutError = AppException +UnknownError = AppException +# ==================== Domain Specific Aliases ==================== +UserNotFoundError = AppException +UserAlreadyExistsError = AppException +InvalidCredentialsError = AppException -class MEConnectionException(Exception): - """Raised when not found exception occurs.""" - pass +TenantNotFoundError = AppException +TenantDisabledError = AppException +AgentNotFoundError = AppException +AgentRunException = AppException +AgentDisabledError = AppException -class VoiceServiceException(Exception): - """Raised when voice service fails.""" - pass +ToolNotFoundError = AppException +ToolExecutionException = AppException +MCPConnectionError = AppException +MCPNameIllegal = AppException +MCPContainerError = AppException -class STTConnectionException(Exception): - """Raised when STT service connection fails.""" - pass +ConversationNotFoundError = AppException +MemoryNotFoundError = AppException +MemoryPreparationException = AppException -class TTSConnectionException(Exception): - """Raised when TTS service connection fails.""" - pass +KnowledgeNotFoundError = AppException +KnowledgeSearchFailedError = AppException +ModelNotFoundError = AppException -class VoiceConfigException(Exception): - """Raised when voice configuration is invalid.""" - pass +# ==================== Voice Service Aliases ==================== +VoiceServiceException = AppException +STTConnectionException = AppException +TTSConnectionException = AppException +VoiceConfigException = AppException +FileNotFoundError = AppException +FileUploadFailedError = AppException +FileTooLargeError = AppException -class ToolExecutionException(Exception): - """Raised when mcp tool execution failed.""" - pass +DifyServiceException = AppException +MEConnectionException = AppException +DataMateConnectionError = AppException +ExternalAPIError = AppException +LimitExceededError = AppException -class MCPContainerError(Exception): - """Raised when MCP container operation fails.""" - pass +# ==================== User Management Aliases ==================== +NoInviteCodeException = AppException +IncorrectInviteCodeException = AppException +UserRegistrationException = AppException +# ==================== Invitation Aliases ==================== +DuplicateError = AppException -class DuplicateError(Exception): - """Raised when a duplicate resource already exists.""" - pass +# ==================== Signature Aliases ==================== +SignatureValidationError = AppException -class DataMateConnectionError(Exception): - """Raised when DataMate connection fails or URL is not configured.""" - pass +def raise_error(error_code: ErrorCode, message: str = None, details: dict = None): + """Raise an AppException with the given error code.""" + raise AppException(error_code, message, details) diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 000000000..937f10972 --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1 @@ +# Backend middleware package diff --git a/backend/middleware/exception_handler.py b/backend/middleware/exception_handler.py new file mode 100644 index 000000000..7376e8cd4 --- /dev/null +++ b/backend/middleware/exception_handler.py @@ -0,0 +1,161 @@ +""" +Global exception handler middleware. + +This middleware provides centralized error handling for the FastAPI application. +It catches all exceptions and returns a standardized JSON response. +""" + +import logging +import traceback +import uuid +from typing import Callable + +from fastapi import Request, Response, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from consts.error_code import ErrorCode +from consts.error_message import ErrorMessage +from consts.exceptions import AppException + +logger = logging.getLogger(__name__) + + +def _http_status_to_error_code(status_code: int) -> ErrorCode: + """Map HTTP status codes to internal error codes for backward compatibility.""" + mapping = { + 400: ErrorCode.VALIDATION_ERROR, + 401: ErrorCode.UNAUTHORIZED, + 403: ErrorCode.FORBIDDEN, + 404: ErrorCode.RESOURCE_NOT_FOUND, + 429: ErrorCode.RATE_LIMIT_EXCEEDED, + 500: ErrorCode.INTERNAL_ERROR, + 502: ErrorCode.SERVICE_UNAVAILABLE, + 503: ErrorCode.SERVICE_UNAVAILABLE, + } + return mapping.get(status_code, ErrorCode.UNKNOWN_ERROR) + + +class ExceptionHandlerMiddleware(BaseHTTPMiddleware): + """ + Global exception handler middleware. + + This middleware catches all exceptions and returns a standardized response: + - For AppException: returns the error code and message + - For other exceptions: logs the error and returns a generic error response + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Generate trace ID for request tracking + trace_id = str(uuid.uuid4()) + request.state.trace_id = trace_id + + try: + response = await call_next(request) + return response + except AppException as exc: + # Log the error with trace ID + logger.error( + f"[{trace_id}] AppException: {exc.error_code.value} - {exc.message}", + extra={"trace_id": trace_id, + "error_code": exc.error_code.value} + ) + + # Use HTTP status from error code mapping, default to 500 + http_status = exc.http_status + + return JSONResponse( + status_code=http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "trace_id": trace_id, + "details": exc.details if exc.details else None + } + ) + except HTTPException as exc: + # Handle FastAPI HTTPException for backward compatibility + # Map HTTP status codes to error codes + error_code = _http_status_to_error_code(exc.status_code) + + return JSONResponse( + status_code=exc.status_code, + content={ + "code": error_code.value, + "message": exc.detail, + "trace_id": trace_id + } + ) + except Exception as exc: + # Log the full exception with traceback + logger.error( + f"[{trace_id}] Unhandled exception: {str(exc)}", + exc_info=True, + extra={"trace_id": trace_id} + ) + + # Return generic error response + return JSONResponse( + status_code=200, + content={ + "code": ErrorCode.UNKNOWN_ERROR.value, + "message": ErrorMessage.get_message(ErrorCode.UNKNOWN_ERROR), + "trace_id": trace_id + } + ) + + +def create_error_response( + error_code: ErrorCode, + message: str = None, + trace_id: str = None, + details: dict = None +) -> JSONResponse: + """ + Create a standardized error response. + + Args: + error_code: The error code + message: Optional custom message (defaults to standard message) + trace_id: Optional trace ID for tracking + details: Optional additional details + + Returns: + JSONResponse with standardized error format + """ + return JSONResponse( + status_code=200, + content={ + "code": error_code.value, + "message": message or ErrorMessage.get_message(error_code), + "trace_id": trace_id, + "details": details + } + ) + + +def create_success_response( + data: any = None, + message: str = "OK", + trace_id: str = None +) -> JSONResponse: + """ + Create a standardized success response. + + Args: + data: The response data + message: Optional success message + trace_id: Optional trace ID for tracking + + Returns: + JSONResponse with standardized success format + """ + return JSONResponse( + status_code=200, + content={ + "code": 0, # 0 indicates success + "message": message, + "data": data, + "trace_id": trace_id + } + ) diff --git a/backend/services/dify_service.py b/backend/services/dify_service.py index 14e3a4d6f..963d566bd 100644 --- a/backend/services/dify_service.py +++ b/backend/services/dify_service.py @@ -12,6 +12,8 @@ import httpx +from consts.error_code import ErrorCode +from consts.exceptions import AppException from nexent.utils.http_client_manager import http_client_manager logger = logging.getLogger("dify_service") @@ -67,11 +69,21 @@ def fetch_dify_datasets_impl( """ # Validate inputs if not dify_api_base or not isinstance(dify_api_base, str): - raise ValueError( - "dify_api_base is required and must be a non-empty string") + raise AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Dify API URL is required and must be a non-empty string" + ) + + # Validate URL format + if not (dify_api_base.startswith("http://") or dify_api_base.startswith("https://")): + raise AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Dify API URL must start with http:// or https://" + ) if not api_key or not isinstance(api_key, str): - raise ValueError("api_key is required and must be a non-empty string") + raise AppException(ErrorCode.DIFY_CONFIG_INVALID, + "Dify API key is required and must be a non-empty string") # Normalize API base URL api_base = dify_api_base.rstrip("/") @@ -157,15 +169,35 @@ def fetch_dify_datasets_impl( except httpx.RequestError as e: logger.error(f"Dify API request failed: {str(e)}") - raise Exception(f"Dify API request failed: {str(e)}") + raise AppException(ErrorCode.DIFY_CONNECTION_ERROR, + f"Dify API request failed: {str(e)}") except httpx.HTTPStatusError as e: - logger.error(f"Dify API HTTP error: {str(e)}") - raise Exception(f"Dify API HTTP error: {str(e)}") + logger.error( + f"Dify API HTTP error: {str(e)}, status_code: {e.response.status_code}") + # Map HTTP status to specific error code + if e.response.status_code == 401: + logger.error("Raising DIFY_AUTH_ERROR for 401 error") + raise AppException(ErrorCode.DIFY_AUTH_ERROR, + f"Dify authentication failed: {str(e)}") + elif e.response.status_code == 403: + logger.error("Raising DIFY_AUTH_ERROR for 403 error") + raise AppException(ErrorCode.DIFY_AUTH_ERROR, + f"Dify access forbidden: {str(e)}") + elif e.response.status_code == 429: + logger.error("Raising DIFY_RATE_LIMIT for 429 error") + raise AppException(ErrorCode.DIFY_RATE_LIMIT, + f"Dify API rate limit exceeded: {str(e)}") + else: + logger.error( + f"Raising DIFY_SERVICE_ERROR for status {e.response.status_code}") + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + f"Dify API HTTP error {e.response.status_code}: {str(e)}") except json.JSONDecodeError as e: logger.error(f"Failed to parse Dify API response: {str(e)}") - raise Exception(f"Failed to parse Dify API response: {str(e)}") + raise AppException(ErrorCode.DIFY_RESPONSE_ERROR, + f"Failed to parse Dify API response: {str(e)}") except KeyError as e: logger.error( f"Unexpected Dify API response format: missing key {str(e)}") - raise Exception( - f"Unexpected Dify API response format: missing key {str(e)}") + raise AppException(ErrorCode.DIFY_RESPONSE_ERROR, + f"Unexpected Dify API response format: missing key {str(e)}") diff --git a/frontend/const/errorCode.ts b/frontend/const/errorCode.ts new file mode 100644 index 000000000..7533e0480 --- /dev/null +++ b/frontend/const/errorCode.ts @@ -0,0 +1,174 @@ +/** + * Error code definitions for the frontend. + * + * Format: XYYZZZ + * - X: Error level (1=System, 2=Auth, 3=Business, 4=External) + * - YY: Module number (01-99) + * - ZZZ: Error sequence (001-999) + * + * Module Numbers: + * - 01: System + * - 02: Auth + * - 03: User + * - 04: Tenant + * - 05: Agent + * - 06: Tool/MCP + * - 07: Conversation + * - 08: Memory + * - 09: Knowledge + * - 10: Model + * - 11: Voice + * - 12: File + * - 13: Invitation + * - 14: Group + * - 15: Data + * - 16: External + * - 20: Validation + * - 21: Resource + * - 22: RateLimit + */ + +export enum ErrorCode { + // ==================== System Level Errors (10xxxx) ==================== + UNKNOWN_ERROR = 101001, + SERVICE_UNAVAILABLE = 101002, + DATABASE_ERROR = 101003, + TIMEOUT = 101004, + INTERNAL_ERROR = 101005, + + // ==================== Auth Level Errors (102xxx) ==================== + UNAUTHORIZED = 102001, + TOKEN_EXPIRED = 102002, + TOKEN_INVALID = 102003, + SIGNATURE_INVALID = 102004, + FORBIDDEN = 102005, + + // ==================== User Module Errors (103xxx) ==================== + USER_NOT_FOUND = 103001, + USER_REGISTRATION_FAILED = 103002, + USER_ALREADY_EXISTS = 103003, + INVALID_CREDENTIALS = 103004, + + // ==================== Tenant Module Errors (104xxx) ==================== + TENANT_NOT_FOUND = 104001, + TENANT_DISABLED = 104002, + TENANT_CONFIG_ERROR = 104003, + + // ==================== Agent Module Errors (105xxx) ==================== + AGENT_NOT_FOUND = 105001, + AGENT_RUN_FAILED = 105002, + AGENT_NAME_DUPLICATE = 105003, + AGENT_DISABLED = 105004, + AGENT_VERSION_NOT_FOUND = 105005, + + // ==================== Tool/MCP Module Errors (106xxx) ==================== + TOOL_NOT_FOUND = 106001, + TOOL_EXECUTION_FAILED = 106002, + TOOL_CONFIG_INVALID = 106003, + + // MCP specific errors (1061xx) + MCP_CONNECTION_FAILED = 106101, + MCP_NAME_ILLEGAL = 106102, + MCP_CONTAINER_ERROR = 106103, + + // ==================== Conversation Module Errors (107xxx) ==================== + CONVERSATION_NOT_FOUND = 107001, + CONVERSATION_SAVE_FAILED = 107002, + MESSAGE_NOT_FOUND = 107003, + CONVERSATION_TITLE_GENERATION_FAILED = 107004, + + // ==================== Memory Module Errors (108xxx) ==================== + MEMORY_NOT_FOUND = 108001, + MEMORY_PREPARATION_FAILED = 108002, + MEMORY_CONFIG_INVALID = 108003, + + // ==================== Knowledge Module Errors (109xxx) ==================== + KNOWLEDGE_NOT_FOUND = 109001, + KNOWLEDGE_SYNC_FAILED = 109002, + INDEX_NOT_FOUND = 109003, + KNOWLEDGE_SEARCH_FAILED = 109004, + KNOWLEDGE_UPLOAD_FAILED = 109005, + + // ==================== Model Module Errors (110xxx) ==================== + MODEL_NOT_FOUND = 110001, + MODEL_CONFIG_INVALID = 110002, + MODEL_HEALTH_CHECK_FAILED = 110003, + MODEL_PROVIDER_ERROR = 110004, + + // ==================== Voice Module Errors (111xxx) ==================== + VOICE_SERVICE_ERROR = 111001, + STT_CONNECTION_FAILED = 111002, + TTS_CONNECTION_FAILED = 111003, + VOICE_CONFIG_INVALID = 111004, + + // ==================== File Module Errors (112xxx) ==================== + FILE_NOT_FOUND = 112001, + FILE_UPLOAD_FAILED = 112002, + FILE_TOO_LARGE = 112003, + FILE_TYPE_NOT_ALLOWED = 112004, + FILE_PREPROCESS_FAILED = 112005, + + // ==================== Invitation Module Errors (113xxx) ==================== + INVITE_CODE_NOT_FOUND = 113001, + INVITE_CODE_INVALID = 113002, + INVITE_CODE_EXPIRED = 113003, + + // ==================== Group Module Errors (114xxx) ==================== + GROUP_NOT_FOUND = 114001, + GROUP_ALREADY_EXISTS = 114002, + MEMBER_NOT_IN_GROUP = 114003, + + // ==================== Data Process Module Errors (115xxx) ==================== + DATA_PROCESS_FAILED = 115001, + DATA_PARSE_FAILED = 115002, + + // ==================== External Service Errors (116xxx) ==================== + ME_CONNECTION_FAILED = 116001, + DATAMATE_CONNECTION_FAILED = 116002, + DIFY_SERVICE_ERROR = 116003, + EXTERNAL_API_ERROR = 116004, + + // Dify specific errors (1161xx) + DIFY_CONFIG_INVALID = 116101, + DIFY_CONNECTION_ERROR = 116102, + DIFY_AUTH_ERROR = 116103, + DIFY_RATE_LIMIT = 116104, + DIFY_RESPONSE_ERROR = 116105, + + // ==================== Validation Errors (120xxx) ==================== + VALIDATION_ERROR = 120001, + PARAMETER_INVALID = 120002, + MISSING_REQUIRED_FIELD = 120003, + + // ==================== Resource Errors (121xxx) ==================== + RESOURCE_NOT_FOUND = 121001, + RESOURCE_ALREADY_EXISTS = 121002, + RESOURCE_DISABLED = 121003, + + // ==================== Rate Limit Errors (122xxx) ==================== + RATE_LIMIT_EXCEEDED = 122001, + + // ==================== Success Code ==================== + SUCCESS = 0, +} + +/** + * Check if an error code represents a success. + */ +export const isSuccess = (code: number): boolean => { + return code === ErrorCode.SUCCESS; +}; + +/** + * Check if an error code represents an authentication error. + */ +export const isAuthError = (code: number): boolean => { + return code >= 102001 && code < 103000; +}; + +/** + * Check if an error code represents a session expiration. + */ +export const isSessionExpired = (code: number): boolean => { + return code === ErrorCode.TOKEN_EXPIRED || code === ErrorCode.TOKEN_INVALID; +}; diff --git a/frontend/const/errorMessage.ts b/frontend/const/errorMessage.ts new file mode 100644 index 000000000..7872366b5 --- /dev/null +++ b/frontend/const/errorMessage.ts @@ -0,0 +1,180 @@ +/** + * Error message utility functions. + * + * This module provides functions to get error messages by error code. + * For i18n support, use the getI18nErrorMessage function which reads from translation files. + */ + +import { ErrorCode } from "./errorCode"; + +/** + * Default error messages (English). + * These are fallback messages when i18n is not available. + */ +export const DEFAULT_ERROR_MESSAGES: Record = { + // ==================== System Level Errors ==================== + [ErrorCode.UNKNOWN_ERROR]: + "An unknown error occurred. Please try again later.", + [ErrorCode.SERVICE_UNAVAILABLE]: + "Service is temporarily unavailable. Please try again later.", + [ErrorCode.DATABASE_ERROR]: + "Database operation failed. Please try again later.", + [ErrorCode.TIMEOUT]: "Operation timed out. Please try again later.", + [ErrorCode.INTERNAL_ERROR]: "Internal server error. Please try again later.", + + // ==================== Auth Level Errors ==================== + [ErrorCode.UNAUTHORIZED]: "You are not authorized to perform this action.", + [ErrorCode.TOKEN_EXPIRED]: "Your session has expired. Please login again.", + [ErrorCode.TOKEN_INVALID]: "Invalid token. Please login again.", + [ErrorCode.SIGNATURE_INVALID]: "Request signature verification failed.", + [ErrorCode.FORBIDDEN]: "Access forbidden.", + + // ==================== User Module Errors ==================== + [ErrorCode.USER_NOT_FOUND]: "User not found.", + [ErrorCode.USER_REGISTRATION_FAILED]: + "User registration failed. Please try again later.", + [ErrorCode.USER_ALREADY_EXISTS]: "User already exists.", + [ErrorCode.INVALID_CREDENTIALS]: "Invalid username or password.", + + // ==================== Tenant Module Errors ==================== + [ErrorCode.TENANT_NOT_FOUND]: "Tenant not found.", + [ErrorCode.TENANT_DISABLED]: "Tenant is disabled.", + [ErrorCode.TENANT_CONFIG_ERROR]: "Tenant configuration error.", + + // ==================== Agent Module Errors ==================== + [ErrorCode.AGENT_NOT_FOUND]: "Agent not found.", + [ErrorCode.AGENT_RUN_FAILED]: "Failed to run agent. Please try again later.", + [ErrorCode.AGENT_NAME_DUPLICATE]: "Agent name already exists.", + [ErrorCode.AGENT_DISABLED]: "Agent is disabled.", + [ErrorCode.AGENT_VERSION_NOT_FOUND]: "Agent version not found.", + + // ==================== Tool/MCP Module Errors ==================== + [ErrorCode.TOOL_NOT_FOUND]: "Tool not found.", + [ErrorCode.TOOL_EXECUTION_FAILED]: "Tool execution failed.", + [ErrorCode.TOOL_CONFIG_INVALID]: "Tool configuration is invalid.", + [ErrorCode.MCP_CONNECTION_FAILED]: "Failed to connect to MCP service.", + [ErrorCode.MCP_NAME_ILLEGAL]: "MCP name contains invalid characters.", + [ErrorCode.MCP_CONTAINER_ERROR]: "MCP container operation failed.", + + // ==================== Conversation Module Errors ==================== + [ErrorCode.CONVERSATION_NOT_FOUND]: "Conversation not found.", + [ErrorCode.CONVERSATION_SAVE_FAILED]: "Failed to save conversation.", + [ErrorCode.MESSAGE_NOT_FOUND]: "Message not found.", + [ErrorCode.CONVERSATION_TITLE_GENERATION_FAILED]: + "Failed to generate conversation title.", + + // ==================== Memory Module Errors ==================== + [ErrorCode.MEMORY_NOT_FOUND]: "Memory not found.", + [ErrorCode.MEMORY_PREPARATION_FAILED]: "Failed to prepare memory.", + [ErrorCode.MEMORY_CONFIG_INVALID]: "Memory configuration is invalid.", + + // ==================== Knowledge Module Errors ==================== + [ErrorCode.KNOWLEDGE_NOT_FOUND]: "Knowledge base not found.", + [ErrorCode.KNOWLEDGE_SYNC_FAILED]: "Failed to sync knowledge base.", + [ErrorCode.INDEX_NOT_FOUND]: "Search index not found.", + [ErrorCode.KNOWLEDGE_SEARCH_FAILED]: "Knowledge search failed.", + [ErrorCode.KNOWLEDGE_UPLOAD_FAILED]: "Failed to upload knowledge.", + + // ==================== Model Module Errors ==================== + [ErrorCode.MODEL_NOT_FOUND]: "Model not found.", + [ErrorCode.MODEL_CONFIG_INVALID]: "Model configuration is invalid.", + [ErrorCode.MODEL_HEALTH_CHECK_FAILED]: "Model health check failed.", + [ErrorCode.MODEL_PROVIDER_ERROR]: "Model provider error.", + + // ==================== Voice Module Errors ==================== + [ErrorCode.VOICE_SERVICE_ERROR]: "Voice service error.", + [ErrorCode.STT_CONNECTION_FAILED]: + "Failed to connect to speech recognition service.", + [ErrorCode.TTS_CONNECTION_FAILED]: + "Failed to connect to speech synthesis service.", + [ErrorCode.VOICE_CONFIG_INVALID]: "Voice configuration is invalid.", + + // ==================== File Module Errors ==================== + [ErrorCode.FILE_NOT_FOUND]: "File not found.", + [ErrorCode.FILE_UPLOAD_FAILED]: "Failed to upload file.", + [ErrorCode.FILE_TOO_LARGE]: "File size exceeds limit.", + [ErrorCode.FILE_TYPE_NOT_ALLOWED]: "File type not allowed.", + [ErrorCode.FILE_PREPROCESS_FAILED]: "File preprocessing failed.", + + // ==================== Invitation Module Errors ==================== + [ErrorCode.INVITE_CODE_NOT_FOUND]: "Invite code not found.", + [ErrorCode.INVITE_CODE_INVALID]: "Invalid invite code.", + [ErrorCode.INVITE_CODE_EXPIRED]: "Invite code has expired.", + + // ==================== Group Module Errors ==================== + [ErrorCode.GROUP_NOT_FOUND]: "Group not found.", + [ErrorCode.GROUP_ALREADY_EXISTS]: "Group already exists.", + [ErrorCode.MEMBER_NOT_IN_GROUP]: "Member is not in the group.", + + // ==================== Data Process Module Errors ==================== + [ErrorCode.DATA_PROCESS_FAILED]: "Data processing failed.", + [ErrorCode.DATA_PARSE_FAILED]: "Data parsing failed.", + + // ==================== External Service Errors ==================== + [ErrorCode.ME_CONNECTION_FAILED]: "Failed to connect to ME service.", + [ErrorCode.DATAMATE_CONNECTION_FAILED]: + "Failed to connect to DataMate service.", + [ErrorCode.DIFY_SERVICE_ERROR]: "Dify service error.", + [ErrorCode.EXTERNAL_API_ERROR]: "External API error.", + + // ==================== Dify Specific Errors ==================== + [ErrorCode.DIFY_CONFIG_INVALID]: + "Dify configuration invalid. Please check URL and API key format.", + [ErrorCode.DIFY_CONNECTION_ERROR]: + "Failed to connect to Dify. Please check network connection and URL.", + [ErrorCode.DIFY_AUTH_ERROR]: + "Dify authentication failed. Please check your API key.", + [ErrorCode.DIFY_RATE_LIMIT]: + "Dify API rate limit exceeded. Please try again later.", + [ErrorCode.DIFY_RESPONSE_ERROR]: + "Failed to parse Dify response. Please check API URL.", + + // ==================== Validation Errors ==================== + [ErrorCode.VALIDATION_ERROR]: "Validation failed.", + [ErrorCode.PARAMETER_INVALID]: "Invalid parameter.", + [ErrorCode.MISSING_REQUIRED_FIELD]: "Required field is missing.", + + // ==================== Resource Errors ==================== + [ErrorCode.RESOURCE_NOT_FOUND]: "Resource not found.", + [ErrorCode.RESOURCE_ALREADY_EXISTS]: "Resource already exists.", + [ErrorCode.RESOURCE_DISABLED]: "Resource is disabled.", + + // ==================== Rate Limit Errors ==================== + [ErrorCode.RATE_LIMIT_EXCEEDED]: "Too many requests. Please try again later.", + + // ==================== Success ==================== + [ErrorCode.SUCCESS]: "Success", +}; + +/** + * Get error message by error code. + * + * @param code - The error code + * @returns The error message + */ +export const getErrorMessage = (code: number): string => { + return ( + DEFAULT_ERROR_MESSAGES[code] || "An error occurred. Please try again later." + ); +}; + +/** + * API Response interface. + */ +export interface ApiResponse { + code: number; + message: string; + data?: T; + trace_id?: string; + details?: any; +} + +/** + * Check if API response indicates success. + * + * @param response - The API response + * @returns True if success + */ +export const isApiSuccess = (response: ApiResponse): boolean => { + return response.code === ErrorCode.SUCCESS; +}; diff --git a/frontend/const/errorMessageI18n.ts b/frontend/const/errorMessageI18n.ts new file mode 100644 index 000000000..c6b3c9cb8 --- /dev/null +++ b/frontend/const/errorMessageI18n.ts @@ -0,0 +1,235 @@ +/** + * Error message utility with i18n support. + * + * This module provides functions to get localized error messages by error code. + */ + +import { message } from "antd"; +import { useTranslation } from "react-i18next"; +import { ErrorCode } from "./errorCode"; +import { DEFAULT_ERROR_MESSAGES } from "./errorMessage"; +import { handleSessionExpired } from "@/lib/session"; +import { isSessionExpired } from "./errorCode"; +import log from "@/lib/logger"; + +/** + * Get error message by error code with i18n support. + * + * This function tries to get the message from i18n translation files first, + * then falls back to default English messages. + * + * @param code - The error code + * @param t - Optional translation function (if not provided, returns default message) + * @returns The localized error message + */ +export const getI18nErrorMessage = ( + code: number, + t?: (key: string) => string +): string => { + // Try i18n translation first + if (t) { + const i18nKey = `errorCode.${code}`; + const translated = t(i18nKey); + // Check if translation exists (i18next returns the key if not found) + if (translated !== i18nKey) { + return translated; + } + } + + // Fall back to default message + return ( + DEFAULT_ERROR_MESSAGES[code] || + DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] + ); +}; + +/** + * Hook to get error message with i18n support. + * + * @returns A function that takes an error code and returns the localized message + */ +export const useErrorMessage = () => { + const { t } = useTranslation(); + + return (code: number) => getI18nErrorMessage(code, t); +}; + +/** + * Handle API error and return user-friendly message. + * + * @param error - The error object (can be ApiError, Error, or any) + * @param t - Optional translation function + * @returns User-friendly error message + */ +export const handleApiError = ( + error: any, + t?: (key: string) => string +): string => { + // Handle ApiError with code + if (error && typeof error === "object" && "code" in error) { + return getI18nErrorMessage(error.code as number, t); + } + + // Handle standard Error + if (error instanceof Error) { + return error.message; + } + + // Handle unknown error + return getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR, t); +}; + +/** + * Options for showing error to user + */ +export interface ShowErrorOptions { + /** Whether to handle session expiration */ + handleSession?: boolean; + /** Whether to show error message (default: true) */ + showMessage?: boolean; + /** Custom error message (overrides auto-detected message) */ + customMessage?: string; + /** Callback after error is shown */ + onError?: (error: any) => void; +} + +/** + * Show error to user with i18n support. + * + * This is a convenience function that: + * 1. Extracts the error code from the error object + * 2. Gets the i18n translated message + * 3. Shows the message to user via antd message + * 4. Optionally handles session expiration + * + * @param error - The error object (ApiError, Error, or any) + * @param t - Translation function (optional, will use default if not provided) + * @param options - Additional options + * + * @example + * // Simple usage + * showErrorToUser(error); + * + * @example + * // With translation function + * const { t } = useTranslation(); + * showErrorToUser(error, t); + * + * @example + * // With options + * showErrorToUser(error, t, { handleSession: true, onError: (e) => console.log(e) }); + */ +export const showErrorToUser = ( + error: any, + t?: (key: string) => string, + options: ShowErrorOptions = {} +): void => { + const { + handleSession = true, + showMessage = true, + customMessage, + onError, + } = options; + + // Get error code if available + let errorCode: number | undefined; + if (error && typeof error === "object" && "code" in error) { + errorCode = error.code as number; + } + + // Handle session expiration + if (handleSession && errorCode && isSessionExpired(errorCode)) { + handleSessionExpired(); + } + + // Get the error message + let errorMessage: string; + if (customMessage) { + errorMessage = customMessage; + } else if (errorCode) { + errorMessage = getI18nErrorMessage(errorCode, t); + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR, t); + } + + // Log the error + log.error(`Error [${errorCode || "unknown"}]: ${errorMessage}`, error); + + // Show message to user + if (showMessage) { + message.error(errorMessage); + } + + // Call onError callback + if (onError) { + onError(error); + } +}; + +/** + * Wrap an async function with automatic error handling. + * + * @param fn - The async function to wrap + * @param options - Error handling options + * @returns Wrapped function that automatically handles errors + * + * @example + * const safeFetchData = withErrorHandler(async () => { + * const result = await api.fetchData(); + * return result; + * }, { handleSession: true }); + * + * // Usage + * await safeFetchData(); + */ +export const withErrorHandler = ( + fn: () => Promise, + options: ShowErrorOptions = {} +) => { + return async (...args: any[]) => { + try { + return await fn(...args); + } catch (error) { + showErrorToUser(error, undefined, options); + throw error; + } + }; +}; + +/** + * Check if error requires session refresh action. + * + * @param code - The error code + * @returns True if user needs to re-login + */ +export const requiresSessionRefresh = (code: number): boolean => { + return code === ErrorCode.TOKEN_EXPIRED || code === ErrorCode.TOKEN_INVALID; +}; + +/** + * Check if error is a validation error. + * + * @param code - The error code + * @returns True if it's a validation error + */ +export const isValidationError = (code: number): boolean => { + return code >= 120001 && code < 121000; +}; + +/** + * Check if error is a resource not found error. + * + * @param code - The error code + * @returns True if resource not found + */ +export const isNotFoundError = (code: number): boolean => { + return ( + code === ErrorCode.RESOURCE_NOT_FOUND || + code === ErrorCode.AGENT_NOT_FOUND || + code === ErrorCode.USER_NOT_FOUND || + code === ErrorCode.FILE_NOT_FOUND || + code === ErrorCode.KNOWLEDGE_NOT_FOUND + ); +}; diff --git a/frontend/hooks/useErrorHandler.ts b/frontend/hooks/useErrorHandler.ts new file mode 100644 index 000000000..cf312c974 --- /dev/null +++ b/frontend/hooks/useErrorHandler.ts @@ -0,0 +1,167 @@ +/** + * Custom hook for handling API errors with i18n support. + * + * This hook provides utilities to: + * - Convert error codes to localized messages + * - Handle session expiration + * - Provide consistent error handling across the app + */ + +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { message } from "antd"; + +import { ErrorCode, isSessionExpired } from "@/const/errorCode"; +import { DEFAULT_ERROR_MESSAGES } from "@/const/errorMessage"; +import { ApiError } from "@/services/api"; +import { handleSessionExpired } from "@/lib/session"; +import log from "@/lib/logger"; + +/** + * Options for error handling + */ +export interface ErrorHandlerOptions { + /** Whether to show error message to user */ + showMessage?: boolean; + /** Custom error message key prefix */ + messagePrefix?: string; + /** Callback on error */ + onError?: (error: Error) => void; + /** Whether to handle session expiration */ + handleSession?: boolean; +} + +/** + * Default error handler options + */ +const DEFAULT_OPTIONS: ErrorHandlerOptions = { + showMessage: true, + handleSession: true, +}; + +/** + * Hook for handling API errors with i18n support + */ +export const useErrorHandler = () => { + const { t } = useTranslation(); + + /** + * Get i18n error message by error code + */ + const getI18nErrorMessage = useCallback( + (code: number): string => { + // Try to get i18n key + const i18nKey = `errorCode.${code}`; + const translated = t(i18nKey); + + // If translation exists (not equal to key), return translated message + if (translated !== i18nKey) { + return translated; + } + + // Fallback to default messages + return ( + DEFAULT_ERROR_MESSAGES[code] || + DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] + ); + }, + [t] + ); + + /** + * Handle API error + */ + const handleError = useCallback( + (error: unknown, options: ErrorHandlerOptions = {}) => { + const { showMessage, onError, handleSession } = { + ...DEFAULT_OPTIONS, + ...options, + }; + + // Handle ApiError + if (error instanceof ApiError) { + // Handle session expiration + if (handleSession && isSessionExpired(error.code)) { + handleSessionExpired(); + } + + // Get localized message + const errorMessage = getI18nErrorMessage(error.code); + + // Log error + log.error(`API Error [${error.code}]: ${errorMessage}`, error); + + // Show message to user + if (showMessage) { + message.error(errorMessage); + } + + // Call onError callback + if (onError) { + onError(error); + } + + return { + code: error.code, + message: errorMessage, + originalError: error, + }; + } + + // Handle unknown error + if (error instanceof Error) { + log.error("Unknown error:", error); + + if (showMessage) { + message.error(getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR)); + } + + if (onError) { + onError(error); + } + + return { + code: ErrorCode.UNKNOWN_ERROR, + message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), + originalError: error, + }; + } + + // Handle non-Error objects + log.error("Non-error object thrown:", error); + + if (showMessage) { + message.error(getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR)); + } + + return { + code: ErrorCode.UNKNOWN_ERROR, + message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), + originalError: null, + }; + }, + [getI18nErrorMessage] + ); + + /** + * Wrap async function with error handling + */ + const withErrorHandler = useCallback( + (fn: () => Promise, options: ErrorHandlerOptions = {}) => { + return async (...args: any[]) => { + try { + return await fn(...args); + } catch (error) { + throw handleError(error, options); + } + }; + }, + [handleError] + ); + + return { + getI18nErrorMessage, + handleError, + withErrorHandler, + }; +}; diff --git a/frontend/hooks/useKnowledgeBaseSelector.ts b/frontend/hooks/useKnowledgeBaseSelector.ts index b3f5de554..a38102291 100644 --- a/frontend/hooks/useKnowledgeBaseSelector.ts +++ b/frontend/hooks/useKnowledgeBaseSelector.ts @@ -2,10 +2,12 @@ import { useState, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import knowledgeBaseService from "@/services/knowledgeBaseService"; import { KnowledgeBase } from "@/types/knowledgeBase"; import log from "@/lib/logger"; +import { showErrorToUser } from "@/const/errorMessageI18n"; /** * Query key factory for knowledge bases @@ -34,6 +36,8 @@ export function useKnowledgeBasesForToolConfig( apiKey?: string; } ) { + const { t } = useTranslation(); + // Support both difyConfig and datamateConfig naming conventions const difyConfig = config; const datamateConfig = config; @@ -94,8 +98,10 @@ export function useKnowledgeBasesForToolConfig( difyConfig.serverUrl, difyConfig.apiKey ); - } catch (error) { + } catch (error: any) { log.error("Failed to fetch Dify knowledge bases:", error); + // Show i18n error message to user + showErrorToUser(error, t); kbs = []; } } else { @@ -130,6 +136,7 @@ export function useKnowledgeBasesForToolConfig( * Call this when the user navigates to the agent config page */ export function usePrefetchKnowledgeBases() { + const { t } = useTranslation(); const queryClient = useQueryClient(); const prefetchKnowledgeBases = useCallback( @@ -194,8 +201,10 @@ export function usePrefetchKnowledgeBases() { difyConfig.serverUrl, difyConfig.apiKey ); - } catch (error) { + } catch (error: any) { log.error("Failed to prefetch Dify knowledge bases:", error); + // Show i18n error message to user + showErrorToUser(error, t); kbs = []; } } else { @@ -225,6 +234,7 @@ export function usePrefetchKnowledgeBases() { * Hook for syncing knowledge bases by tool type */ export function useSyncKnowledgeBases() { + const { t } = useTranslation(); const [isSyncing, setIsSyncing] = useState(null); const syncKnowledgeBases = useCallback( @@ -261,6 +271,10 @@ export function useSyncKnowledgeBases() { // Default sync behavior - sync Nexent only await knowledgeBaseService.getKnowledgeBasesInfo(false, false); } + } catch (error: any) { + log.error("Failed to sync knowledge bases:", error); + // Show i18n error message to user + showErrorToUser(error, t); } finally { setIsSyncing(null); } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 69ebe24a8..4f3790fba 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1754,5 +1754,83 @@ "agent.version.statusUpdateError": "Failed to update version status", "agent.version.needTwoVersions": "At least two versions are required to compare", "agent.version.selectDifferentVersions": "Please select two different versions to compare", - "agent.error.agentNotFound": "Agent not found" + "agent.error.agentNotFound": "Agent not found", + + "errorCode.101001": "An unknown error occurred. Please try again later.", + "errorCode.101002": "Service is temporarily unavailable. Please try again later.", + "errorCode.101003": "Database operation failed. Please try again later.", + "errorCode.101004": "Operation timed out. Please try again later.", + "errorCode.101005": "Internal server error. Please try again later.", + "errorCode.102001": "You are not authorized to perform this action.", + "errorCode.102002": "Your session has expired. Please login again.", + "errorCode.102003": "Invalid token. Please login again.", + "errorCode.102004": "Request signature verification failed.", + "errorCode.102005": "Access forbidden.", + "errorCode.103001": "User not found.", + "errorCode.103002": "User registration failed. Please try again later.", + "errorCode.103003": "User already exists.", + "errorCode.103004": "Invalid username or password.", + "errorCode.104001": "Tenant not found.", + "errorCode.104002": "Tenant is disabled.", + "errorCode.104003": "Tenant configuration error.", + "errorCode.105001": "Agent not found.", + "errorCode.105002": "Failed to run agent. Please try again later.", + "errorCode.105003": "Agent name already exists.", + "errorCode.105004": "Agent is disabled.", + "errorCode.105005": "Agent version not found.", + "errorCode.106001": "Tool not found.", + "errorCode.106002": "Tool execution failed.", + "errorCode.106003": "Tool configuration is invalid.", + "errorCode.106101": "Failed to connect to MCP service.", + "errorCode.106102": "MCP name contains invalid characters.", + "errorCode.106103": "MCP container operation failed.", + "errorCode.107001": "Conversation not found.", + "errorCode.107002": "Failed to save conversation.", + "errorCode.107003": "Message not found.", + "errorCode.107004": "Failed to generate conversation title.", + "errorCode.108001": "Memory not found.", + "errorCode.108002": "Failed to prepare memory.", + "errorCode.108003": "Memory configuration is invalid.", + "errorCode.109001": "Knowledge base not found.", + "errorCode.109002": "Failed to sync knowledge base.", + "errorCode.109003": "Search index not found.", + "errorCode.109004": "Knowledge search failed.", + "errorCode.109005": "Failed to upload knowledge.", + "errorCode.110001": "Model not found.", + "errorCode.110002": "Model configuration is invalid.", + "errorCode.110003": "Model health check failed.", + "errorCode.110004": "Model provider error.", + "errorCode.111001": "Voice service error.", + "errorCode.111002": "Failed to connect to speech recognition service.", + "errorCode.111003": "Failed to connect to speech synthesis service.", + "errorCode.111004": "Voice configuration is invalid.", + "errorCode.112001": "File not found.", + "errorCode.112002": "Failed to upload file.", + "errorCode.112003": "File size exceeds limit.", + "errorCode.112004": "File type not allowed.", + "errorCode.112005": "File preprocessing failed.", + "errorCode.113001": "Invite code not found.", + "errorCode.113002": "Invalid invite code.", + "errorCode.113003": "Invite code has expired.", + "errorCode.114001": "Group not found.", + "errorCode.114002": "Group already exists.", + "errorCode.114003": "Member is not in the group.", + "errorCode.115001": "Data processing failed.", + "errorCode.115002": "Data parsing failed.", + "errorCode.116001": "Failed to connect to ME service.", + "errorCode.116002": "Failed to connect to DataMate service.", + "errorCode.116003": "Dify service error.", + "errorCode.116004": "External API error.", + "errorCode.120001": "Validation failed.", + "errorCode.120002": "Invalid parameter.", + "errorCode.120003": "Required field is missing.", + "errorCode.121001": "Resource not found.", + "errorCode.121002": "Resource already exists.", + "errorCode.121003": "Resource is disabled.", + "errorCode.122001": "Too many requests. Please try again later.", + "errorCode.116101": "Dify configuration invalid. Please check URL and API key format.", + "errorCode.116102": "Failed to connect to Dify. Please check network connection and URL.", + "errorCode.116103": "Dify authentication failed. Please check your API key.", + "errorCode.116104": "Dify API rate limit exceeded. Please try again later.", + "errorCode.116105": "Failed to parse Dify response. Please check API URL." } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 23df67ded..05d3b83ae 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1756,5 +1756,102 @@ "agent.version.statusUpdateError": "更新版本状态失败", "agent.version.needTwoVersions": "至少需要两个版本才能进行对比", "agent.version.selectDifferentVersions": "请选择两个不同的版本进行对比", - "agent.error.agentNotFound": "未找到智能体" + "agent.error.agentNotFound": "未找到智能体", + + "errorCode.101001": "发生未知错误,请稍后重试", + "errorCode.101002": "服务暂时不可用,请稍后重试", + "errorCode.101003": "数据库操作失败,请稍后重试", + "errorCode.101004": "操作超时,请稍后重试", + "errorCode.101005": "服务器内部错误,请稍后重试", + + "errorCode.102001": "您没有执行此操作的权限", + "errorCode.102002": "您的登录已过期,请重新登录", + "errorCode.102003": "登录令牌无效,请重新登录", + "errorCode.102004": "请求签名验证失败", + "errorCode.102005": "禁止访问", + + "errorCode.103001": "用户不存在", + "errorCode.103002": "用户注册失败,请稍后重试", + "errorCode.103003": "用户已存在", + "errorCode.103004": "用户名或密码错误", + + "errorCode.104001": "租户不存在", + "errorCode.104002": "租户已被禁用", + "errorCode.104003": "租户配置错误", + + "errorCode.105001": "智能体不存在", + "errorCode.105002": "运行智能体失败,请稍后重试", + "errorCode.105003": "智能体名称已存在", + "errorCode.105004": "智能体已被禁用", + "errorCode.105005": "智能体版本不存在", + + "errorCode.106001": "工具不存在", + "errorCode.106002": "工具执行失败", + "errorCode.106003": "工具配置无效", + "errorCode.106101": "连接MCP服务失败", + "errorCode.106102": "MCP名称包含非法字符", + "errorCode.106103": "MCP容器操作失败", + + "errorCode.107001": "对话不存在", + "errorCode.107002": "保存对话失败", + "errorCode.107003": "消息不存在", + "errorCode.107004": "生成对话标题失败", + + "errorCode.108001": "记忆不存在", + "errorCode.108002": "准备记忆失败", + "errorCode.108003": "记忆配置无效", + + "errorCode.109001": "知识库不存在", + "errorCode.109002": "同步知识库失败", + "errorCode.109003": "搜索索引不存在", + "errorCode.109004": "知识搜索失败", + "errorCode.109005": "上传知识失败", + + "errorCode.110001": "模型不存在", + "errorCode.110002": "模型配置无效", + "errorCode.110003": "模型健康检查失败", + "errorCode.110004": "模型提供商错误", + + "errorCode.111001": "语音服务错误", + "errorCode.111002": "连接语音识别服务失败", + "errorCode.111003": "连接语音合成服务失败", + "errorCode.111004": "语音配置无效", + + "errorCode.112001": "文件不存在", + "errorCode.112002": "文件上传失败", + "errorCode.112003": "文件大小超出限制", + "errorCode.112004": "不支持的文件类型", + "errorCode.112005": "文件预处理失败", + + "errorCode.113001": "邀请码不存在", + "errorCode.113002": "邀请码无效", + "errorCode.113003": "邀请码已过期", + + "errorCode.114001": "群组不存在", + "errorCode.114002": "群组已存在", + "errorCode.114003": "成员不在群组中", + + "errorCode.115001": "数据处理失败", + "errorCode.115002": "数据解析失败", + + "errorCode.116001": "连接ME服务失败", + "errorCode.116002": "连接DataMate服务失败", + "errorCode.116003": "Dify服务错误", + "errorCode.116004": "外部API错误", + + "errorCode.116101": "Dify配置无效,请检查URL和API Key格式", + "errorCode.116102": "连接Dify失败,请检查网络和URL", + "errorCode.116103": "Dify认证失败,请检查API Key", + "errorCode.116104": "Dify请求频率超限,请稍后重试", + "errorCode.116105": "Dify响应解析失败,请检查API URL", + + "errorCode.120001": "验证失败", + "errorCode.120002": "参数无效", + "errorCode.120003": "缺少必填字段", + + "errorCode.121001": "资源不存在", + "errorCode.121002": "资源已存在", + "errorCode.121003": "资源已被禁用", + + "errorCode.122001": "请求过于频繁,请稍后重试" } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index fbf5b4336..42fd75b68 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -343,12 +343,25 @@ export const fetchWithErrorHandling = async ( ); } - // Other HTTP errors + // Other HTTP errors - try to parse JSON response for error code + let errorCode = response.status; + let errorMessage = `Request failed: ${response.status}`; const errorText = await response.text(); - throw new ApiError( - response.status, - errorText || `Request failed: ${response.status}` - ); + + try { + const errorData = JSON.parse(errorText); + if (errorData && errorData.code) { + errorCode = errorData.code; + errorMessage = errorData.message || errorMessage; + } else { + errorMessage = errorText || errorMessage; + } + } catch { + // Not JSON, use text as message + errorMessage = errorText || errorMessage; + } + + throw new ApiError(errorCode, errorMessage); } return response; diff --git a/frontend/services/knowledgeBaseService.ts b/frontend/services/knowledgeBaseService.ts index 0fe76ced7..5558eabd0 100644 --- a/frontend/services/knowledgeBaseService.ts +++ b/frontend/services/knowledgeBaseService.ts @@ -2,7 +2,7 @@ import i18n from "i18next"; -import { API_ENDPOINTS } from "./api"; +import { API_ENDPOINTS, ApiError } from "./api"; import { NAME_CHECK_STATUS } from "@/const/agentConfig"; import { FILE_TYPES, EXTENSION_TO_TYPE_MAP } from "@/const/knowledgeBase"; @@ -53,32 +53,35 @@ class KnowledgeBaseService { count: number; indices_info: any[]; }> { - try { - // Call backend proxy endpoint to avoid CORS issues - const url = new URL(API_ENDPOINTS.dify.datasets, window.location.origin); - url.searchParams.set("dify_api_base", difyApiBase); - url.searchParams.set("api_key", apiKey); - - const response = await fetch(url.toString(), { - method: "GET", - headers: getAuthHeaders(), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to fetch Dify datasets"); - } - - const result = await response.json(); - return { - indices: result.indices || [], - count: result.count || 0, - indices_info: result.indices_info || [], - }; - } catch (error) { - log.error("Failed to sync Dify knowledge bases:", error); - throw error; + // Call backend proxy endpoint to avoid CORS issues + const url = new URL(API_ENDPOINTS.dify.datasets, window.location.origin); + url.searchParams.set("dify_api_base", difyApiBase); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: getAuthHeaders(), + }); + + const result = await response.json(); + + // Check for error response from middleware (has code field) + if (result.code !== undefined && result.code !== 0) { + // Use backend error code and message + const errorCode = result.code || response.status; + const errorMessage = result.message || "Failed to fetch Dify datasets"; + log.error("Dify API error:", { code: errorCode, message: errorMessage }); + + // Use ApiError for proper error handling with i18n support + throw new ApiError(errorCode, errorMessage); } + + // Success: result is directly the data (indices, count, indices_info) + return { + indices: result.indices || [], + count: result.count || 0, + indices_info: result.indices_info || [], + }; } // Get Dify knowledge bases as KnowledgeBase array From 29c8164a873696ce44254bd0b404989d1588bd06 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Fri, 27 Feb 2026 11:47:17 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A7=20Update=20error=20handling:?= =?UTF-8?q?=20Adjusted=20HTTP=20status=20codes=20for=20DIFY=20errors=20to?= =?UTF-8?q?=20401,=20improved=20exception=20handling=20middleware=20to=20r?= =?UTF-8?q?eturn=20proper=20HTTP=20status,=20and=20refined=20error=20respo?= =?UTF-8?q?nse=20creation.=20Enhanced=20frontend=20error=20handling=20to?= =?UTF-8?q?=20manage=20session=20expiration=20and=20specific=20error=20cod?= =?UTF-8?q?es=20more=20effectively.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/consts/error_code.py | 4 +- backend/middleware/exception_handler.py | 26 +- backend/services/dify_service.py | 2 +- doc/docs/error_code_handling.md | 950 ++++++++++++++++++++++++ frontend/services/api.ts | 58 +- 5 files changed, 1001 insertions(+), 39 deletions(-) create mode 100644 doc/docs/error_code_handling.md diff --git a/backend/consts/error_code.py b/backend/consts/error_code.py index e234b8a3d..c230b683e 100644 --- a/backend/consts/error_code.py +++ b/backend/consts/error_code.py @@ -167,8 +167,8 @@ class ErrorCode(int, Enum): ErrorCode.PARAMETER_INVALID: 400, ErrorCode.MISSING_REQUIRED_FIELD: 400, ErrorCode.FILE_TOO_LARGE: 413, - ErrorCode.DIFY_CONFIG_INVALID: 400, - ErrorCode.DIFY_AUTH_ERROR: 400, + ErrorCode.DIFY_CONFIG_INVALID: 401, + ErrorCode.DIFY_AUTH_ERROR: 401, ErrorCode.DIFY_CONNECTION_ERROR: 502, ErrorCode.DIFY_RESPONSE_ERROR: 502, } diff --git a/backend/middleware/exception_handler.py b/backend/middleware/exception_handler.py index 7376e8cd4..fd0ecf58f 100644 --- a/backend/middleware/exception_handler.py +++ b/backend/middleware/exception_handler.py @@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware -from consts.error_code import ErrorCode +from consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS from consts.error_message import ErrorMessage from consts.exceptions import AppException @@ -94,13 +94,15 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: extra={"trace_id": trace_id} ) - # Return generic error response + # Return generic error response with proper HTTP 500 status + # Using mixed mode: HTTP status code + business error code return JSONResponse( - status_code=200, + status_code=500, content={ - "code": ErrorCode.UNKNOWN_ERROR.value, - "message": ErrorMessage.get_message(ErrorCode.UNKNOWN_ERROR), - "trace_id": trace_id + "code": ErrorCode.INTERNAL_ERROR.value, + "message": ErrorMessage.get_message(ErrorCode.INTERNAL_ERROR), + "trace_id": trace_id, + "details": None } ) @@ -109,22 +111,28 @@ def create_error_response( error_code: ErrorCode, message: str = None, trace_id: str = None, - details: dict = None + details: dict = None, + http_status: int = None ) -> JSONResponse: """ - Create a standardized error response. + Create a standardized error response with mixed mode (HTTP status + business error code). Args: error_code: The error code message: Optional custom message (defaults to standard message) trace_id: Optional trace ID for tracking details: Optional additional details + http_status: Optional HTTP status code (defaults to mapping from error_code) Returns: JSONResponse with standardized error format """ + # Use provided http_status or get from error code mapping + status = http_status if http_status else ERROR_CODE_HTTP_STATUS.get( + error_code, 500) + return JSONResponse( - status_code=200, + status_code=status, content={ "code": error_code.value, "message": message or ErrorMessage.get_message(error_code), diff --git a/backend/services/dify_service.py b/backend/services/dify_service.py index 963d566bd..d99300cd0 100644 --- a/backend/services/dify_service.py +++ b/backend/services/dify_service.py @@ -107,7 +107,7 @@ def fetch_dify_datasets_impl( # Use shared HttpClientManager for connection pooling client = http_client_manager.get_sync_client( base_url=api_base, - timeout=30.0, + timeout=5.0, verify_ssl=False ) response = client.get(url, headers=headers) diff --git a/doc/docs/error_code_handling.md b/doc/docs/error_code_handling.md new file mode 100644 index 000000000..4a51f1c2d --- /dev/null +++ b/doc/docs/error_code_handling.md @@ -0,0 +1,950 @@ +# 错误码处理方案 + +## 1. 错误码格式设计 + +项目采用了统一的 **XYYZZZ** 格式的 6 位数字错误码: + +| 位置 | 含义 | 取值范围 | +|------|------|----------| +| **X** | 错误级别 | 1=系统, 2=认证, 3=业务, 4=外部 | +| **YY** | 模块编号 | 01-22 (系统、认证、用户、租户、Agent、工具、对话、记忆、知识、模型、语音、文件、邀请、分组、数据、外部、验证、资源、限流等) | +| **ZZZ** | 错误序号 | 001-999 | + +### 模块编号对照表 + +| 编号 | 模块 | 编号 | 模块 | +|------|------|------|------| +| 01 | System | 12 | File | +| 02 | Auth | 13 | Invitation | +| 03 | User | 14 | Group | +| 04 | Tenant | 15 | Data | +| 05 | Agent | 16 | External | +| 06 | Tool/MCP | 20 | Validation | +| 07 | Conversation | 21 | Resource | +| 08 | Memory | 22 | RateLimit | +| 09 | Knowledge | | | +| 10 | Model | | | +| 11 | Voice | | | + +--- + +## 2. 后端错误码实现 + +### 核心文件 + +| 文件路径 | 职责 | +|----------|------| +| `backend/consts/error_code.py` | 错误码枚举定义 | +| `backend/consts/error_message.py` | 错误消息映射 | +| `backend/consts/exceptions.py` | 自定义异常类 | + +### 错误码枚举定义 + +```python +# backend/consts/error_code.py +class ErrorCode(int, Enum): + """Business error codes.""" + + # ==================== System Level Errors (10xxxx) ==================== + UNKNOWN_ERROR = 101001 + SERVICE_UNAVAILABLE = 101002 + DATABASE_ERROR = 101003 + TIMEOUT = 101004 + INTERNAL_ERROR = 101005 + + # ==================== Auth Level Errors (102xxx) ==================== + UNAUTHORIZED = 102001 + TOKEN_EXPIRED = 102002 + TOKEN_INVALID = 102003 + SIGNATURE_INVALID = 102004 + FORBIDDEN = 102005 + + # ==================== User Module Errors (103xxx) ==================== + USER_NOT_FOUND = 103001 + USER_REGISTRATION_FAILED = 103002 + USER_ALREADY_EXISTS = 103003 + INVALID_CREDENTIALS = 103004 + + # ==================== Tenant Module Errors (104xxx) ==================== + TENANT_NOT_FOUND = 104001 + TENANT_DISABLED = 104002 + TENANT_CONFIG_ERROR = 104003 + + # ==================== Agent Module Errors (105xxx) ==================== + AGENT_NOT_FOUND = 105001 + AGENT_RUN_FAILED = 105002 + AGENT_NAME_DUPLICATE = 105003 + AGENT_DISABLED = 105004 + AGENT_VERSION_NOT_FOUND = 105005 + + # ==================== Tool/MCP Module Errors (106xxx) ==================== + TOOL_NOT_FOUND = 106001 + TOOL_EXECUTION_FAILED = 106002 + TOOL_CONFIG_INVALID = 106003 + MCP_CONNECTION_FAILED = 106101 + MCP_NAME_ILLEGAL = 106102 + MCP_CONTAINER_ERROR = 106103 + + # ==================== Conversation Module Errors (107xxx) ==================== + CONVERSATION_NOT_FOUND = 107001 + CONVERSATION_SAVE_FAILED = 107002 + MESSAGE_NOT_FOUND = 107003 + CONVERSATION_TITLE_GENERATION_FAILED = 107004 + + # ==================== Memory Module Errors (108xxx) ==================== + MEMORY_NOT_FOUND = 108001 + MEMORY_PREPARATION_FAILED = 108002 + MEMORY_CONFIG_INVALID = 108003 + + # ==================== Knowledge Module Errors (109xxx) ==================== + KNOWLEDGE_NOT_FOUND = 109001 + KNOWLEDGE_SYNC_FAILED = 109002 + INDEX_NOT_FOUND = 109003 + KNOWLEDGE_SEARCH_FAILED = 109004 + KNOWLEDGE_UPLOAD_FAILED = 109005 + + # ==================== Model Module Errors (110xxx) ==================== + MODEL_NOT_FOUND = 110001 + MODEL_CONFIG_INVALID = 110002 + MODEL_HEALTH_CHECK_FAILED = 110003 + MODEL_PROVIDER_ERROR = 110004 + + # ==================== Voice Module Errors (111xxx) ==================== + VOICE_SERVICE_ERROR = 111001 + STT_CONNECTION_FAILED = 111002 + TTS_CONNECTION_FAILED = 111003 + VOICE_CONFIG_INVALID = 111004 + + # ==================== File Module Errors (112xxx) ==================== + FILE_NOT_FOUND = 112001 + FILE_UPLOAD_FAILED = 112002 + FILE_TOO_LARGE = 112003 + FILE_TYPE_NOT_ALLOWED = 112004 + FILE_PREPROCESS_FAILED = 112005 + + # ==================== Invitation Module Errors (113xxx) ==================== + INVITE_CODE_NOT_FOUND = 113001 + INVITE_CODE_INVALID = 113002 + INVITE_CODE_EXPIRED = 113003 + + # ==================== Group Module Errors (114xxx) ==================== + GROUP_NOT_FOUND = 114001 + GROUP_ALREADY_EXISTS = 114002 + MEMBER_NOT_IN_GROUP = 114003 + + # ==================== Data Process Module Errors (115xxx) ==================== + DATA_PROCESS_FAILED = 115001 + DATA_PARSE_FAILED = 115002 + + # ==================== External Service Errors (116xxx) ==================== + ME_CONNECTION_FAILED = 116001 + DATAMATE_CONNECTION_FAILED = 116002 + DIFY_SERVICE_ERROR = 116003 + EXTERNAL_API_ERROR = 116004 + DIFY_CONFIG_INVALID = 116101 + DIFY_CONNECTION_ERROR = 116102 + DIFY_AUTH_ERROR = 116103 + DIFY_RATE_LIMIT = 116104 + DIFY_RESPONSE_ERROR = 116105 + + # ==================== Validation Errors (120xxx) ==================== + VALIDATION_ERROR = 120001 + PARAMETER_INVALID = 120002 + MISSING_REQUIRED_FIELD = 120003 + + # ==================== Resource Errors (121xxx) ==================== + RESOURCE_NOT_FOUND = 121001 + RESOURCE_ALREADY_EXISTS = 121002 + RESOURCE_DISABLED = 121003 + + # ==================== Rate Limit Errors (122xxx) ==================== + RATE_LIMIT_EXCEEDED = 122001 + + +# HTTP status code mapping +ERROR_CODE_HTTP_STATUS = { + ErrorCode.UNAUTHORIZED: 401, + ErrorCode.TOKEN_EXPIRED: 401, + ErrorCode.TOKEN_INVALID: 401, + ErrorCode.SIGNATURE_INVALID: 401, + ErrorCode.FORBIDDEN: 403, + ErrorCode.RATE_LIMIT_EXCEEDED: 429, + ErrorCode.DIFY_RATE_LIMIT: 429, + ErrorCode.VALIDATION_ERROR: 400, + ErrorCode.PARAMETER_INVALID: 400, + ErrorCode.MISSING_REQUIRED_FIELD: 400, + ErrorCode.FILE_TOO_LARGE: 413, + ErrorCode.DIFY_CONFIG_INVALID: 400, + ErrorCode.DIFY_AUTH_ERROR: 400, + ErrorCode.DIFY_CONNECTION_ERROR: 502, + ErrorCode.DIFY_RESPONSE_ERROR: 502, +} +``` + +### 自定义异常类 + +```python +# backend/consts/exceptions.py +class AppException(Exception): + """Base application exception with error code.""" + + def __init__(self, error_code: ErrorCode, message: str = None, details: dict = None): + self.error_code = error_code + self.message = message or ErrorMessage.get_message(error_code) + self.details = details or {} + super().__init__(self.message) + + def to_dict(self) -> dict: + return { + "code": self.error_code.value, + "message": self.message, + "details": self.details if self.details else None + } + + @property + def http_status(self) -> int: + return ERROR_CODE_HTTP_STATUS.get(self.error_code, 500) + + +# Backward compatible aliases +NotFoundException = AppException +UnauthorizedError = AppException +ValidationError = AppException +ParameterInvalidError = AppException +ForbiddenError = AppException +ServiceUnavailableError = AppException +DatabaseError = AppException +TimeoutError = AppException +UnknownError = AppException + +# Domain Specific Aliases +UserNotFoundError = AppException +UserAlreadyExistsError = AppException +InvalidCredentialsError = AppException + +TenantNotFoundError = AppException +TenantDisabledError = AppException + +AgentNotFoundError = AppException +AgentRunException = AppException +AgentDisabledError = AppException + +ToolNotFoundError = AppException +ToolExecutionException = AppException + +MCPConnectionError = AppException +MCPNameIllegal = AppException +MCPContainerError = AppException + + +def raise_error(error_code: ErrorCode, message: str = None, details: dict = None): + """Raise an AppException with the given error code.""" + raise AppException(error_code, message, details) +``` + +### 使用示例 + +```python +from consts.error_code import ErrorCode +from consts.exceptions import AppException + +# 方式1: 直接抛出 +raise AppException(ErrorCode.AGENT_NOT_FOUND) + +# 方式2: 带自定义消息和详情 +raise AppException( + ErrorCode.MCP_CONNECTION_FAILED, + "Connection timeout", + details={"host": "localhost", "port": 8080} +) + +# 方式3: 使用别名 +raise AgentNotFoundError(ErrorCode.AGENT_NOT_FOUND) + +# 方式4: 使用辅助函数 +from consts.exceptions import raise_error +raise_error(ErrorCode.USER_NOT_FOUND, "User ID not found", details={"user_id": 123}) +``` + +--- + +## 3. 后端异常处理 + +### 全局异常处理中间件 + +```python +# backend/middleware/exception_handler.py +class ExceptionHandlerMiddleware(BaseHTTPMiddleware): + """Global exception handler middleware.""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Generate trace ID for request tracking + trace_id = str(uuid.uuid4()) + request.state.trace_id = trace_id + + try: + response = await call_next(request) + return response + except AppException as exc: + # Handle AppException with error code + logger.error(f"[{trace_id}] AppException: {exc.error_code.value} - {exc.message}") + + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "trace_id": trace_id, + "details": exc.details if exc.details else None + } + ) + except HTTPException as exc: + # Handle FastAPI HTTPException for backward compatibility + error_code = _http_status_to_error_code(exc.status_code) + + return JSONResponse( + status_code=exc.status_code, + content={ + "code": error_code.value, + "message": exc.detail, + "trace_id": trace_id + } + ) + except Exception as exc: + # Handle unknown exceptions - return HTTP 500 + logger.error(f"[{trace_id}] Unhandled exception: {str(exc)}", exc_info=True) + + return JSONResponse( + status_code=500, + content={ + "code": ErrorCode.INTERNAL_ERROR.value, + "message": ErrorMessage.get_message(ErrorCode.INTERNAL_ERROR), + "trace_id": trace_id, + "details": None + } + ) +``` + +### HTTP 状态码与业务错误码映射 + +项目采用 **混合模式**:HTTP 状态码 + 业务错误码。 + +| 业务错误码 | HTTP 状态码 | 说明 | +|------------|-------------|------| +| 102001-102005 (认证错误) | 401 | 未授权 | +| 102005 (FORBIDDEN) | 403 | 禁止访问 | +| 122001 (RATE_LIMIT_EXCEEDED) | 429 | 请求过于频繁 | +| 120001-120003 (验证错误) | 400 | 请求参数错误 | +| 112003 (FILE_TOO_LARGE) | 413 | 请求实体过大 | +| 116102, 116105 (Dify 连接/响应错误) | 502 | 网关错误 | +| 其他 AppException | 使用映射表或默认 500 | 根据错误码映射 | +| 未知异常 | 500 | 服务器内部错误 | + +### 响应格式 + +**成功响应:** +```json +{ + "code": 0, + "message": "OK", + "data": { ... }, + "trace_id": "uuid-string" +} +``` + +**错误响应:** + +```http +HTTP/1.1 404 Not Found +Content-Type: application/json + +{ + "code": 105001, + "message": "Agent not found.", + "trace_id": "uuid-string", + "details": null +} +``` + +```http +HTTP/1.1 401 Unauthorized +Content-Type: application/json + +{ + "code": 102002, + "message": "Your session has expired. Please login again.", + "trace_id": "uuid-string" +} +``` + +```http +HTTP/1.1 500 Internal Server Error +Content-Type: application/json + +{ + "code": 101005, + "message": "Internal server error. Please try again later.", + "trace_id": "uuid-string" +} +``` + +### 辅助函数 + +```python +# backend/middleware/exception_handler.py +def create_error_response( + error_code: ErrorCode, + message: str = None, + trace_id: str = None, + details: dict = None, + http_status: int = None +) -> JSONResponse: + """ + Create a standardized error response with mixed mode. + + Args: + error_code: The error code + message: Optional custom message (defaults to standard message) + trace_id: Optional trace ID for tracking + details: Optional additional details + http_status: Optional HTTP status code (defaults to mapping from error_code) + + Returns: + JSONResponse with standardized error format + """ + # Use provided http_status or get from error code mapping + status = http_status if http_status else ERROR_CODE_HTTP_STATUS.get(error_code, 500) + + return JSONResponse( + status_code=status, + content={ + "code": error_code.value, + "message": message or ErrorMessage.get_message(error_code), + "trace_id": trace_id, + "details": details + } + ) + + +def create_success_response( + data: any = None, + message: str = "OK", + trace_id: str = None +) -> JSONResponse: + """Create a standardized success response.""" + return JSONResponse( + status_code=200, + content={ + "code": 0, + "message": message, + "data": data, + "trace_id": trace_id + } + ) +``` + +--- + +## 4. 前端错误码实现 + +### 核心文件 + +| 文件路径 | 职责 | +|----------|------| +| `frontend/const/errorCode.ts` | 错误码枚举(与后端一致) | +| `frontend/const/errorMessage.ts` | 默认错误消息(英文) | +| `frontend/const/errorMessageI18n.ts` | i18n 支持工具函数 | +| `frontend/hooks/useErrorHandler.ts` | 错误处理 Hook | + +### TypeScript 错误码枚举 + +```typescript +// frontend/const/errorCode.ts +export enum ErrorCode { + // ==================== System Level Errors (10xxxx) ==================== + UNKNOWN_ERROR = 101001, + SERVICE_UNAVAILABLE = 101002, + DATABASE_ERROR = 101003, + TIMEOUT = 101004, + INTERNAL_ERROR = 101005, + + // ==================== Auth Level Errors (102xxx) ==================== + UNAUTHORIZED = 102001, + TOKEN_EXPIRED = 102002, + TOKEN_INVALID = 102003, + SIGNATURE_INVALID = 102004, + FORBIDDEN = 102005, + + // ==================== User Module Errors (103xxx) ==================== + USER_NOT_FOUND = 103001, + USER_REGISTRATION_FAILED = 103002, + USER_ALREADY_EXISTS = 103003, + INVALID_CREDENTIALS = 103004, + + // ... (与后端完全一致的其他模块错误码) + + // ==================== Validation Errors (120xxx) ==================== + VALIDATION_ERROR = 120001, + PARAMETER_INVALID = 120002, + MISSING_REQUIRED_FIELD = 120003, + + // ==================== Resource Errors (121xxx) ==================== + RESOURCE_NOT_FOUND = 121001, + RESOURCE_ALREADY_EXISTS = 121002, + RESOURCE_DISABLED = 121003, + + // ==================== Rate Limit Errors (122xxx) ==================== + RATE_LIMIT_EXCEEDED = 122001, + + // ==================== Success Code ==================== + SUCCESS = 0, +} + +/** + * Check if an error code represents a success. + */ +export const isSuccess = (code: number): boolean => { + return code === ErrorCode.SUCCESS; +}; + +/** + * Check if an error code represents an authentication error. + */ +export const isAuthError = (code: number): boolean => { + return code >= 102001 && code < 103000; +}; + +/** + * Check if an error code represents a session expiration. + */ +export const isSessionExpired = (code: number): boolean => { + return code === ErrorCode.TOKEN_EXPIRED || code === ErrorCode.TOKEN_INVALID; +}; +``` + +--- + +## 5. 前端错误处理 + +### API 层 + +```typescript +// frontend/services/api.ts +export class ApiError extends Error { + constructor( + public code: number, + message: string + ) { + super(message); + this.name = "ApiError"; + } +} + +// API request interceptor +export const fetchWithErrorHandling = async ( + url: string, + options: RequestInit = {} +) => { + try { + const response = await fetch(url, options); + + // Handle HTTP errors + if (!response.ok) { + // Handle 401 - Session expired + if (response.status === 401) { + handleSessionExpired(); + throw new ApiError( + STATUS_CODES.TOKEN_EXPIRED, + "Login expired, please login again" + ); + } + + // Handle 499 - Client closed connection + if (response.status === 499) { + handleSessionExpired(); + throw new ApiError( + STATUS_CODES.TOKEN_EXPIRED, + "Connection disconnected, session may have expired" + ); + } + + // Handle 413 - Request entity too large + if (response.status === 413) { + throw new ApiError( + STATUS_CODES.REQUEST_ENTITY_TOO_LARGE, + "File size exceeds limit" + ); + } + + // Other HTTP errors - try to parse JSON response for error code + let errorCode = response.status; + let errorMessage = `Request failed: ${response.status}`; + const errorText = await response.text(); + + try { + const errorData = JSON.parse(errorText); + if (errorData && errorData.code) { + errorCode = errorData.code; + errorMessage = errorData.message || errorMessage; + } + } catch { + errorMessage = errorText || errorMessage; + } + + throw new ApiError(errorCode, errorMessage); + } + + return response; + } catch (error) { + // Handle network errors + if (error instanceof TypeError && error.message.includes("NetworkError")) { + throw new ApiError( + STATUS_CODES.SERVER_ERROR, + "Network connection error, please check your network connection" + ); + } + + // Handle connection reset errors + if (error instanceof TypeError && error.message.includes("Failed to fetch")) { + // For user management related requests, it might be login expiration + if (url.includes("/user/session") || url.includes("/user/current_user_id")) { + handleSessionExpired(); + throw new ApiError( + STATUS_CODES.TOKEN_EXPIRED, + "Connection disconnected, session may have expired" + ); + } else { + throw new ApiError( + STATUS_CODES.SERVER_ERROR, + "Server connection error, please try again later" + ); + } + } + + throw error; + } +}; +``` + +### 错误处理 Hook + +```typescript +// frontend/hooks/useErrorHandler.ts +export const useErrorHandler = () => { + const { t } = useTranslation(); + + /** + * Get i18n error message by error code + */ + const getI18nErrorMessage = useCallback((code: number): string => { + // Try to get i18n key + const i18nKey = `errorCode.${code}`; + const translated = t(i18nKey); + + // If translation exists (not equal to key), return translated message + if (translated !== i18nKey) { + return translated; + } + + // Fallback to default messages + return ( + DEFAULT_ERROR_MESSAGES[code] || + DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] + ); + }, [t]); + + /** + * Handle API error + */ + const handleError = useCallback( + (error: unknown, options: ErrorHandlerOptions = {}) => { + const { showMessage, onError, handleSession } = { + ...DEFAULT_OPTIONS, + ...options, + }; + + // Handle ApiError + if (error instanceof ApiError) { + // Handle session expiration + if (handleSession && isSessionExpired(error.code)) { + handleSessionExpired(); + } + + // Get localized message + const errorMessage = getI18nErrorMessage(error.code); + + // Log error + log.error(`API Error [${error.code}]: ${errorMessage}`, error); + + // Show message to user + if (showMessage) { + message.error(errorMessage); + } + + // Call onError callback + if (onError) { + onError(error); + } + + return { + code: error.code, + message: errorMessage, + originalError: error, + }; + } + + // Handle unknown error + if (error instanceof Error) { + log.error("Unknown error:", error); + if (showMessage) { + message.error(getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR)); + } + return { + code: ErrorCode.UNKNOWN_ERROR, + message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), + originalError: error, + }; + } + + return { + code: ErrorCode.UNKNOWN_ERROR, + message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), + originalError: null, + }; + }, + [getI18nErrorMessage] + ); + + /** + * Wrap async function with error handling + */ + const withErrorHandler = useCallback( + (fn: () => Promise, options: ErrorHandlerOptions = {}) => { + return async (...args: any[]) => { + try { + return await fn(...args); + } catch (error) { + throw handleError(error, options); + } + }; + }, + [handleError] + ); + + return { + getI18nErrorMessage, + handleError, + withErrorHandler, + }; +}; +``` + +### i18n 工具函数 + +```typescript +// frontend/const/errorMessageI18n.ts +export const getI18nErrorMessage = ( + code: number, + t?: (key: string) => string +): string => { + // Try i18n translation first + if (t) { + const i18nKey = `errorCode.${code}`; + const translated = t(i18nKey); + if (translated !== i18nKey) { + return translated; + } + } + + // Fall back to default message + return ( + DEFAULT_ERROR_MESSAGES[code] || + DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] + ); +}; + +/** + * Show error to user with i18n support. + */ +export const showErrorToUser = ( + error: any, + t?: (key: string) => string, + options: ShowErrorOptions = {} +): void => { + const { + handleSession = true, + showMessage = true, + customMessage, + onError, + } = options; + + // Get error code if available + let errorCode: number | undefined; + if (error && typeof error === "object" && "code" in error) { + errorCode = error.code as number; + } + + // Handle session expiration + if (handleSession && errorCode && isSessionExpired(errorCode)) { + handleSessionExpired(); + } + + // Get the error message + let errorMessage: string; + if (customMessage) { + errorMessage = customMessage; + } else if (errorCode) { + errorMessage = getI18nErrorMessage(errorCode, t); + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR, t); + } + + // Log and show message + log.error(`Error [${errorCode || "unknown"}]: ${errorMessage}`, error); + if (showMessage) { + message.error(errorMessage); + } + + if (onError) { + onError(error); + } +}; + +/** + * Wrap an async function with automatic error handling. + */ +export const withErrorHandler = ( + fn: () => Promise, + options: ShowErrorOptions = {} +) => { + return async (...args: any[]) => { + try { + return await fn(...args); + } catch (error) { + showErrorToUser(error, undefined, options); + throw error; + } + }; +}; +``` + +--- + +## 6. i18n 多语言支持 + +### 翻译文件位置 + +- `frontend/public/locales/en/common.json` +- `frontend/public/locales/zh/common.json` + +### 翻译 key 格式 + +```json +{ + "errorCode.101001": "An unknown error occurred. Please try again later.", + "errorCode.101002": "Service is temporarily unavailable. Please try again later.", + "errorCode.102001": "You are not authorized to perform this action.", + "errorCode.102002": "Your session has expired. Please login again.", + "errorCode.105001": "Agent not found.", + "errorCode.105002": "Failed to run agent. Please try again later.", + "errorCode.120001": "Validation failed." +} +``` + +中文翻译示例: + +```json +{ + "errorCode.101001": "发生未知错误,请稍后重试", + "errorCode.101002": "服务暂时不可用,请稍后重试", + "errorCode.102001": "您没有执行此操作的权限", + "errorCode.102002": "您的登录已过期,请重新登录", + "errorCode.105001": "智能体不存在", + "errorCode.105002": "运行智能体失败,请稍后重试", + "errorCode.120001": "验证失败" +} +``` + +--- + +## 7. 整体数据流 + +``` ++-----------------------------------------------------------------+ +| Backend | +| +--------------+ +--------------+ +----------------------+ | +| | error_code | | error_message| | exceptions | | +| | .py | | .py | | .py | | +| | ErrorCode | | ErrorMessage | | AppException | | +| | (Enum) | | (Mapping) | | (with aliases) | | +| +--------------+ +--------------+ +----------------------+ | +| | | | | +| +-----------------+--------------------+ | +| v | +| exception_handler.py | +| (Middleware: converts to JSON response) | ++-----------------------------------------------------------------+ + | + HTTP Response + | + v ++-----------------------------------------------------------------+ +| Frontend | +| +------------------------------------------------------+ | +| | api.ts | | +| | fetchWithErrorHandling -> ApiError | | +| +------------------------------------------------------+ | +| | | +| +-----------------+-----------------+ | +| v v v | +| +------------+ +--------------+ +-------------+ | +| | errorCode | | errorMessage | | errorMessage| | +| | .ts | | .ts | | I18n.ts | | +| +------------+ +--------------+ +-------------+ | +| | | | | +| +----------------+------------------+ | +| v | +| useErrorHandler.ts | +| (Hook: getI18nErrorMessage, handleError) | +| | | +| v | +| +-----------------------------+ | +| | public/locales/{lang}/ | | +| | common.json | | +| | (i18n translations) | | +| +-----------------------------+ | ++-----------------------------------------------------------------+ +``` + +--- + +## 8. 方案总结 + +### 文件清单 + +| 层面 | 后端文件 | 前端文件 | +|------|----------|----------| +| **定义层** | `backend/consts/error_code.py` | `frontend/const/errorCode.ts` | +| **消息层** | `backend/consts/error_message.py` | `frontend/const/errorMessage.ts` | +| **异常层** | `backend/consts/exceptions.py` | - | +| **处理层** | `backend/middleware/exception_handler.py` | `frontend/hooks/useErrorHandler.ts` | +| **API层** | - | `frontend/services/api.ts` | +| **i18n层** | - | `frontend/const/errorMessageI18n.ts` | +| **翻译层** | - | `frontend/public/locales/{lang}/common.json` | + +### 方案优点 + +1. **前后端统一** - 错误码在前后端完全一致,便于问题追踪 +2. **i18n 支持** - 支持多语言错误消息 +3. **统一响应格式** - 所有 API 响应采用相同结构 +4. **可扩展性** - 支持 details 字段传递额外错误信息 +5. **可追踪性** - 支持 trace_id 便于问题定位 +6. **类型安全** - TypeScript 枚举与 Python Enum 一一对应 + +### 注意事项 + +- 项目采用 **混合模式**:HTTP 状态码 + 业务错误码 +- 建议新增错误码时按照 XYYZZZ 格式依次递增,保持模块内连续性 +- 所有新增错误码需要在 `common.json` 中添加对应的 i18n 翻译 diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 4c893e5b8..48082eed5 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -1,4 +1,5 @@ import { STATUS_CODES } from "@/const/auth"; +import { ErrorCode } from "@/const/errorCode"; import { handleSessionExpired } from "@/lib/session"; import log from "@/lib/logger"; import type { MarketAgentListParams } from "@/types/market"; @@ -319,40 +320,16 @@ export const fetchWithErrorHandling = async ( // Handle HTTP errors if (!response.ok) { - // Check if it's a session expired error (401) - if (response.status === 401) { - handleSessionExpired(); - throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, - "Login expired, please login again" - ); - } - - // Handle custom 499 error code (client closed connection) - if (response.status === 499) { - handleSessionExpired(); - throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, - "Connection disconnected, session may have expired" - ); - } - - // Handle request entity too large error (413) - if (response.status === 413) { - throw new ApiError( - STATUS_CODES.REQUEST_ENTITY_TOO_LARGE, - "REQUEST_ENTITY_TOO_LARGE" - ); - } - - // Other HTTP errors - try to parse JSON response for error code + // Try to parse JSON response for business error code first let errorCode = response.status; let errorMessage = `Request failed: ${response.status}`; const errorText = await response.text(); + let parsedErrorData = null; try { const errorData = JSON.parse(errorText); if (errorData && errorData.code) { + parsedErrorData = errorData; errorCode = errorData.code; errorMessage = errorData.message || errorMessage; } else { @@ -363,6 +340,33 @@ export const fetchWithErrorHandling = async ( errorMessage = errorText || errorMessage; } + // Check if it's a session expiration error based on business error code + // 102002 = TOKEN_EXPIRED, 102003 = TOKEN_INVALID + if ( + errorCode === ErrorCode.TOKEN_EXPIRED || + errorCode === ErrorCode.TOKEN_INVALID + ) { + handleSessionExpired(); + throw new ApiError(errorCode, errorMessage); + } + + // Handle custom 499 error code (client closed connection) + if (response.status === 499) { + handleSessionExpired(); + throw new ApiError( + ErrorCode.TOKEN_EXPIRED, + "Connection disconnected, session may have expired" + ); + } + + // Handle request entity too large error (413) + if (response.status === 413) { + throw new ApiError( + ErrorCode.FILE_TOO_LARGE, + "File size exceeds limit." + ); + } + throw new ApiError(errorCode, errorMessage); } From 6ac30756ab968f338f80a72a071108f9abc374b1 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Sat, 28 Feb 2026 09:47:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20Add=20unit=20tests=20for=20erro?= =?UTF-8?q?r=20codes=20and=20exception=20handling:=20Introduced=20comprehe?= =?UTF-8?q?nsive=20tests=20for=20ErrorCode=20enum=20and=20ERROR=5FCODE=5FH?= =?UTF-8?q?TTP=5FSTATUS=20mapping,=20as=20well=20as=20for=20ExceptionHandl?= =?UTF-8?q?erMiddleware.=20Ensured=20proper=20mapping=20of=20HTTP=20status?= =?UTF-8?q?=20codes=20to=20error=20codes=20and=20validated=20error=20respo?= =?UTF-8?q?nse=20creation=20functions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/backend/app/test_config_app.py | 231 ++++++++++++-- test/backend/app/test_dify_app.py | 111 +++++-- test/backend/app/test_northbound_base_app.py | 254 ++++++++++++++- test/backend/services/test_dify_service.py | 151 +++++++++ test/backend/test_error_code.py | 168 ++++++++++ test/backend/test_exception_handler.py | 316 +++++++++++++++++++ 6 files changed, 1187 insertions(+), 44 deletions(-) create mode 100644 test/backend/test_error_code.py create mode 100644 test/backend/test_exception_handler.py diff --git a/test/backend/app/test_config_app.py b/test/backend/app/test_config_app.py index 87ca2b959..537aa8b40 100644 --- a/test/backend/app/test_config_app.py +++ b/test/backend/app/test_config_app.py @@ -1,10 +1,15 @@ +import atexit +from apps.config_app import app +from fastapi.testclient import TestClient +from fastapi import HTTPException import unittest from unittest.mock import patch, MagicMock, Mock import sys import os # Add the backend directory to path so we can import modules -backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../backend')) +backend_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), '../../../backend')) sys.path.insert(0, backend_path) # Apply patches before importing any app modules @@ -23,8 +28,10 @@ # before any module imports that might trigger MinioClient initialization critical_patches = [ # Patch storage factory and MinIO config validation FIRST - patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock), - patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None), + patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock), + patch('nexent.storage.minio_config.MinIOStorageConfig.validate', + lambda self: None), # Mock boto3 client patch('boto3.client', return_value=Mock()), # Mock boto3 resource @@ -55,26 +62,28 @@ try: from backend.database import client as db_client_module # Patch get_db_session after module is imported - db_session_patch = patch.object(db_client_module, 'get_db_session', return_value=Mock()) + db_session_patch = patch.object( + db_client_module, 'get_db_session', return_value=Mock()) db_session_patch.start() all_patches.append(db_session_patch) except ImportError: # If import fails, try patching the path directly (may trigger import) - db_session_patch = patch('backend.database.client.get_db_session', return_value=Mock()) + db_session_patch = patch( + 'backend.database.client.get_db_session', return_value=Mock()) db_session_patch.start() all_patches.append(db_session_patch) # Now safe to import app modules -from fastapi import HTTPException -from fastapi.testclient import TestClient -from apps.config_app import app # Stop all patches at the end of the module -import atexit + + def stop_patches(): for p in all_patches: p.stop() + + atexit.register(stop_patches) @@ -94,9 +103,9 @@ def test_cors_middleware(self): if middleware.cls.__name__ == "CORSMiddleware": cors_middleware = middleware break - + self.assertIsNotNone(cors_middleware) - + # In FastAPI, middleware options are stored in 'middleware.kwargs' self.assertEqual(cors_middleware.kwargs.get("allow_origins"), ["*"]) self.assertTrue(cors_middleware.kwargs.get("allow_credentials")) @@ -107,7 +116,7 @@ def test_routers_included(self): """Test that all routers are included in the app.""" # Get all routes in the app routes = [route.path for route in app.routes] - + # Check if routes exist (at least some routes should be present) self.assertTrue(len(routes) > 0) @@ -116,7 +125,7 @@ def test_http_exception_handler(self): # Test that the exception handler is registered exception_handlers = app.exception_handlers self.assertIn(HTTPException, exception_handlers) - + # Test that the handler function exists and is callable http_exception_handler = exception_handlers[HTTPException] self.assertIsNotNone(http_exception_handler) @@ -127,7 +136,7 @@ def test_generic_exception_handler(self): # Test that the exception handler is registered exception_handlers = app.exception_handlers self.assertIn(Exception, exception_handlers) - + # Test that the handler function exists and is callable generic_exception_handler = exception_handlers[Exception] self.assertTrue(callable(generic_exception_handler)) @@ -178,7 +187,8 @@ def test_all_routers_included(self): 'file_manager_router', 'proxy_router', 'tool_config_router', - 'mock_user_management_router', # or 'user_management_router' depending on IS_SPEED_MODE + # or 'user_management_router' depending on IS_SPEED_MODE + 'mock_user_management_router', 'summary_router', 'prompt_router', 'tenant_config_router', @@ -197,7 +207,8 @@ def test_all_routers_included(self): # Since it's hard to identify routers directly from routes, # we'll check that we have a reasonable number of routes - self.assertGreater(len(app.routes), 10) # Should have many routes from all routers + # Should have many routes from all routers + self.assertGreater(len(app.routes), 10) def test_http_exception_handler_registration(self): """Test that HTTP exception handler is properly registered.""" @@ -211,6 +222,190 @@ def test_generic_exception_handler_registration(self): exception_handlers = app.exception_handlers self.assertIn(Exception, exception_handlers) + def test_app_exception_handler_registration(self): + """Test that AppException handler is properly registered.""" + from consts.exceptions import AppException + exception_handlers = app.exception_handlers + self.assertIn(AppException, exception_handlers) + + +class TestExceptionHandlerResponses(unittest.TestCase): + """Test exception handler responses.""" + + def setUp(self): + # Use the actual config_app with exception handlers + from apps.config_app import app + from consts.const import IS_SPEED_MODE + + self.test_app = app + # Use raise_server_exceptions=False to let exception handlers process the exceptions + self.client = TestClient(self.test_app, raise_server_exceptions=False) + + # Also access the logger for testing + from apps import config_app as config_app_module + self.logger = config_app_module.logger + + def test_http_exception_handler_logs_error(self): + """Test HTTPException handler logs the error.""" + from fastapi import HTTPException + + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly + from apps.config_app import http_exception_handler + exc = HTTPException(status_code=400, detail="Bad request") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + http_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify response + self.assertEqual(response.status_code, 400) + self.assertEqual(response.body, b'{"message":"Bad request"}') + + def test_app_exception_handler_logs_error(self): + """Test AppException handler logs the error and returns correct response.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly + from apps.config_app import app_exception_handler + exc = AppException(ErrorCode.VALIDATION_ERROR, "Validation failed") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + app_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify response + self.assertEqual(response.status_code, 400) # VALIDATION_ERROR -> 400 + import json + body = json.loads(response.body) + self.assertEqual(body["code"], ErrorCode.VALIDATION_ERROR.value) + self.assertEqual(body["message"], "Validation failed") + + def test_app_exception_handler_with_details(self): + """Test AppException handler with details field.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + from apps.config_app import app_exception_handler + exc = AppException( + ErrorCode.VALIDATION_ERROR, + "Validation failed", + details={"field": "email"} + ) + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + app_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["details"]["field"], "email") + + def test_generic_exception_handler_logs_error(self): + """Test generic exception handler logs the error.""" + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly + from apps.config_app import generic_exception_handler + exc = ValueError("Something went wrong") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify response + self.assertEqual(response.status_code, 500) + self.assertEqual( + response.body, b'{"message":"Internal server error, please try again later."}') + + def test_generic_exception_handler_delegates_to_app_exception_handler(self): + """Test generic exception handler delegates to AppException handler for AppException.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly with an AppException + from apps.config_app import generic_exception_handler + exc = AppException(ErrorCode.FORBIDDEN, "Access denied") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify it was delegated to app_exception_handler (returns 403 not 500) + self.assertEqual(response.status_code, 403) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Access denied") + + def test_different_app_exception_error_codes(self): + """Test AppException with different error codes.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + test_cases = [ + (ErrorCode.TOKEN_EXPIRED, 401), + (ErrorCode.FORBIDDEN, 403), + (ErrorCode.RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + from apps.config_app import app_exception_handler + + import asyncio + for error_code, expected_status in test_cases: + exc = AppException(error_code, "Test error") + + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + app_exception_handler(mock_request, exc)) + finally: + loop.close() -if __name__ == "__main__": - unittest.main() + self.assertEqual(response.status_code, expected_status, + f"Expected {expected_status} for {error_code}, got {response.status_code}") diff --git a/test/backend/app/test_dify_app.py b/test/backend/app/test_dify_app.py index ad9c8c53c..cf41c0d8c 100644 --- a/test/backend/app/test_dify_app.py +++ b/test/backend/app/test_dify_app.py @@ -132,7 +132,8 @@ async def test_fetch_dify_datasets_api_success(self, dify_mocks): response_body = json.loads(result.body.decode()) assert response_body == expected_result - dify_mocks['get_current_user_id'].assert_called_once_with(mock_auth_header) + # Note: get_current_user_id is imported but not used in dify_app.py + # The test verifies the actual behavior of the function dify_mocks['fetch_dify'].assert_called_once_with( dify_api_base=dify_api_base.rstrip('/'), api_key=api_key @@ -172,6 +173,9 @@ async def test_fetch_dify_datasets_api_url_normalization(self, dify_mocks): @pytest.mark.asyncio async def test_fetch_dify_datasets_api_auth_error(self, dify_mocks): """Test endpoint with authentication error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer invalid-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -179,21 +183,24 @@ async def test_fetch_dify_datasets_api_auth_error(self, dify_mocks): # Mock authentication failure dify_mocks['get_current_user_id'].side_effect = Exception("Invalid token") - # Execute and Assert - with pytest.raises(HTTPException) as exc_info: + # Execute and Assert - the code catches Exception and converts to AppException + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) - dify_mocks['logger'].error.assert_not_called() + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + dify_mocks['logger'].error.assert_called() @pytest.mark.asyncio async def test_fetch_dify_datasets_api_service_validation_error(self, dify_mocks): """Test endpoint with service layer validation error (ValueError).""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "" @@ -203,19 +210,21 @@ async def test_fetch_dify_datasets_api_service_validation_error(self, dify_mocks ) dify_mocks['fetch_dify'].side_effect = ValueError("api_key is required") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST - assert "api_key is required" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR @pytest.mark.asyncio async def test_fetch_dify_datasets_api_service_error(self, dify_mocks): """Test endpoint with general service layer error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -225,21 +234,24 @@ async def test_fetch_dify_datasets_api_service_error(self, dify_mocks): ) dify_mocks['fetch_dify'].side_effect = Exception("Dify API connection failed") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) - assert "Dify API connection failed" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + assert "Dify API connection failed" in str(exc_info.value.message) dify_mocks['logger'].error.assert_called() @pytest.mark.asyncio async def test_fetch_dify_datasets_api_http_error_from_service(self, dify_mocks): """Test endpoint when service raises HTTP-related exception.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -250,19 +262,22 @@ async def test_fetch_dify_datasets_api_http_error_from_service(self, dify_mocks) # Simulate HTTP error from service dify_mocks['fetch_dify'].side_effect = Exception("Dify API HTTP error: 404 Not Found") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) @pytest.mark.asyncio async def test_fetch_dify_datasets_api_request_error_from_service(self, dify_mocks): """Test endpoint when service raises request error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -273,15 +288,18 @@ async def test_fetch_dify_datasets_api_request_error_from_service(self, dify_moc # Simulate request error from service dify_mocks['fetch_dify'].side_effect = Exception("Dify API request failed: Connection refused") - with pytest.raises(HTTPException) as exc_info: + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) @pytest.mark.asyncio async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): @@ -297,7 +315,7 @@ async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): "pagination": {"embedding_available": False} } - # Mock user and tenant ID for None auth + # Mock user and tenant ID for None auth (even though it's not used in the current implementation) dify_mocks['get_current_user_id'].return_value = ( "default_user", "default_tenant" ) @@ -312,7 +330,8 @@ async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): assert isinstance(result, JSONResponse) assert result.status_code == HTTPStatus.OK - dify_mocks['get_current_user_id'].assert_called_once_with(None) + # Note: get_current_user_id is imported but not used in dify_app.py + # The test verifies the actual behavior of the function @pytest.mark.asyncio async def test_fetch_dify_datasets_api_empty_result(self, dify_mocks): @@ -457,6 +476,8 @@ async def test_fetch_dify_datasets_api_logger_info_call(self, dify_mocks): @pytest.mark.asyncio async def test_fetch_dify_datasets_api_logger_error_call(self, dify_mocks): """Test that endpoint logs errors appropriately.""" + from consts.exceptions import AppException + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -466,7 +487,7 @@ async def test_fetch_dify_datasets_api_logger_error_call(self, dify_mocks): ) dify_mocks['fetch_dify'].side_effect = Exception("Connection timeout") - with pytest.raises(HTTPException): + with pytest.raises(AppException): await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, @@ -557,3 +578,49 @@ def test_router_has_datasets_endpoint(self): routes = [route.path for route in router.routes] # Router prefix is /dify, and route is /datasets, so full path is /dify/datasets assert "/dify/datasets" in routes + + +class TestDifyAppExceptionHandlers: + """Test exception handlers in dify_app.py""" + + def test_dify_app_exception_handler_functions_exist(self): + """Test that dify_app module can import exception handlers if defined.""" + # dify_app.py doesn't define its own exception handlers, + # it relies on the global middleware in config_app.py + # This test verifies the module structure + from backend.apps import dify_app + from backend.apps.dify_app import router, logger, fetch_dify_datasets_api + + # Verify router exists + assert router is not None + # Verify logger exists + assert logger is not None + # Verify endpoint function exists + assert fetch_dify_datasets_api is not None + + @pytest.mark.asyncio + async def test_dify_app_logs_service_error(self, dify_mocks): + """Test that service errors are logged and converted to AppException.""" + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + dify_mocks['get_current_user_id'].return_value = ( + "test_user_id", "test_tenant_id" + ) + + # Test with service error + dify_mocks['fetch_dify'].side_effect = Exception("URL connection error") + + from consts.exceptions import AppException + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + # Verify it's a DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + dify_mocks['logger'].error.assert_called() \ No newline at end of file diff --git a/test/backend/app/test_northbound_base_app.py b/test/backend/app/test_northbound_base_app.py index 4316ddd64..fad17ef56 100644 --- a/test/backend/app/test_northbound_base_app.py +++ b/test/backend/app/test_northbound_base_app.py @@ -29,6 +29,22 @@ async def _dummy_route(): return {"msg": "ok"} +# Add endpoints for exception testing +@router_stub.get("/http-exception") +async def _http_exception_route(): + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="Bad request") + +@router_stub.get("/app-exception") +async def _app_exception_route(): + from consts.error_code import ErrorCode + from consts.exceptions import AppException + raise AppException(ErrorCode.VALIDATION_ERROR, "Validation failed") + +@router_stub.get("/generic-exception") +async def _generic_exception_route(): + raise ValueError("Something went wrong") + # Create a lightweight module object and register it as 'apps.northbound_app'. # We add a minimalist namespace package for 'apps' (PEP 420 style) so that imports # using dotted paths still resolve. We set its __path__ to include the real @@ -75,9 +91,25 @@ class SignatureValidationError(Exception): consts_exceptions_module.UnauthorizedError = UnauthorizedError consts_exceptions_module.SignatureValidationError = SignatureValidationError +# Need to import AppException for the stub +from backend.consts.exceptions import AppException as RealAppException +consts_exceptions_module.AppException = RealAppException + # Register the stub so that `from consts.exceptions import ...` works seamlessly sys.modules['consts.exceptions'] = consts_exceptions_module +# --------------------------------------------------------------------------- +# Provide 'consts.error_code' stub so that it can be imported in tests +# --------------------------------------------------------------------------- +consts_error_code_module = types.ModuleType("consts.error_code") + +# Import the real ErrorCode from backend +from backend.consts.error_code import ErrorCode as RealErrorCode +consts_error_code_module.ErrorCode = RealErrorCode + +# Register the stub +sys.modules['consts.error_code'] = consts_error_code_module + # --------------------------------------------------------------------------- # SAFE TO IMPORT THE TARGET MODULE UNDER TEST NOW # --------------------------------------------------------------------------- @@ -90,7 +122,8 @@ class TestNorthboundBaseApp(unittest.TestCase): """Unit tests covering the FastAPI instance defined in northbound_base_app.py""" def setUp(self): - self.client = TestClient(app) + # Use raise_server_exceptions=False to let exception handlers process the exceptions + self.client = TestClient(app, raise_server_exceptions=False) # ------------------------------------------------------------------- # Basic application wiring / configuration @@ -99,6 +132,14 @@ def test_app_root_path(self): """Ensure the FastAPI application is configured with the correct root path.""" self.assertEqual(app.root_path, "/api") + def test_app_title(self): + """Ensure the FastAPI application has correct title.""" + self.assertEqual(app.title, "Nexent Northbound API") + + def test_app_version(self): + """Ensure the FastAPI application has correct version.""" + self.assertEqual(app.version, "1.0.0") + def test_cors_middleware_configuration(self): """Verify that CORS middleware is present and its options match expectations.""" cors_middleware = None @@ -108,14 +149,14 @@ def test_cors_middleware_configuration(self): break # Middleware must be registered self.assertIsNotNone(cors_middleware) - # Validate configured options – these must match the implementation exactly + # Validate configured options - these must match the implementation exactly self.assertEqual(cors_middleware.kwargs.get("allow_origins"), ["*"]) self.assertTrue(cors_middleware.kwargs.get("allow_credentials")) self.assertEqual(cors_middleware.kwargs.get("allow_methods"), ["GET", "POST", "PUT", "DELETE"]) self.assertEqual(cors_middleware.kwargs.get("allow_headers"), ["*"]) def test_router_inclusion(self): - """The northbound router should be included – expect our dummy '/test' endpoint present.""" + """The northbound router should be included - expect our dummy '/test' endpoint present.""" routes = [route.path for route in app.routes] self.assertIn("/test", routes) @@ -131,7 +172,7 @@ def test_custom_exception_handlers_registration(self): self.assertTrue(callable(app.exception_handlers[Exception])) # ------------------------------------------------------------------- - # End-to-end sanity for health (dummy) endpoint – relies on router stub + # End-to-end sanity for health (dummy) endpoint - relies on router stub # ------------------------------------------------------------------- def test_dummy_endpoint_success(self): response = self.client.get("/test") @@ -139,5 +180,210 @@ def test_dummy_endpoint_success(self): self.assertEqual(response.json(), {"msg": "ok"}) +class TestNorthboundExceptionHandlers(unittest.TestCase): + """Test exception handlers in northbound_base_app.py""" + + def setUp(self): + from apps.northbound_base_app import northbound_app as test_app + self.test_app = test_app + self.client = TestClient(self.test_app, raise_server_exceptions=False) + + def test_http_exception_handler_response(self): + """Test HTTPException handler returns correct response.""" + response = self.client.get("/http-exception") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"message": "Bad request"}) + + def test_http_exception_handler_different_status_codes(self): + """Test HTTPException handler with different status codes.""" + from apps.northbound_base_app import northbound_app + + @northbound_app.get("/not-found") + async def _not_found(): + raise HTTPException(status_code=404, detail="Resource not found") + + response = self.client.get("/not-found") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"message": "Resource not found"}) + + def test_app_exception_handler_response(self): + """Test AppException handler returns correct response.""" + response = self.client.get("/app-exception") + self.assertEqual(response.status_code, 400) # VALIDATION_ERROR -> 400 + data = response.json() + self.assertIn("code", data) + self.assertIn("message", data) + self.assertEqual(data["message"], "Validation failed") + + def test_app_exception_handler_includes_error_code(self): + """Test AppException handler includes error code in response.""" + response = self.client.get("/app-exception") + data = response.json() + self.assertIn("code", data) + # Should have a valid error code + self.assertIsNotNone(data["code"]) + + def test_generic_exception_handler_response(self): + """Test generic exception handler returns 500.""" + response = self.client.get("/generic-exception") + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json(), {"message": "Internal server error, please try again later."}) + + def test_generic_exception_handler_delegates_to_app_exception(self): + """Test generic exception handler delegates to AppException handler.""" + from apps.northbound_base_app import northbound_app + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + @northbound_app.get("/app-exception-delegated") + async def _app_exception_delegated(): + raise AppException(ErrorCode.FORBIDDEN, "Access denied") + + response = self.client.get("/app-exception-delegated") + # Should return 403 (FORBIDDEN), not 500 + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertEqual(data["message"], "Access denied") + + def test_different_app_exception_error_codes(self): + """Test different error codes map to correct HTTP status.""" + from apps.northbound_base_app import northbound_app + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + test_cases = [ + (ErrorCode.TOKEN_EXPIRED, 401), + (ErrorCode.FORBIDDEN, 403), + (ErrorCode.RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + for error_code, expected_status in test_cases: + @northbound_app.get(f"/test-{error_code.name}") + async def _test_error(): + raise AppException(error_code, "Test error") + + response = self.client.get(f"/test-{error_code.name}") + self.assertEqual(response.status_code, expected_status, + f"Expected {expected_status} for {error_code}, got {response.status_code}") + + +class TestNorthboundExceptionHandlerFunctions(unittest.TestCase): + """Test exception handler functions directly.""" + + def test_northbound_http_exception_handler_logs_and_returns_json(self): + """Test northbound_http_exception_handler logs and returns correct JSON.""" + from apps.northbound_base_app import northbound_http_exception_handler + from apps.northbound_base_app import logger + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = HTTPException(status_code=400, detail="Bad request") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_http_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Bad request") + + def test_northbound_app_exception_handler_logs_and_returns_json(self): + """Test northbound_app_exception_handler logs and returns correct JSON.""" + from apps.northbound_base_app import northbound_app_exception_handler + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = AppException(ErrorCode.VALIDATION_ERROR, "Validation failed") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_app_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["code"], ErrorCode.VALIDATION_ERROR.value) + self.assertEqual(body["message"], "Validation failed") + + def test_northbound_app_exception_handler_with_details(self): + """Test northbound_app_exception_handler with details field.""" + from apps.northbound_base_app import northbound_app_exception_handler + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = AppException( + ErrorCode.VALIDATION_ERROR, + "Validation failed", + details={"field": "email"} + ) + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_app_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["details"]["field"], "email") + + def test_northbound_generic_exception_handler_returns_500(self): + """Test northbound_generic_exception_handler returns 500.""" + from apps.northbound_base_app import northbound_generic_exception_handler + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = ValueError("Something went wrong") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 500) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Internal server error, please try again later.") + + def test_northbound_generic_exception_handler_delegates_to_app_exception(self): + """Test northbound_generic_exception_handler delegates to AppException handler.""" + from apps.northbound_base_app import northbound_generic_exception_handler + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = AppException(ErrorCode.FORBIDDEN, "Access denied") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Should delegate to app exception handler (403 not 500) + self.assertEqual(response.status_code, 403) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Access denied") + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/test/backend/services/test_dify_service.py b/test/backend/services/test_dify_service.py index a6ed3b091..395174caa 100644 --- a/test/backend/services/test_dify_service.py +++ b/test/backend/services/test_dify_service.py @@ -9,6 +9,8 @@ from unittest.mock import MagicMock, patch import httpx +from backend.consts.error_code import ErrorCode + def _create_mock_client(mock_response): """ @@ -695,3 +697,152 @@ def test_fetch_dify_datasets_impl_url_v1_suffix_parametrized(self, api_base_url) assert "/v1/v1" not in called_url, f"URL duplication detected: {called_url}" # Verify URL ends with /v1/datasets assert called_url.endswith("/v1/datasets") + + def test_fetch_dify_datasets_impl_url_without_protocol(self): + """Test ValueError when dify_api_base doesn't start with http:// or https://.""" + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="dify.example.com", + api_key="test-api-key" + ) + + assert "must start with http:// or https://" in str(excinfo.value) + + def test_fetch_dify_datasets_impl_url_with_ftp_protocol(self): + """Test ValueError when dify_api_base uses unsupported protocol.""" + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="ftp://dify.example.com", + api_key="test-api-key" + ) + + assert "must start with http:// or https://" in str(excinfo.value) + + def test_fetch_dify_datasets_impl_http_401_auth_error(self): + """Test that HTTP 401 maps to DIFY_AUTH_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "401 Unauthorized", + request=MagicMock(), + response=MagicMock(status_code=401) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_AUTH_ERROR + + def test_fetch_dify_datasets_impl_http_403_auth_error(self): + """Test that HTTP 403 maps to DIFY_AUTH_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "403 Forbidden", + request=MagicMock(), + response=MagicMock(status_code=403) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_AUTH_ERROR + + def test_fetch_dify_datasets_impl_http_429_rate_limit(self): + """Test that HTTP 429 maps to DIFY_RATE_LIMIT.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "429 Too Many Requests", + request=MagicMock(), + response=MagicMock(status_code=429) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_RATE_LIMIT + + def test_fetch_dify_datasets_impl_http_500_service_error(self): + """Test that HTTP 500 maps to DIFY_SERVICE_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500 Internal Server Error", + request=MagicMock(), + response=MagicMock(status_code=500) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + + def test_fetch_dify_datasets_impl_http_404_service_error(self): + """Test that HTTP 404 maps to DIFY_SERVICE_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "404 Not Found", + request=MagicMock(), + response=MagicMock(status_code=404) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_SERVICE_ERROR \ No newline at end of file diff --git a/test/backend/test_error_code.py b/test/backend/test_error_code.py new file mode 100644 index 000000000..c740db8fd --- /dev/null +++ b/test/backend/test_error_code.py @@ -0,0 +1,168 @@ +""" +Unit tests for Error Code definitions. + +Tests the ErrorCode enum and ERROR_CODE_HTTP_STATUS mapping +to ensure error codes are properly defined and mapped. +""" +import pytest +from backend.consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS + + +class TestErrorCodeEnum: + """Test class for ErrorCode enum values.""" + + def test_dify_error_codes_exist(self): + """Test that all Dify-related error codes are defined.""" + assert ErrorCode.DIFY_SERVICE_ERROR is not None + assert ErrorCode.DIFY_CONFIG_INVALID is not None + assert ErrorCode.DIFY_CONNECTION_ERROR is not None + assert ErrorCode.DIFY_AUTH_ERROR is not None + assert ErrorCode.DIFY_RATE_LIMIT is not None + assert ErrorCode.DIFY_RESPONSE_ERROR is not None + + def test_dify_auth_error_value(self): + """Test DIFY_AUTH_ERROR has correct value.""" + assert ErrorCode.DIFY_AUTH_ERROR.value == 116103 + + def test_dify_config_invalid_value(self): + """Test DIFY_CONFIG_INVALID has correct value.""" + assert ErrorCode.DIFY_CONFIG_INVALID.value == 116101 + + def test_dify_connection_error_value(self): + """Test DIFY_CONNECTION_ERROR has correct value.""" + assert ErrorCode.DIFY_CONNECTION_ERROR.value == 116102 + + def test_dify_service_error_value(self): + """Test DIFY_SERVICE_ERROR has correct value.""" + assert ErrorCode.DIFY_SERVICE_ERROR.value == 116003 + + def test_dify_rate_limit_value(self): + """Test DIFY_RATE_LIMIT has correct value.""" + assert ErrorCode.DIFY_RATE_LIMIT.value == 116104 + + def test_dify_response_error_value(self): + """Test DIFY_RESPONSE_ERROR has correct value.""" + assert ErrorCode.DIFY_RESPONSE_ERROR.value == 116105 + + +class TestErrorCodeHttpStatusMapping: + """Test class for ERROR_CODE_HTTP_STATUS mapping.""" + + def test_dify_auth_error_maps_to_401(self): + """Test DIFY_AUTH_ERROR maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_AUTH_ERROR] == 401 + + def test_dify_config_invalid_maps_to_401(self): + """Test DIFY_CONFIG_INVALID maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_CONFIG_INVALID] == 401 + + def test_dify_connection_error_maps_to_502(self): + """Test DIFY_CONNECTION_ERROR maps to HTTP 502.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_CONNECTION_ERROR] == 502 + + def test_dify_response_error_maps_to_502(self): + """Test DIFY_RESPONSE_ERROR maps to HTTP 502.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_RESPONSE_ERROR] == 502 + + def test_dify_rate_limit_maps_to_429(self): + """Test DIFY_RATE_LIMIT maps to HTTP 429.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_RATE_LIMIT] == 429 + + def test_token_expired_maps_to_401(self): + """Test TOKEN_EXPIRED maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.TOKEN_EXPIRED] == 401 + + def test_token_invalid_maps_to_401(self): + """Test TOKEN_INVALID maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.TOKEN_INVALID] == 401 + + def test_unauthorized_maps_to_401(self): + """Test UNAUTHORIZED maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.UNAUTHORIZED] == 401 + + def test_forbidden_maps_to_403(self): + """Test FORBIDDEN maps to HTTP 403.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.FORBIDDEN] == 403 + + def test_rate_limit_exceeded_maps_to_429(self): + """Test RATE_LIMIT_EXCEEDED maps to HTTP 429.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.RATE_LIMIT_EXCEEDED] == 429 + + def test_validation_error_maps_to_400(self): + """Test VALIDATION_ERROR maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.VALIDATION_ERROR] == 400 + + def test_parameter_invalid_maps_to_400(self): + """Test PARAMETER_INVALID maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.PARAMETER_INVALID] == 400 + + def test_missing_required_field_maps_to_400(self): + """Test MISSING_REQUIRED_FIELD maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.MISSING_REQUIRED_FIELD] == 400 + + def test_file_too_large_maps_to_413(self): + """Test FILE_TOO_LARGE maps to HTTP 413.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.FILE_TOO_LARGE] == 413 + + +class TestErrorCodeFormat: + """Test class for error code format consistency.""" + + def test_all_dify_codes_start_with_116(self): + """Test all Dify error codes start with 116.""" + dify_codes = [ + ErrorCode.DIFY_SERVICE_ERROR, + ErrorCode.DIFY_CONFIG_INVALID, + ErrorCode.DIFY_CONNECTION_ERROR, + ErrorCode.DIFY_AUTH_ERROR, + ErrorCode.DIFY_RATE_LIMIT, + ErrorCode.DIFY_RESPONSE_ERROR, + ] + for code in dify_codes: + assert str(code.value).startswith("116"), f"{code} should start with 116" + + def test_auth_codes_start_with_102(self): + """Test auth error codes start with 102.""" + auth_codes = [ + ErrorCode.UNAUTHORIZED, + ErrorCode.TOKEN_EXPIRED, + ErrorCode.TOKEN_INVALID, + ErrorCode.SIGNATURE_INVALID, + ErrorCode.FORBIDDEN, + ] + for code in auth_codes: + assert str(code.value).startswith("102"), f"{code} should start with 102" + + def test_system_codes_start_with_101(self): + """Test system error codes start with 101.""" + system_codes = [ + ErrorCode.UNKNOWN_ERROR, + ErrorCode.SERVICE_UNAVAILABLE, + ErrorCode.DATABASE_ERROR, + ErrorCode.TIMEOUT, + ErrorCode.INTERNAL_ERROR, + ] + for code in system_codes: + assert str(code.value).startswith("101"), f"{code} should start with 101" + + +class TestErrorCodeIntEnum: + """Test that ErrorCode properly inherits from int and Enum.""" + + def test_error_code_is_int(self): + """Test ErrorCode values can be used as integers.""" + assert isinstance(ErrorCode.DIFY_AUTH_ERROR.value, int) + assert ErrorCode.DIFY_AUTH_ERROR.value + 1 == 116104 + + def test_error_code_comparison(self): + """Test ErrorCode can be compared with integers.""" + assert ErrorCode.DIFY_AUTH_ERROR == 116103 + assert ErrorCode.DIFY_AUTH_ERROR.value == 116103 + + def test_error_code_in_conditional(self): + """Test ErrorCode can be used in conditionals.""" + code = ErrorCode.DIFY_AUTH_ERROR + if code == 116103: + assert True + else: + assert False diff --git a/test/backend/test_exception_handler.py b/test/backend/test_exception_handler.py new file mode 100644 index 000000000..26e609514 --- /dev/null +++ b/test/backend/test_exception_handler.py @@ -0,0 +1,316 @@ +""" +Unit tests for Exception Handler Middleware. + +Tests the ExceptionHandlerMiddleware class and helper functions +for centralized error handling in the FastAPI application. +""" +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse, Response + +from backend.middleware.exception_handler import ( + ExceptionHandlerMiddleware, + _http_status_to_error_code, + create_error_response, + create_success_response, +) +from backend.consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from backend.consts.exceptions import AppException + + +class TestHttpStatusToErrorCode: + """Test class for _http_status_to_error_code function.""" + + def test_maps_400_to_validation_error(self): + """Test that HTTP 400 maps to VALIDATION_ERROR.""" + assert _http_status_to_error_code(400) == ErrorCode.VALIDATION_ERROR + + def test_maps_401_to_unauthorized(self): + """Test that HTTP 401 maps to UNAUTHORIZED.""" + assert _http_status_to_error_code(401) == ErrorCode.UNAUTHORIZED + + def test_maps_403_to_forbidden(self): + """Test that HTTP 403 maps to FORBIDDEN.""" + assert _http_status_to_error_code(403) == ErrorCode.FORBIDDEN + + def test_maps_404_to_resource_not_found(self): + """Test that HTTP 404 maps to RESOURCE_NOT_FOUND.""" + assert _http_status_to_error_code(404) == ErrorCode.RESOURCE_NOT_FOUND + + def test_maps_429_to_rate_limit_exceeded(self): + """Test that HTTP 429 maps to RATE_LIMIT_EXCEEDED.""" + assert _http_status_to_error_code(429) == ErrorCode.RATE_LIMIT_EXCEEDED + + def test_maps_500_to_internal_error(self): + """Test that HTTP 500 maps to INTERNAL_ERROR.""" + assert _http_status_to_error_code(500) == ErrorCode.INTERNAL_ERROR + + def test_maps_502_to_service_unavailable(self): + """Test that HTTP 502 maps to SERVICE_UNAVAILABLE.""" + assert _http_status_to_error_code(502) == ErrorCode.SERVICE_UNAVAILABLE + + def test_maps_503_to_service_unavailable(self): + """Test that HTTP 503 maps to SERVICE_UNAVAILABLE.""" + assert _http_status_to_error_code(503) == ErrorCode.SERVICE_UNAVAILABLE + + def test_unknown_status_returns_unknown_error(self): + """Test that unknown HTTP status codes map to UNKNOWN_ERROR.""" + assert _http_status_to_error_code(418) == ErrorCode.UNKNOWN_ERROR + assert _http_status_to_error_code(599) == ErrorCode.UNKNOWN_ERROR + + +class TestCreateErrorResponse: + """Test class for create_error_response function.""" + + def test_create_error_response_default(self): + """Test creating error response with default values.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + + assert response.status_code == 401 + assert response.body is not None + + def test_create_error_response_custom_message(self): + """Test creating error response with custom message.""" + custom_message = "Custom error message" + response = create_error_response( + ErrorCode.DIFY_AUTH_ERROR, + message=custom_message + ) + + assert response.status_code == 401 + + def test_create_error_response_with_trace_id(self): + """Test creating error response with trace ID.""" + trace_id = "test-trace-id-123" + response = create_error_response( + ErrorCode.DIFY_AUTH_ERROR, + trace_id=trace_id + ) + + assert response.status_code == 401 + + def test_create_error_response_with_details(self): + """Test creating error response with additional details.""" + details = {"field": "api_key", "issue": "invalid format"} + response = create_error_response( + ErrorCode.DIFY_CONFIG_INVALID, + details=details + ) + + assert response.status_code == 401 + + def test_create_error_response_custom_http_status(self): + """Test creating error response with custom HTTP status.""" + response = create_error_response( + ErrorCode.DIFY_SERVICE_ERROR, + http_status=502 + ) + + assert response.status_code == 502 + + def test_create_error_response_dify_auth_error(self): + """Test creating error response for DIFY_AUTH_ERROR.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + + assert response.status_code == 401 + + def test_create_error_response_dify_config_invalid(self): + """Test creating error response for DIFY_CONFIG_INVALID.""" + response = create_error_response(ErrorCode.DIFY_CONFIG_INVALID) + + assert response.status_code == 401 + + def test_create_error_response_dify_rate_limit(self): + """Test creating error response for DIFY_RATE_LIMIT.""" + response = create_error_response(ErrorCode.DIFY_RATE_LIMIT) + + assert response.status_code == 429 + + def test_create_error_response_validation_error(self): + """Test creating error response for VALIDATION_ERROR.""" + response = create_error_response(ErrorCode.VALIDATION_ERROR) + + assert response.status_code == 400 + + def test_create_error_response_token_expired(self): + """Test creating error response for TOKEN_EXPIRED.""" + response = create_error_response(ErrorCode.TOKEN_EXPIRED) + + assert response.status_code == 401 + + +class TestCreateSuccessResponse: + """Test class for create_success_response function.""" + + def test_create_success_response_default(self): + """Test creating success response with default values.""" + response = create_success_response() + + assert response.status_code == 200 + + def test_create_success_response_with_data(self): + """Test creating success response with data.""" + data = {"key": "value"} + response = create_success_response(data=data) + + assert response.status_code == 200 + + def test_create_success_response_custom_message(self): + """Test creating success response with custom message.""" + response = create_success_response(message="Operation successful") + + assert response.status_code == 200 + + def test_create_success_response_with_trace_id(self): + """Test creating success response with trace ID.""" + trace_id = "test-trace-id-456" + response = create_success_response(trace_id=trace_id) + + assert response.status_code == 200 + + def test_create_success_response_all_params(self): + """Test creating success response with all parameters.""" + data = {"result": "ok"} + message = "Success" + trace_id = "trace-789" + response = create_success_response( + data=data, + message=message, + trace_id=trace_id + ) + + assert response.status_code == 200 + + +class TestExceptionHandlerMiddleware: + """Test class for ExceptionHandlerMiddleware.""" + + @pytest.mark.asyncio + async def test_dispatch_normal_request(self): + """Test that normal requests pass through without error.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + mock_response = MagicMock(spec=Response) + mock_call_next = AsyncMock(return_value=mock_response) + + response = await middleware.dispatch(mock_request, mock_call_next) + + mock_call_next.assert_called_once_with(mock_request) + assert response == mock_response + + @pytest.mark.asyncio + async def test_dispatch_app_exception(self): + """Test handling of AppException.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate AppException being raised + app_exception = AppException( + ErrorCode.DIFY_AUTH_ERROR, + "Dify authentication failed" + ) + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_dispatch_http_exception(self): + """Test handling of FastAPI HTTPException.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate HTTPException being raised + http_exception = HTTPException(status_code=404, detail="Not found") + mock_call_next = AsyncMock(side_effect=http_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_dispatch_generic_exception(self): + """Test handling of generic exceptions.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate generic exception being raised + generic_exception = RuntimeError("Something went wrong") + mock_call_next = AsyncMock(side_effect=generic_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + # Should return 500 with internal error code + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_trace_id_generated(self): + """Test that trace ID is generated for each request.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + mock_response = MagicMock(spec=Response) + mock_call_next = AsyncMock(return_value=mock_response) + + response = await middleware.dispatch(mock_request, mock_call_next) + + # Verify trace_id was set on request.state + assert hasattr(mock_request.state, 'trace_id') + + @pytest.mark.asyncio + async def test_app_exception_with_details(self): + """Test handling of AppException with details.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # AppException with details + app_exception = AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Invalid configuration", + details={"field": "api_key"} + ) + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_different_error_codes_map_to_correct_status(self): + """Test that different error codes produce correct HTTP status.""" + test_cases = [ + (ErrorCode.TOKEN_EXPIRED, 401), + (ErrorCode.TOKEN_INVALID, 401), + (ErrorCode.FORBIDDEN, 403), + (ErrorCode.RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.VALIDATION_ERROR, 400), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + for error_code, expected_status in test_cases: + app_exception = AppException(error_code, "Test error") + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == expected_status, \ + f"Expected {expected_status} for {error_code}, got {response.status_code}" From 30365f05a5f3438fce3ed0788d1a6c40019624f9 Mon Sep 17 00:00:00 2001 From: zhizhi <928570418@qq.com> Date: Sat, 28 Feb 2026 09:47:12 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20Add=20unit=20tests=20for=20erro?= =?UTF-8?q?r=20codes=20and=20exception=20handling:=20Introduced=20comprehe?= =?UTF-8?q?nsive=20tests=20for=20ErrorCode=20enum=20and=20ERROR=5FCODE=5FH?= =?UTF-8?q?TTP=5FSTATUS=20mapping,=20as=20well=20as=20for=20ExceptionHandl?= =?UTF-8?q?erMiddleware.=20Ensured=20proper=20mapping=20of=20HTTP=20status?= =?UTF-8?q?=20codes=20to=20error=20codes=20and=20validated=20error=20respo?= =?UTF-8?q?nse=20creation=20functions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/docs/error_code_handling.md | 950 ------------------- test/backend/app/test_config_app.py | 231 ++++- test/backend/app/test_dify_app.py | 111 ++- test/backend/app/test_northbound_base_app.py | 254 ++++- test/backend/services/test_dify_service.py | 151 +++ test/backend/test_error_code.py | 168 ++++ test/backend/test_exception_handler.py | 316 ++++++ 7 files changed, 1187 insertions(+), 994 deletions(-) delete mode 100644 doc/docs/error_code_handling.md create mode 100644 test/backend/test_error_code.py create mode 100644 test/backend/test_exception_handler.py diff --git a/doc/docs/error_code_handling.md b/doc/docs/error_code_handling.md deleted file mode 100644 index 4a51f1c2d..000000000 --- a/doc/docs/error_code_handling.md +++ /dev/null @@ -1,950 +0,0 @@ -# 错误码处理方案 - -## 1. 错误码格式设计 - -项目采用了统一的 **XYYZZZ** 格式的 6 位数字错误码: - -| 位置 | 含义 | 取值范围 | -|------|------|----------| -| **X** | 错误级别 | 1=系统, 2=认证, 3=业务, 4=外部 | -| **YY** | 模块编号 | 01-22 (系统、认证、用户、租户、Agent、工具、对话、记忆、知识、模型、语音、文件、邀请、分组、数据、外部、验证、资源、限流等) | -| **ZZZ** | 错误序号 | 001-999 | - -### 模块编号对照表 - -| 编号 | 模块 | 编号 | 模块 | -|------|------|------|------| -| 01 | System | 12 | File | -| 02 | Auth | 13 | Invitation | -| 03 | User | 14 | Group | -| 04 | Tenant | 15 | Data | -| 05 | Agent | 16 | External | -| 06 | Tool/MCP | 20 | Validation | -| 07 | Conversation | 21 | Resource | -| 08 | Memory | 22 | RateLimit | -| 09 | Knowledge | | | -| 10 | Model | | | -| 11 | Voice | | | - ---- - -## 2. 后端错误码实现 - -### 核心文件 - -| 文件路径 | 职责 | -|----------|------| -| `backend/consts/error_code.py` | 错误码枚举定义 | -| `backend/consts/error_message.py` | 错误消息映射 | -| `backend/consts/exceptions.py` | 自定义异常类 | - -### 错误码枚举定义 - -```python -# backend/consts/error_code.py -class ErrorCode(int, Enum): - """Business error codes.""" - - # ==================== System Level Errors (10xxxx) ==================== - UNKNOWN_ERROR = 101001 - SERVICE_UNAVAILABLE = 101002 - DATABASE_ERROR = 101003 - TIMEOUT = 101004 - INTERNAL_ERROR = 101005 - - # ==================== Auth Level Errors (102xxx) ==================== - UNAUTHORIZED = 102001 - TOKEN_EXPIRED = 102002 - TOKEN_INVALID = 102003 - SIGNATURE_INVALID = 102004 - FORBIDDEN = 102005 - - # ==================== User Module Errors (103xxx) ==================== - USER_NOT_FOUND = 103001 - USER_REGISTRATION_FAILED = 103002 - USER_ALREADY_EXISTS = 103003 - INVALID_CREDENTIALS = 103004 - - # ==================== Tenant Module Errors (104xxx) ==================== - TENANT_NOT_FOUND = 104001 - TENANT_DISABLED = 104002 - TENANT_CONFIG_ERROR = 104003 - - # ==================== Agent Module Errors (105xxx) ==================== - AGENT_NOT_FOUND = 105001 - AGENT_RUN_FAILED = 105002 - AGENT_NAME_DUPLICATE = 105003 - AGENT_DISABLED = 105004 - AGENT_VERSION_NOT_FOUND = 105005 - - # ==================== Tool/MCP Module Errors (106xxx) ==================== - TOOL_NOT_FOUND = 106001 - TOOL_EXECUTION_FAILED = 106002 - TOOL_CONFIG_INVALID = 106003 - MCP_CONNECTION_FAILED = 106101 - MCP_NAME_ILLEGAL = 106102 - MCP_CONTAINER_ERROR = 106103 - - # ==================== Conversation Module Errors (107xxx) ==================== - CONVERSATION_NOT_FOUND = 107001 - CONVERSATION_SAVE_FAILED = 107002 - MESSAGE_NOT_FOUND = 107003 - CONVERSATION_TITLE_GENERATION_FAILED = 107004 - - # ==================== Memory Module Errors (108xxx) ==================== - MEMORY_NOT_FOUND = 108001 - MEMORY_PREPARATION_FAILED = 108002 - MEMORY_CONFIG_INVALID = 108003 - - # ==================== Knowledge Module Errors (109xxx) ==================== - KNOWLEDGE_NOT_FOUND = 109001 - KNOWLEDGE_SYNC_FAILED = 109002 - INDEX_NOT_FOUND = 109003 - KNOWLEDGE_SEARCH_FAILED = 109004 - KNOWLEDGE_UPLOAD_FAILED = 109005 - - # ==================== Model Module Errors (110xxx) ==================== - MODEL_NOT_FOUND = 110001 - MODEL_CONFIG_INVALID = 110002 - MODEL_HEALTH_CHECK_FAILED = 110003 - MODEL_PROVIDER_ERROR = 110004 - - # ==================== Voice Module Errors (111xxx) ==================== - VOICE_SERVICE_ERROR = 111001 - STT_CONNECTION_FAILED = 111002 - TTS_CONNECTION_FAILED = 111003 - VOICE_CONFIG_INVALID = 111004 - - # ==================== File Module Errors (112xxx) ==================== - FILE_NOT_FOUND = 112001 - FILE_UPLOAD_FAILED = 112002 - FILE_TOO_LARGE = 112003 - FILE_TYPE_NOT_ALLOWED = 112004 - FILE_PREPROCESS_FAILED = 112005 - - # ==================== Invitation Module Errors (113xxx) ==================== - INVITE_CODE_NOT_FOUND = 113001 - INVITE_CODE_INVALID = 113002 - INVITE_CODE_EXPIRED = 113003 - - # ==================== Group Module Errors (114xxx) ==================== - GROUP_NOT_FOUND = 114001 - GROUP_ALREADY_EXISTS = 114002 - MEMBER_NOT_IN_GROUP = 114003 - - # ==================== Data Process Module Errors (115xxx) ==================== - DATA_PROCESS_FAILED = 115001 - DATA_PARSE_FAILED = 115002 - - # ==================== External Service Errors (116xxx) ==================== - ME_CONNECTION_FAILED = 116001 - DATAMATE_CONNECTION_FAILED = 116002 - DIFY_SERVICE_ERROR = 116003 - EXTERNAL_API_ERROR = 116004 - DIFY_CONFIG_INVALID = 116101 - DIFY_CONNECTION_ERROR = 116102 - DIFY_AUTH_ERROR = 116103 - DIFY_RATE_LIMIT = 116104 - DIFY_RESPONSE_ERROR = 116105 - - # ==================== Validation Errors (120xxx) ==================== - VALIDATION_ERROR = 120001 - PARAMETER_INVALID = 120002 - MISSING_REQUIRED_FIELD = 120003 - - # ==================== Resource Errors (121xxx) ==================== - RESOURCE_NOT_FOUND = 121001 - RESOURCE_ALREADY_EXISTS = 121002 - RESOURCE_DISABLED = 121003 - - # ==================== Rate Limit Errors (122xxx) ==================== - RATE_LIMIT_EXCEEDED = 122001 - - -# HTTP status code mapping -ERROR_CODE_HTTP_STATUS = { - ErrorCode.UNAUTHORIZED: 401, - ErrorCode.TOKEN_EXPIRED: 401, - ErrorCode.TOKEN_INVALID: 401, - ErrorCode.SIGNATURE_INVALID: 401, - ErrorCode.FORBIDDEN: 403, - ErrorCode.RATE_LIMIT_EXCEEDED: 429, - ErrorCode.DIFY_RATE_LIMIT: 429, - ErrorCode.VALIDATION_ERROR: 400, - ErrorCode.PARAMETER_INVALID: 400, - ErrorCode.MISSING_REQUIRED_FIELD: 400, - ErrorCode.FILE_TOO_LARGE: 413, - ErrorCode.DIFY_CONFIG_INVALID: 400, - ErrorCode.DIFY_AUTH_ERROR: 400, - ErrorCode.DIFY_CONNECTION_ERROR: 502, - ErrorCode.DIFY_RESPONSE_ERROR: 502, -} -``` - -### 自定义异常类 - -```python -# backend/consts/exceptions.py -class AppException(Exception): - """Base application exception with error code.""" - - def __init__(self, error_code: ErrorCode, message: str = None, details: dict = None): - self.error_code = error_code - self.message = message or ErrorMessage.get_message(error_code) - self.details = details or {} - super().__init__(self.message) - - def to_dict(self) -> dict: - return { - "code": self.error_code.value, - "message": self.message, - "details": self.details if self.details else None - } - - @property - def http_status(self) -> int: - return ERROR_CODE_HTTP_STATUS.get(self.error_code, 500) - - -# Backward compatible aliases -NotFoundException = AppException -UnauthorizedError = AppException -ValidationError = AppException -ParameterInvalidError = AppException -ForbiddenError = AppException -ServiceUnavailableError = AppException -DatabaseError = AppException -TimeoutError = AppException -UnknownError = AppException - -# Domain Specific Aliases -UserNotFoundError = AppException -UserAlreadyExistsError = AppException -InvalidCredentialsError = AppException - -TenantNotFoundError = AppException -TenantDisabledError = AppException - -AgentNotFoundError = AppException -AgentRunException = AppException -AgentDisabledError = AppException - -ToolNotFoundError = AppException -ToolExecutionException = AppException - -MCPConnectionError = AppException -MCPNameIllegal = AppException -MCPContainerError = AppException - - -def raise_error(error_code: ErrorCode, message: str = None, details: dict = None): - """Raise an AppException with the given error code.""" - raise AppException(error_code, message, details) -``` - -### 使用示例 - -```python -from consts.error_code import ErrorCode -from consts.exceptions import AppException - -# 方式1: 直接抛出 -raise AppException(ErrorCode.AGENT_NOT_FOUND) - -# 方式2: 带自定义消息和详情 -raise AppException( - ErrorCode.MCP_CONNECTION_FAILED, - "Connection timeout", - details={"host": "localhost", "port": 8080} -) - -# 方式3: 使用别名 -raise AgentNotFoundError(ErrorCode.AGENT_NOT_FOUND) - -# 方式4: 使用辅助函数 -from consts.exceptions import raise_error -raise_error(ErrorCode.USER_NOT_FOUND, "User ID not found", details={"user_id": 123}) -``` - ---- - -## 3. 后端异常处理 - -### 全局异常处理中间件 - -```python -# backend/middleware/exception_handler.py -class ExceptionHandlerMiddleware(BaseHTTPMiddleware): - """Global exception handler middleware.""" - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - # Generate trace ID for request tracking - trace_id = str(uuid.uuid4()) - request.state.trace_id = trace_id - - try: - response = await call_next(request) - return response - except AppException as exc: - # Handle AppException with error code - logger.error(f"[{trace_id}] AppException: {exc.error_code.value} - {exc.message}") - - return JSONResponse( - status_code=exc.http_status, - content={ - "code": exc.error_code.value, - "message": exc.message, - "trace_id": trace_id, - "details": exc.details if exc.details else None - } - ) - except HTTPException as exc: - # Handle FastAPI HTTPException for backward compatibility - error_code = _http_status_to_error_code(exc.status_code) - - return JSONResponse( - status_code=exc.status_code, - content={ - "code": error_code.value, - "message": exc.detail, - "trace_id": trace_id - } - ) - except Exception as exc: - # Handle unknown exceptions - return HTTP 500 - logger.error(f"[{trace_id}] Unhandled exception: {str(exc)}", exc_info=True) - - return JSONResponse( - status_code=500, - content={ - "code": ErrorCode.INTERNAL_ERROR.value, - "message": ErrorMessage.get_message(ErrorCode.INTERNAL_ERROR), - "trace_id": trace_id, - "details": None - } - ) -``` - -### HTTP 状态码与业务错误码映射 - -项目采用 **混合模式**:HTTP 状态码 + 业务错误码。 - -| 业务错误码 | HTTP 状态码 | 说明 | -|------------|-------------|------| -| 102001-102005 (认证错误) | 401 | 未授权 | -| 102005 (FORBIDDEN) | 403 | 禁止访问 | -| 122001 (RATE_LIMIT_EXCEEDED) | 429 | 请求过于频繁 | -| 120001-120003 (验证错误) | 400 | 请求参数错误 | -| 112003 (FILE_TOO_LARGE) | 413 | 请求实体过大 | -| 116102, 116105 (Dify 连接/响应错误) | 502 | 网关错误 | -| 其他 AppException | 使用映射表或默认 500 | 根据错误码映射 | -| 未知异常 | 500 | 服务器内部错误 | - -### 响应格式 - -**成功响应:** -```json -{ - "code": 0, - "message": "OK", - "data": { ... }, - "trace_id": "uuid-string" -} -``` - -**错误响应:** - -```http -HTTP/1.1 404 Not Found -Content-Type: application/json - -{ - "code": 105001, - "message": "Agent not found.", - "trace_id": "uuid-string", - "details": null -} -``` - -```http -HTTP/1.1 401 Unauthorized -Content-Type: application/json - -{ - "code": 102002, - "message": "Your session has expired. Please login again.", - "trace_id": "uuid-string" -} -``` - -```http -HTTP/1.1 500 Internal Server Error -Content-Type: application/json - -{ - "code": 101005, - "message": "Internal server error. Please try again later.", - "trace_id": "uuid-string" -} -``` - -### 辅助函数 - -```python -# backend/middleware/exception_handler.py -def create_error_response( - error_code: ErrorCode, - message: str = None, - trace_id: str = None, - details: dict = None, - http_status: int = None -) -> JSONResponse: - """ - Create a standardized error response with mixed mode. - - Args: - error_code: The error code - message: Optional custom message (defaults to standard message) - trace_id: Optional trace ID for tracking - details: Optional additional details - http_status: Optional HTTP status code (defaults to mapping from error_code) - - Returns: - JSONResponse with standardized error format - """ - # Use provided http_status or get from error code mapping - status = http_status if http_status else ERROR_CODE_HTTP_STATUS.get(error_code, 500) - - return JSONResponse( - status_code=status, - content={ - "code": error_code.value, - "message": message or ErrorMessage.get_message(error_code), - "trace_id": trace_id, - "details": details - } - ) - - -def create_success_response( - data: any = None, - message: str = "OK", - trace_id: str = None -) -> JSONResponse: - """Create a standardized success response.""" - return JSONResponse( - status_code=200, - content={ - "code": 0, - "message": message, - "data": data, - "trace_id": trace_id - } - ) -``` - ---- - -## 4. 前端错误码实现 - -### 核心文件 - -| 文件路径 | 职责 | -|----------|------| -| `frontend/const/errorCode.ts` | 错误码枚举(与后端一致) | -| `frontend/const/errorMessage.ts` | 默认错误消息(英文) | -| `frontend/const/errorMessageI18n.ts` | i18n 支持工具函数 | -| `frontend/hooks/useErrorHandler.ts` | 错误处理 Hook | - -### TypeScript 错误码枚举 - -```typescript -// frontend/const/errorCode.ts -export enum ErrorCode { - // ==================== System Level Errors (10xxxx) ==================== - UNKNOWN_ERROR = 101001, - SERVICE_UNAVAILABLE = 101002, - DATABASE_ERROR = 101003, - TIMEOUT = 101004, - INTERNAL_ERROR = 101005, - - // ==================== Auth Level Errors (102xxx) ==================== - UNAUTHORIZED = 102001, - TOKEN_EXPIRED = 102002, - TOKEN_INVALID = 102003, - SIGNATURE_INVALID = 102004, - FORBIDDEN = 102005, - - // ==================== User Module Errors (103xxx) ==================== - USER_NOT_FOUND = 103001, - USER_REGISTRATION_FAILED = 103002, - USER_ALREADY_EXISTS = 103003, - INVALID_CREDENTIALS = 103004, - - // ... (与后端完全一致的其他模块错误码) - - // ==================== Validation Errors (120xxx) ==================== - VALIDATION_ERROR = 120001, - PARAMETER_INVALID = 120002, - MISSING_REQUIRED_FIELD = 120003, - - // ==================== Resource Errors (121xxx) ==================== - RESOURCE_NOT_FOUND = 121001, - RESOURCE_ALREADY_EXISTS = 121002, - RESOURCE_DISABLED = 121003, - - // ==================== Rate Limit Errors (122xxx) ==================== - RATE_LIMIT_EXCEEDED = 122001, - - // ==================== Success Code ==================== - SUCCESS = 0, -} - -/** - * Check if an error code represents a success. - */ -export const isSuccess = (code: number): boolean => { - return code === ErrorCode.SUCCESS; -}; - -/** - * Check if an error code represents an authentication error. - */ -export const isAuthError = (code: number): boolean => { - return code >= 102001 && code < 103000; -}; - -/** - * Check if an error code represents a session expiration. - */ -export const isSessionExpired = (code: number): boolean => { - return code === ErrorCode.TOKEN_EXPIRED || code === ErrorCode.TOKEN_INVALID; -}; -``` - ---- - -## 5. 前端错误处理 - -### API 层 - -```typescript -// frontend/services/api.ts -export class ApiError extends Error { - constructor( - public code: number, - message: string - ) { - super(message); - this.name = "ApiError"; - } -} - -// API request interceptor -export const fetchWithErrorHandling = async ( - url: string, - options: RequestInit = {} -) => { - try { - const response = await fetch(url, options); - - // Handle HTTP errors - if (!response.ok) { - // Handle 401 - Session expired - if (response.status === 401) { - handleSessionExpired(); - throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, - "Login expired, please login again" - ); - } - - // Handle 499 - Client closed connection - if (response.status === 499) { - handleSessionExpired(); - throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, - "Connection disconnected, session may have expired" - ); - } - - // Handle 413 - Request entity too large - if (response.status === 413) { - throw new ApiError( - STATUS_CODES.REQUEST_ENTITY_TOO_LARGE, - "File size exceeds limit" - ); - } - - // Other HTTP errors - try to parse JSON response for error code - let errorCode = response.status; - let errorMessage = `Request failed: ${response.status}`; - const errorText = await response.text(); - - try { - const errorData = JSON.parse(errorText); - if (errorData && errorData.code) { - errorCode = errorData.code; - errorMessage = errorData.message || errorMessage; - } - } catch { - errorMessage = errorText || errorMessage; - } - - throw new ApiError(errorCode, errorMessage); - } - - return response; - } catch (error) { - // Handle network errors - if (error instanceof TypeError && error.message.includes("NetworkError")) { - throw new ApiError( - STATUS_CODES.SERVER_ERROR, - "Network connection error, please check your network connection" - ); - } - - // Handle connection reset errors - if (error instanceof TypeError && error.message.includes("Failed to fetch")) { - // For user management related requests, it might be login expiration - if (url.includes("/user/session") || url.includes("/user/current_user_id")) { - handleSessionExpired(); - throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, - "Connection disconnected, session may have expired" - ); - } else { - throw new ApiError( - STATUS_CODES.SERVER_ERROR, - "Server connection error, please try again later" - ); - } - } - - throw error; - } -}; -``` - -### 错误处理 Hook - -```typescript -// frontend/hooks/useErrorHandler.ts -export const useErrorHandler = () => { - const { t } = useTranslation(); - - /** - * Get i18n error message by error code - */ - const getI18nErrorMessage = useCallback((code: number): string => { - // Try to get i18n key - const i18nKey = `errorCode.${code}`; - const translated = t(i18nKey); - - // If translation exists (not equal to key), return translated message - if (translated !== i18nKey) { - return translated; - } - - // Fallback to default messages - return ( - DEFAULT_ERROR_MESSAGES[code] || - DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] - ); - }, [t]); - - /** - * Handle API error - */ - const handleError = useCallback( - (error: unknown, options: ErrorHandlerOptions = {}) => { - const { showMessage, onError, handleSession } = { - ...DEFAULT_OPTIONS, - ...options, - }; - - // Handle ApiError - if (error instanceof ApiError) { - // Handle session expiration - if (handleSession && isSessionExpired(error.code)) { - handleSessionExpired(); - } - - // Get localized message - const errorMessage = getI18nErrorMessage(error.code); - - // Log error - log.error(`API Error [${error.code}]: ${errorMessage}`, error); - - // Show message to user - if (showMessage) { - message.error(errorMessage); - } - - // Call onError callback - if (onError) { - onError(error); - } - - return { - code: error.code, - message: errorMessage, - originalError: error, - }; - } - - // Handle unknown error - if (error instanceof Error) { - log.error("Unknown error:", error); - if (showMessage) { - message.error(getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR)); - } - return { - code: ErrorCode.UNKNOWN_ERROR, - message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), - originalError: error, - }; - } - - return { - code: ErrorCode.UNKNOWN_ERROR, - message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), - originalError: null, - }; - }, - [getI18nErrorMessage] - ); - - /** - * Wrap async function with error handling - */ - const withErrorHandler = useCallback( - (fn: () => Promise, options: ErrorHandlerOptions = {}) => { - return async (...args: any[]) => { - try { - return await fn(...args); - } catch (error) { - throw handleError(error, options); - } - }; - }, - [handleError] - ); - - return { - getI18nErrorMessage, - handleError, - withErrorHandler, - }; -}; -``` - -### i18n 工具函数 - -```typescript -// frontend/const/errorMessageI18n.ts -export const getI18nErrorMessage = ( - code: number, - t?: (key: string) => string -): string => { - // Try i18n translation first - if (t) { - const i18nKey = `errorCode.${code}`; - const translated = t(i18nKey); - if (translated !== i18nKey) { - return translated; - } - } - - // Fall back to default message - return ( - DEFAULT_ERROR_MESSAGES[code] || - DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] - ); -}; - -/** - * Show error to user with i18n support. - */ -export const showErrorToUser = ( - error: any, - t?: (key: string) => string, - options: ShowErrorOptions = {} -): void => { - const { - handleSession = true, - showMessage = true, - customMessage, - onError, - } = options; - - // Get error code if available - let errorCode: number | undefined; - if (error && typeof error === "object" && "code" in error) { - errorCode = error.code as number; - } - - // Handle session expiration - if (handleSession && errorCode && isSessionExpired(errorCode)) { - handleSessionExpired(); - } - - // Get the error message - let errorMessage: string; - if (customMessage) { - errorMessage = customMessage; - } else if (errorCode) { - errorMessage = getI18nErrorMessage(errorCode, t); - } else if (error instanceof Error) { - errorMessage = error.message; - } else { - errorMessage = getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR, t); - } - - // Log and show message - log.error(`Error [${errorCode || "unknown"}]: ${errorMessage}`, error); - if (showMessage) { - message.error(errorMessage); - } - - if (onError) { - onError(error); - } -}; - -/** - * Wrap an async function with automatic error handling. - */ -export const withErrorHandler = ( - fn: () => Promise, - options: ShowErrorOptions = {} -) => { - return async (...args: any[]) => { - try { - return await fn(...args); - } catch (error) { - showErrorToUser(error, undefined, options); - throw error; - } - }; -}; -``` - ---- - -## 6. i18n 多语言支持 - -### 翻译文件位置 - -- `frontend/public/locales/en/common.json` -- `frontend/public/locales/zh/common.json` - -### 翻译 key 格式 - -```json -{ - "errorCode.101001": "An unknown error occurred. Please try again later.", - "errorCode.101002": "Service is temporarily unavailable. Please try again later.", - "errorCode.102001": "You are not authorized to perform this action.", - "errorCode.102002": "Your session has expired. Please login again.", - "errorCode.105001": "Agent not found.", - "errorCode.105002": "Failed to run agent. Please try again later.", - "errorCode.120001": "Validation failed." -} -``` - -中文翻译示例: - -```json -{ - "errorCode.101001": "发生未知错误,请稍后重试", - "errorCode.101002": "服务暂时不可用,请稍后重试", - "errorCode.102001": "您没有执行此操作的权限", - "errorCode.102002": "您的登录已过期,请重新登录", - "errorCode.105001": "智能体不存在", - "errorCode.105002": "运行智能体失败,请稍后重试", - "errorCode.120001": "验证失败" -} -``` - ---- - -## 7. 整体数据流 - -``` -+-----------------------------------------------------------------+ -| Backend | -| +--------------+ +--------------+ +----------------------+ | -| | error_code | | error_message| | exceptions | | -| | .py | | .py | | .py | | -| | ErrorCode | | ErrorMessage | | AppException | | -| | (Enum) | | (Mapping) | | (with aliases) | | -| +--------------+ +--------------+ +----------------------+ | -| | | | | -| +-----------------+--------------------+ | -| v | -| exception_handler.py | -| (Middleware: converts to JSON response) | -+-----------------------------------------------------------------+ - | - HTTP Response - | - v -+-----------------------------------------------------------------+ -| Frontend | -| +------------------------------------------------------+ | -| | api.ts | | -| | fetchWithErrorHandling -> ApiError | | -| +------------------------------------------------------+ | -| | | -| +-----------------+-----------------+ | -| v v v | -| +------------+ +--------------+ +-------------+ | -| | errorCode | | errorMessage | | errorMessage| | -| | .ts | | .ts | | I18n.ts | | -| +------------+ +--------------+ +-------------+ | -| | | | | -| +----------------+------------------+ | -| v | -| useErrorHandler.ts | -| (Hook: getI18nErrorMessage, handleError) | -| | | -| v | -| +-----------------------------+ | -| | public/locales/{lang}/ | | -| | common.json | | -| | (i18n translations) | | -| +-----------------------------+ | -+-----------------------------------------------------------------+ -``` - ---- - -## 8. 方案总结 - -### 文件清单 - -| 层面 | 后端文件 | 前端文件 | -|------|----------|----------| -| **定义层** | `backend/consts/error_code.py` | `frontend/const/errorCode.ts` | -| **消息层** | `backend/consts/error_message.py` | `frontend/const/errorMessage.ts` | -| **异常层** | `backend/consts/exceptions.py` | - | -| **处理层** | `backend/middleware/exception_handler.py` | `frontend/hooks/useErrorHandler.ts` | -| **API层** | - | `frontend/services/api.ts` | -| **i18n层** | - | `frontend/const/errorMessageI18n.ts` | -| **翻译层** | - | `frontend/public/locales/{lang}/common.json` | - -### 方案优点 - -1. **前后端统一** - 错误码在前后端完全一致,便于问题追踪 -2. **i18n 支持** - 支持多语言错误消息 -3. **统一响应格式** - 所有 API 响应采用相同结构 -4. **可扩展性** - 支持 details 字段传递额外错误信息 -5. **可追踪性** - 支持 trace_id 便于问题定位 -6. **类型安全** - TypeScript 枚举与 Python Enum 一一对应 - -### 注意事项 - -- 项目采用 **混合模式**:HTTP 状态码 + 业务错误码 -- 建议新增错误码时按照 XYYZZZ 格式依次递增,保持模块内连续性 -- 所有新增错误码需要在 `common.json` 中添加对应的 i18n 翻译 diff --git a/test/backend/app/test_config_app.py b/test/backend/app/test_config_app.py index 87ca2b959..537aa8b40 100644 --- a/test/backend/app/test_config_app.py +++ b/test/backend/app/test_config_app.py @@ -1,10 +1,15 @@ +import atexit +from apps.config_app import app +from fastapi.testclient import TestClient +from fastapi import HTTPException import unittest from unittest.mock import patch, MagicMock, Mock import sys import os # Add the backend directory to path so we can import modules -backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../backend')) +backend_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), '../../../backend')) sys.path.insert(0, backend_path) # Apply patches before importing any app modules @@ -23,8 +28,10 @@ # before any module imports that might trigger MinioClient initialization critical_patches = [ # Patch storage factory and MinIO config validation FIRST - patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock), - patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None), + patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock), + patch('nexent.storage.minio_config.MinIOStorageConfig.validate', + lambda self: None), # Mock boto3 client patch('boto3.client', return_value=Mock()), # Mock boto3 resource @@ -55,26 +62,28 @@ try: from backend.database import client as db_client_module # Patch get_db_session after module is imported - db_session_patch = patch.object(db_client_module, 'get_db_session', return_value=Mock()) + db_session_patch = patch.object( + db_client_module, 'get_db_session', return_value=Mock()) db_session_patch.start() all_patches.append(db_session_patch) except ImportError: # If import fails, try patching the path directly (may trigger import) - db_session_patch = patch('backend.database.client.get_db_session', return_value=Mock()) + db_session_patch = patch( + 'backend.database.client.get_db_session', return_value=Mock()) db_session_patch.start() all_patches.append(db_session_patch) # Now safe to import app modules -from fastapi import HTTPException -from fastapi.testclient import TestClient -from apps.config_app import app # Stop all patches at the end of the module -import atexit + + def stop_patches(): for p in all_patches: p.stop() + + atexit.register(stop_patches) @@ -94,9 +103,9 @@ def test_cors_middleware(self): if middleware.cls.__name__ == "CORSMiddleware": cors_middleware = middleware break - + self.assertIsNotNone(cors_middleware) - + # In FastAPI, middleware options are stored in 'middleware.kwargs' self.assertEqual(cors_middleware.kwargs.get("allow_origins"), ["*"]) self.assertTrue(cors_middleware.kwargs.get("allow_credentials")) @@ -107,7 +116,7 @@ def test_routers_included(self): """Test that all routers are included in the app.""" # Get all routes in the app routes = [route.path for route in app.routes] - + # Check if routes exist (at least some routes should be present) self.assertTrue(len(routes) > 0) @@ -116,7 +125,7 @@ def test_http_exception_handler(self): # Test that the exception handler is registered exception_handlers = app.exception_handlers self.assertIn(HTTPException, exception_handlers) - + # Test that the handler function exists and is callable http_exception_handler = exception_handlers[HTTPException] self.assertIsNotNone(http_exception_handler) @@ -127,7 +136,7 @@ def test_generic_exception_handler(self): # Test that the exception handler is registered exception_handlers = app.exception_handlers self.assertIn(Exception, exception_handlers) - + # Test that the handler function exists and is callable generic_exception_handler = exception_handlers[Exception] self.assertTrue(callable(generic_exception_handler)) @@ -178,7 +187,8 @@ def test_all_routers_included(self): 'file_manager_router', 'proxy_router', 'tool_config_router', - 'mock_user_management_router', # or 'user_management_router' depending on IS_SPEED_MODE + # or 'user_management_router' depending on IS_SPEED_MODE + 'mock_user_management_router', 'summary_router', 'prompt_router', 'tenant_config_router', @@ -197,7 +207,8 @@ def test_all_routers_included(self): # Since it's hard to identify routers directly from routes, # we'll check that we have a reasonable number of routes - self.assertGreater(len(app.routes), 10) # Should have many routes from all routers + # Should have many routes from all routers + self.assertGreater(len(app.routes), 10) def test_http_exception_handler_registration(self): """Test that HTTP exception handler is properly registered.""" @@ -211,6 +222,190 @@ def test_generic_exception_handler_registration(self): exception_handlers = app.exception_handlers self.assertIn(Exception, exception_handlers) + def test_app_exception_handler_registration(self): + """Test that AppException handler is properly registered.""" + from consts.exceptions import AppException + exception_handlers = app.exception_handlers + self.assertIn(AppException, exception_handlers) + + +class TestExceptionHandlerResponses(unittest.TestCase): + """Test exception handler responses.""" + + def setUp(self): + # Use the actual config_app with exception handlers + from apps.config_app import app + from consts.const import IS_SPEED_MODE + + self.test_app = app + # Use raise_server_exceptions=False to let exception handlers process the exceptions + self.client = TestClient(self.test_app, raise_server_exceptions=False) + + # Also access the logger for testing + from apps import config_app as config_app_module + self.logger = config_app_module.logger + + def test_http_exception_handler_logs_error(self): + """Test HTTPException handler logs the error.""" + from fastapi import HTTPException + + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly + from apps.config_app import http_exception_handler + exc = HTTPException(status_code=400, detail="Bad request") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + http_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify response + self.assertEqual(response.status_code, 400) + self.assertEqual(response.body, b'{"message":"Bad request"}') + + def test_app_exception_handler_logs_error(self): + """Test AppException handler logs the error and returns correct response.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly + from apps.config_app import app_exception_handler + exc = AppException(ErrorCode.VALIDATION_ERROR, "Validation failed") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + app_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify response + self.assertEqual(response.status_code, 400) # VALIDATION_ERROR -> 400 + import json + body = json.loads(response.body) + self.assertEqual(body["code"], ErrorCode.VALIDATION_ERROR.value) + self.assertEqual(body["message"], "Validation failed") + + def test_app_exception_handler_with_details(self): + """Test AppException handler with details field.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + from apps.config_app import app_exception_handler + exc = AppException( + ErrorCode.VALIDATION_ERROR, + "Validation failed", + details={"field": "email"} + ) + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + app_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["details"]["field"], "email") + + def test_generic_exception_handler_logs_error(self): + """Test generic exception handler logs the error.""" + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly + from apps.config_app import generic_exception_handler + exc = ValueError("Something went wrong") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify response + self.assertEqual(response.status_code, 500) + self.assertEqual( + response.body, b'{"message":"Internal server error, please try again later."}') + + def test_generic_exception_handler_delegates_to_app_exception_handler(self): + """Test generic exception handler delegates to AppException handler for AppException.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + # Create a mock request + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + # Call the exception handler directly with an AppException + from apps.config_app import generic_exception_handler + exc = AppException(ErrorCode.FORBIDDEN, "Access denied") + + # Run the handler + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Verify it was delegated to app_exception_handler (returns 403 not 500) + self.assertEqual(response.status_code, 403) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Access denied") + + def test_different_app_exception_error_codes(self): + """Test AppException with different error codes.""" + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + test_cases = [ + (ErrorCode.TOKEN_EXPIRED, 401), + (ErrorCode.FORBIDDEN, 403), + (ErrorCode.RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + + from apps.config_app import app_exception_handler + + import asyncio + for error_code, expected_status in test_cases: + exc = AppException(error_code, "Test error") + + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete( + app_exception_handler(mock_request, exc)) + finally: + loop.close() -if __name__ == "__main__": - unittest.main() + self.assertEqual(response.status_code, expected_status, + f"Expected {expected_status} for {error_code}, got {response.status_code}") diff --git a/test/backend/app/test_dify_app.py b/test/backend/app/test_dify_app.py index ad9c8c53c..cf41c0d8c 100644 --- a/test/backend/app/test_dify_app.py +++ b/test/backend/app/test_dify_app.py @@ -132,7 +132,8 @@ async def test_fetch_dify_datasets_api_success(self, dify_mocks): response_body = json.loads(result.body.decode()) assert response_body == expected_result - dify_mocks['get_current_user_id'].assert_called_once_with(mock_auth_header) + # Note: get_current_user_id is imported but not used in dify_app.py + # The test verifies the actual behavior of the function dify_mocks['fetch_dify'].assert_called_once_with( dify_api_base=dify_api_base.rstrip('/'), api_key=api_key @@ -172,6 +173,9 @@ async def test_fetch_dify_datasets_api_url_normalization(self, dify_mocks): @pytest.mark.asyncio async def test_fetch_dify_datasets_api_auth_error(self, dify_mocks): """Test endpoint with authentication error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer invalid-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -179,21 +183,24 @@ async def test_fetch_dify_datasets_api_auth_error(self, dify_mocks): # Mock authentication failure dify_mocks['get_current_user_id'].side_effect = Exception("Invalid token") - # Execute and Assert - with pytest.raises(HTTPException) as exc_info: + # Execute and Assert - the code catches Exception and converts to AppException + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) - dify_mocks['logger'].error.assert_not_called() + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + dify_mocks['logger'].error.assert_called() @pytest.mark.asyncio async def test_fetch_dify_datasets_api_service_validation_error(self, dify_mocks): """Test endpoint with service layer validation error (ValueError).""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "" @@ -203,19 +210,21 @@ async def test_fetch_dify_datasets_api_service_validation_error(self, dify_mocks ) dify_mocks['fetch_dify'].side_effect = ValueError("api_key is required") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST - assert "api_key is required" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR @pytest.mark.asyncio async def test_fetch_dify_datasets_api_service_error(self, dify_mocks): """Test endpoint with general service layer error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -225,21 +234,24 @@ async def test_fetch_dify_datasets_api_service_error(self, dify_mocks): ) dify_mocks['fetch_dify'].side_effect = Exception("Dify API connection failed") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) - assert "Dify API connection failed" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + assert "Dify API connection failed" in str(exc_info.value.message) dify_mocks['logger'].error.assert_called() @pytest.mark.asyncio async def test_fetch_dify_datasets_api_http_error_from_service(self, dify_mocks): """Test endpoint when service raises HTTP-related exception.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -250,19 +262,22 @@ async def test_fetch_dify_datasets_api_http_error_from_service(self, dify_mocks) # Simulate HTTP error from service dify_mocks['fetch_dify'].side_effect = Exception("Dify API HTTP error: 404 Not Found") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) @pytest.mark.asyncio async def test_fetch_dify_datasets_api_request_error_from_service(self, dify_mocks): """Test endpoint when service raises request error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -273,15 +288,18 @@ async def test_fetch_dify_datasets_api_request_error_from_service(self, dify_moc # Simulate request error from service dify_mocks['fetch_dify'].side_effect = Exception("Dify API request failed: Connection refused") - with pytest.raises(HTTPException) as exc_info: + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) @pytest.mark.asyncio async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): @@ -297,7 +315,7 @@ async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): "pagination": {"embedding_available": False} } - # Mock user and tenant ID for None auth + # Mock user and tenant ID for None auth (even though it's not used in the current implementation) dify_mocks['get_current_user_id'].return_value = ( "default_user", "default_tenant" ) @@ -312,7 +330,8 @@ async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): assert isinstance(result, JSONResponse) assert result.status_code == HTTPStatus.OK - dify_mocks['get_current_user_id'].assert_called_once_with(None) + # Note: get_current_user_id is imported but not used in dify_app.py + # The test verifies the actual behavior of the function @pytest.mark.asyncio async def test_fetch_dify_datasets_api_empty_result(self, dify_mocks): @@ -457,6 +476,8 @@ async def test_fetch_dify_datasets_api_logger_info_call(self, dify_mocks): @pytest.mark.asyncio async def test_fetch_dify_datasets_api_logger_error_call(self, dify_mocks): """Test that endpoint logs errors appropriately.""" + from consts.exceptions import AppException + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -466,7 +487,7 @@ async def test_fetch_dify_datasets_api_logger_error_call(self, dify_mocks): ) dify_mocks['fetch_dify'].side_effect = Exception("Connection timeout") - with pytest.raises(HTTPException): + with pytest.raises(AppException): await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, @@ -557,3 +578,49 @@ def test_router_has_datasets_endpoint(self): routes = [route.path for route in router.routes] # Router prefix is /dify, and route is /datasets, so full path is /dify/datasets assert "/dify/datasets" in routes + + +class TestDifyAppExceptionHandlers: + """Test exception handlers in dify_app.py""" + + def test_dify_app_exception_handler_functions_exist(self): + """Test that dify_app module can import exception handlers if defined.""" + # dify_app.py doesn't define its own exception handlers, + # it relies on the global middleware in config_app.py + # This test verifies the module structure + from backend.apps import dify_app + from backend.apps.dify_app import router, logger, fetch_dify_datasets_api + + # Verify router exists + assert router is not None + # Verify logger exists + assert logger is not None + # Verify endpoint function exists + assert fetch_dify_datasets_api is not None + + @pytest.mark.asyncio + async def test_dify_app_logs_service_error(self, dify_mocks): + """Test that service errors are logged and converted to AppException.""" + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + dify_mocks['get_current_user_id'].return_value = ( + "test_user_id", "test_tenant_id" + ) + + # Test with service error + dify_mocks['fetch_dify'].side_effect = Exception("URL connection error") + + from consts.exceptions import AppException + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + # Verify it's a DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + dify_mocks['logger'].error.assert_called() \ No newline at end of file diff --git a/test/backend/app/test_northbound_base_app.py b/test/backend/app/test_northbound_base_app.py index 4316ddd64..fad17ef56 100644 --- a/test/backend/app/test_northbound_base_app.py +++ b/test/backend/app/test_northbound_base_app.py @@ -29,6 +29,22 @@ async def _dummy_route(): return {"msg": "ok"} +# Add endpoints for exception testing +@router_stub.get("/http-exception") +async def _http_exception_route(): + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="Bad request") + +@router_stub.get("/app-exception") +async def _app_exception_route(): + from consts.error_code import ErrorCode + from consts.exceptions import AppException + raise AppException(ErrorCode.VALIDATION_ERROR, "Validation failed") + +@router_stub.get("/generic-exception") +async def _generic_exception_route(): + raise ValueError("Something went wrong") + # Create a lightweight module object and register it as 'apps.northbound_app'. # We add a minimalist namespace package for 'apps' (PEP 420 style) so that imports # using dotted paths still resolve. We set its __path__ to include the real @@ -75,9 +91,25 @@ class SignatureValidationError(Exception): consts_exceptions_module.UnauthorizedError = UnauthorizedError consts_exceptions_module.SignatureValidationError = SignatureValidationError +# Need to import AppException for the stub +from backend.consts.exceptions import AppException as RealAppException +consts_exceptions_module.AppException = RealAppException + # Register the stub so that `from consts.exceptions import ...` works seamlessly sys.modules['consts.exceptions'] = consts_exceptions_module +# --------------------------------------------------------------------------- +# Provide 'consts.error_code' stub so that it can be imported in tests +# --------------------------------------------------------------------------- +consts_error_code_module = types.ModuleType("consts.error_code") + +# Import the real ErrorCode from backend +from backend.consts.error_code import ErrorCode as RealErrorCode +consts_error_code_module.ErrorCode = RealErrorCode + +# Register the stub +sys.modules['consts.error_code'] = consts_error_code_module + # --------------------------------------------------------------------------- # SAFE TO IMPORT THE TARGET MODULE UNDER TEST NOW # --------------------------------------------------------------------------- @@ -90,7 +122,8 @@ class TestNorthboundBaseApp(unittest.TestCase): """Unit tests covering the FastAPI instance defined in northbound_base_app.py""" def setUp(self): - self.client = TestClient(app) + # Use raise_server_exceptions=False to let exception handlers process the exceptions + self.client = TestClient(app, raise_server_exceptions=False) # ------------------------------------------------------------------- # Basic application wiring / configuration @@ -99,6 +132,14 @@ def test_app_root_path(self): """Ensure the FastAPI application is configured with the correct root path.""" self.assertEqual(app.root_path, "/api") + def test_app_title(self): + """Ensure the FastAPI application has correct title.""" + self.assertEqual(app.title, "Nexent Northbound API") + + def test_app_version(self): + """Ensure the FastAPI application has correct version.""" + self.assertEqual(app.version, "1.0.0") + def test_cors_middleware_configuration(self): """Verify that CORS middleware is present and its options match expectations.""" cors_middleware = None @@ -108,14 +149,14 @@ def test_cors_middleware_configuration(self): break # Middleware must be registered self.assertIsNotNone(cors_middleware) - # Validate configured options – these must match the implementation exactly + # Validate configured options - these must match the implementation exactly self.assertEqual(cors_middleware.kwargs.get("allow_origins"), ["*"]) self.assertTrue(cors_middleware.kwargs.get("allow_credentials")) self.assertEqual(cors_middleware.kwargs.get("allow_methods"), ["GET", "POST", "PUT", "DELETE"]) self.assertEqual(cors_middleware.kwargs.get("allow_headers"), ["*"]) def test_router_inclusion(self): - """The northbound router should be included – expect our dummy '/test' endpoint present.""" + """The northbound router should be included - expect our dummy '/test' endpoint present.""" routes = [route.path for route in app.routes] self.assertIn("/test", routes) @@ -131,7 +172,7 @@ def test_custom_exception_handlers_registration(self): self.assertTrue(callable(app.exception_handlers[Exception])) # ------------------------------------------------------------------- - # End-to-end sanity for health (dummy) endpoint – relies on router stub + # End-to-end sanity for health (dummy) endpoint - relies on router stub # ------------------------------------------------------------------- def test_dummy_endpoint_success(self): response = self.client.get("/test") @@ -139,5 +180,210 @@ def test_dummy_endpoint_success(self): self.assertEqual(response.json(), {"msg": "ok"}) +class TestNorthboundExceptionHandlers(unittest.TestCase): + """Test exception handlers in northbound_base_app.py""" + + def setUp(self): + from apps.northbound_base_app import northbound_app as test_app + self.test_app = test_app + self.client = TestClient(self.test_app, raise_server_exceptions=False) + + def test_http_exception_handler_response(self): + """Test HTTPException handler returns correct response.""" + response = self.client.get("/http-exception") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"message": "Bad request"}) + + def test_http_exception_handler_different_status_codes(self): + """Test HTTPException handler with different status codes.""" + from apps.northbound_base_app import northbound_app + + @northbound_app.get("/not-found") + async def _not_found(): + raise HTTPException(status_code=404, detail="Resource not found") + + response = self.client.get("/not-found") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"message": "Resource not found"}) + + def test_app_exception_handler_response(self): + """Test AppException handler returns correct response.""" + response = self.client.get("/app-exception") + self.assertEqual(response.status_code, 400) # VALIDATION_ERROR -> 400 + data = response.json() + self.assertIn("code", data) + self.assertIn("message", data) + self.assertEqual(data["message"], "Validation failed") + + def test_app_exception_handler_includes_error_code(self): + """Test AppException handler includes error code in response.""" + response = self.client.get("/app-exception") + data = response.json() + self.assertIn("code", data) + # Should have a valid error code + self.assertIsNotNone(data["code"]) + + def test_generic_exception_handler_response(self): + """Test generic exception handler returns 500.""" + response = self.client.get("/generic-exception") + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json(), {"message": "Internal server error, please try again later."}) + + def test_generic_exception_handler_delegates_to_app_exception(self): + """Test generic exception handler delegates to AppException handler.""" + from apps.northbound_base_app import northbound_app + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + @northbound_app.get("/app-exception-delegated") + async def _app_exception_delegated(): + raise AppException(ErrorCode.FORBIDDEN, "Access denied") + + response = self.client.get("/app-exception-delegated") + # Should return 403 (FORBIDDEN), not 500 + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertEqual(data["message"], "Access denied") + + def test_different_app_exception_error_codes(self): + """Test different error codes map to correct HTTP status.""" + from apps.northbound_base_app import northbound_app + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + test_cases = [ + (ErrorCode.TOKEN_EXPIRED, 401), + (ErrorCode.FORBIDDEN, 403), + (ErrorCode.RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + for error_code, expected_status in test_cases: + @northbound_app.get(f"/test-{error_code.name}") + async def _test_error(): + raise AppException(error_code, "Test error") + + response = self.client.get(f"/test-{error_code.name}") + self.assertEqual(response.status_code, expected_status, + f"Expected {expected_status} for {error_code}, got {response.status_code}") + + +class TestNorthboundExceptionHandlerFunctions(unittest.TestCase): + """Test exception handler functions directly.""" + + def test_northbound_http_exception_handler_logs_and_returns_json(self): + """Test northbound_http_exception_handler logs and returns correct JSON.""" + from apps.northbound_base_app import northbound_http_exception_handler + from apps.northbound_base_app import logger + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = HTTPException(status_code=400, detail="Bad request") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_http_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Bad request") + + def test_northbound_app_exception_handler_logs_and_returns_json(self): + """Test northbound_app_exception_handler logs and returns correct JSON.""" + from apps.northbound_base_app import northbound_app_exception_handler + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = AppException(ErrorCode.VALIDATION_ERROR, "Validation failed") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_app_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["code"], ErrorCode.VALIDATION_ERROR.value) + self.assertEqual(body["message"], "Validation failed") + + def test_northbound_app_exception_handler_with_details(self): + """Test northbound_app_exception_handler with details field.""" + from apps.northbound_base_app import northbound_app_exception_handler + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = AppException( + ErrorCode.VALIDATION_ERROR, + "Validation failed", + details={"field": "email"} + ) + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_app_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 400) + import json + body = json.loads(response.body) + self.assertEqual(body["details"]["field"], "email") + + def test_northbound_generic_exception_handler_returns_500(self): + """Test northbound_generic_exception_handler returns 500.""" + from apps.northbound_base_app import northbound_generic_exception_handler + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = ValueError("Something went wrong") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + self.assertEqual(response.status_code, 500) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Internal server error, please try again later.") + + def test_northbound_generic_exception_handler_delegates_to_app_exception(self): + """Test northbound_generic_exception_handler delegates to AppException handler.""" + from apps.northbound_base_app import northbound_generic_exception_handler + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + mock_request = MagicMock() + mock_request.url = "http://test.com/test" + exc = AppException(ErrorCode.FORBIDDEN, "Access denied") + + import asyncio + loop = asyncio.new_event_loop() + try: + response = loop.run_until_complete(northbound_generic_exception_handler(mock_request, exc)) + finally: + loop.close() + + # Should delegate to app exception handler (403 not 500) + self.assertEqual(response.status_code, 403) + import json + body = json.loads(response.body) + self.assertEqual(body["message"], "Access denied") + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/test/backend/services/test_dify_service.py b/test/backend/services/test_dify_service.py index a6ed3b091..395174caa 100644 --- a/test/backend/services/test_dify_service.py +++ b/test/backend/services/test_dify_service.py @@ -9,6 +9,8 @@ from unittest.mock import MagicMock, patch import httpx +from backend.consts.error_code import ErrorCode + def _create_mock_client(mock_response): """ @@ -695,3 +697,152 @@ def test_fetch_dify_datasets_impl_url_v1_suffix_parametrized(self, api_base_url) assert "/v1/v1" not in called_url, f"URL duplication detected: {called_url}" # Verify URL ends with /v1/datasets assert called_url.endswith("/v1/datasets") + + def test_fetch_dify_datasets_impl_url_without_protocol(self): + """Test ValueError when dify_api_base doesn't start with http:// or https://.""" + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="dify.example.com", + api_key="test-api-key" + ) + + assert "must start with http:// or https://" in str(excinfo.value) + + def test_fetch_dify_datasets_impl_url_with_ftp_protocol(self): + """Test ValueError when dify_api_base uses unsupported protocol.""" + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="ftp://dify.example.com", + api_key="test-api-key" + ) + + assert "must start with http:// or https://" in str(excinfo.value) + + def test_fetch_dify_datasets_impl_http_401_auth_error(self): + """Test that HTTP 401 maps to DIFY_AUTH_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "401 Unauthorized", + request=MagicMock(), + response=MagicMock(status_code=401) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_AUTH_ERROR + + def test_fetch_dify_datasets_impl_http_403_auth_error(self): + """Test that HTTP 403 maps to DIFY_AUTH_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "403 Forbidden", + request=MagicMock(), + response=MagicMock(status_code=403) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_AUTH_ERROR + + def test_fetch_dify_datasets_impl_http_429_rate_limit(self): + """Test that HTTP 429 maps to DIFY_RATE_LIMIT.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "429 Too Many Requests", + request=MagicMock(), + response=MagicMock(status_code=429) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_RATE_LIMIT + + def test_fetch_dify_datasets_impl_http_500_service_error(self): + """Test that HTTP 500 maps to DIFY_SERVICE_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500 Internal Server Error", + request=MagicMock(), + response=MagicMock(status_code=500) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + + def test_fetch_dify_datasets_impl_http_404_service_error(self): + """Test that HTTP 404 maps to DIFY_SERVICE_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "404 Not Found", + request=MagicMock(), + response=MagicMock(status_code=404) + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + from backend.consts.exceptions import AppException + + with pytest.raises(AppException) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert excinfo.value.error_code == ErrorCode.DIFY_SERVICE_ERROR \ No newline at end of file diff --git a/test/backend/test_error_code.py b/test/backend/test_error_code.py new file mode 100644 index 000000000..c740db8fd --- /dev/null +++ b/test/backend/test_error_code.py @@ -0,0 +1,168 @@ +""" +Unit tests for Error Code definitions. + +Tests the ErrorCode enum and ERROR_CODE_HTTP_STATUS mapping +to ensure error codes are properly defined and mapped. +""" +import pytest +from backend.consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS + + +class TestErrorCodeEnum: + """Test class for ErrorCode enum values.""" + + def test_dify_error_codes_exist(self): + """Test that all Dify-related error codes are defined.""" + assert ErrorCode.DIFY_SERVICE_ERROR is not None + assert ErrorCode.DIFY_CONFIG_INVALID is not None + assert ErrorCode.DIFY_CONNECTION_ERROR is not None + assert ErrorCode.DIFY_AUTH_ERROR is not None + assert ErrorCode.DIFY_RATE_LIMIT is not None + assert ErrorCode.DIFY_RESPONSE_ERROR is not None + + def test_dify_auth_error_value(self): + """Test DIFY_AUTH_ERROR has correct value.""" + assert ErrorCode.DIFY_AUTH_ERROR.value == 116103 + + def test_dify_config_invalid_value(self): + """Test DIFY_CONFIG_INVALID has correct value.""" + assert ErrorCode.DIFY_CONFIG_INVALID.value == 116101 + + def test_dify_connection_error_value(self): + """Test DIFY_CONNECTION_ERROR has correct value.""" + assert ErrorCode.DIFY_CONNECTION_ERROR.value == 116102 + + def test_dify_service_error_value(self): + """Test DIFY_SERVICE_ERROR has correct value.""" + assert ErrorCode.DIFY_SERVICE_ERROR.value == 116003 + + def test_dify_rate_limit_value(self): + """Test DIFY_RATE_LIMIT has correct value.""" + assert ErrorCode.DIFY_RATE_LIMIT.value == 116104 + + def test_dify_response_error_value(self): + """Test DIFY_RESPONSE_ERROR has correct value.""" + assert ErrorCode.DIFY_RESPONSE_ERROR.value == 116105 + + +class TestErrorCodeHttpStatusMapping: + """Test class for ERROR_CODE_HTTP_STATUS mapping.""" + + def test_dify_auth_error_maps_to_401(self): + """Test DIFY_AUTH_ERROR maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_AUTH_ERROR] == 401 + + def test_dify_config_invalid_maps_to_401(self): + """Test DIFY_CONFIG_INVALID maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_CONFIG_INVALID] == 401 + + def test_dify_connection_error_maps_to_502(self): + """Test DIFY_CONNECTION_ERROR maps to HTTP 502.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_CONNECTION_ERROR] == 502 + + def test_dify_response_error_maps_to_502(self): + """Test DIFY_RESPONSE_ERROR maps to HTTP 502.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_RESPONSE_ERROR] == 502 + + def test_dify_rate_limit_maps_to_429(self): + """Test DIFY_RATE_LIMIT maps to HTTP 429.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_RATE_LIMIT] == 429 + + def test_token_expired_maps_to_401(self): + """Test TOKEN_EXPIRED maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.TOKEN_EXPIRED] == 401 + + def test_token_invalid_maps_to_401(self): + """Test TOKEN_INVALID maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.TOKEN_INVALID] == 401 + + def test_unauthorized_maps_to_401(self): + """Test UNAUTHORIZED maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.UNAUTHORIZED] == 401 + + def test_forbidden_maps_to_403(self): + """Test FORBIDDEN maps to HTTP 403.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.FORBIDDEN] == 403 + + def test_rate_limit_exceeded_maps_to_429(self): + """Test RATE_LIMIT_EXCEEDED maps to HTTP 429.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.RATE_LIMIT_EXCEEDED] == 429 + + def test_validation_error_maps_to_400(self): + """Test VALIDATION_ERROR maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.VALIDATION_ERROR] == 400 + + def test_parameter_invalid_maps_to_400(self): + """Test PARAMETER_INVALID maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.PARAMETER_INVALID] == 400 + + def test_missing_required_field_maps_to_400(self): + """Test MISSING_REQUIRED_FIELD maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.MISSING_REQUIRED_FIELD] == 400 + + def test_file_too_large_maps_to_413(self): + """Test FILE_TOO_LARGE maps to HTTP 413.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.FILE_TOO_LARGE] == 413 + + +class TestErrorCodeFormat: + """Test class for error code format consistency.""" + + def test_all_dify_codes_start_with_116(self): + """Test all Dify error codes start with 116.""" + dify_codes = [ + ErrorCode.DIFY_SERVICE_ERROR, + ErrorCode.DIFY_CONFIG_INVALID, + ErrorCode.DIFY_CONNECTION_ERROR, + ErrorCode.DIFY_AUTH_ERROR, + ErrorCode.DIFY_RATE_LIMIT, + ErrorCode.DIFY_RESPONSE_ERROR, + ] + for code in dify_codes: + assert str(code.value).startswith("116"), f"{code} should start with 116" + + def test_auth_codes_start_with_102(self): + """Test auth error codes start with 102.""" + auth_codes = [ + ErrorCode.UNAUTHORIZED, + ErrorCode.TOKEN_EXPIRED, + ErrorCode.TOKEN_INVALID, + ErrorCode.SIGNATURE_INVALID, + ErrorCode.FORBIDDEN, + ] + for code in auth_codes: + assert str(code.value).startswith("102"), f"{code} should start with 102" + + def test_system_codes_start_with_101(self): + """Test system error codes start with 101.""" + system_codes = [ + ErrorCode.UNKNOWN_ERROR, + ErrorCode.SERVICE_UNAVAILABLE, + ErrorCode.DATABASE_ERROR, + ErrorCode.TIMEOUT, + ErrorCode.INTERNAL_ERROR, + ] + for code in system_codes: + assert str(code.value).startswith("101"), f"{code} should start with 101" + + +class TestErrorCodeIntEnum: + """Test that ErrorCode properly inherits from int and Enum.""" + + def test_error_code_is_int(self): + """Test ErrorCode values can be used as integers.""" + assert isinstance(ErrorCode.DIFY_AUTH_ERROR.value, int) + assert ErrorCode.DIFY_AUTH_ERROR.value + 1 == 116104 + + def test_error_code_comparison(self): + """Test ErrorCode can be compared with integers.""" + assert ErrorCode.DIFY_AUTH_ERROR == 116103 + assert ErrorCode.DIFY_AUTH_ERROR.value == 116103 + + def test_error_code_in_conditional(self): + """Test ErrorCode can be used in conditionals.""" + code = ErrorCode.DIFY_AUTH_ERROR + if code == 116103: + assert True + else: + assert False diff --git a/test/backend/test_exception_handler.py b/test/backend/test_exception_handler.py new file mode 100644 index 000000000..26e609514 --- /dev/null +++ b/test/backend/test_exception_handler.py @@ -0,0 +1,316 @@ +""" +Unit tests for Exception Handler Middleware. + +Tests the ExceptionHandlerMiddleware class and helper functions +for centralized error handling in the FastAPI application. +""" +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse, Response + +from backend.middleware.exception_handler import ( + ExceptionHandlerMiddleware, + _http_status_to_error_code, + create_error_response, + create_success_response, +) +from backend.consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from backend.consts.exceptions import AppException + + +class TestHttpStatusToErrorCode: + """Test class for _http_status_to_error_code function.""" + + def test_maps_400_to_validation_error(self): + """Test that HTTP 400 maps to VALIDATION_ERROR.""" + assert _http_status_to_error_code(400) == ErrorCode.VALIDATION_ERROR + + def test_maps_401_to_unauthorized(self): + """Test that HTTP 401 maps to UNAUTHORIZED.""" + assert _http_status_to_error_code(401) == ErrorCode.UNAUTHORIZED + + def test_maps_403_to_forbidden(self): + """Test that HTTP 403 maps to FORBIDDEN.""" + assert _http_status_to_error_code(403) == ErrorCode.FORBIDDEN + + def test_maps_404_to_resource_not_found(self): + """Test that HTTP 404 maps to RESOURCE_NOT_FOUND.""" + assert _http_status_to_error_code(404) == ErrorCode.RESOURCE_NOT_FOUND + + def test_maps_429_to_rate_limit_exceeded(self): + """Test that HTTP 429 maps to RATE_LIMIT_EXCEEDED.""" + assert _http_status_to_error_code(429) == ErrorCode.RATE_LIMIT_EXCEEDED + + def test_maps_500_to_internal_error(self): + """Test that HTTP 500 maps to INTERNAL_ERROR.""" + assert _http_status_to_error_code(500) == ErrorCode.INTERNAL_ERROR + + def test_maps_502_to_service_unavailable(self): + """Test that HTTP 502 maps to SERVICE_UNAVAILABLE.""" + assert _http_status_to_error_code(502) == ErrorCode.SERVICE_UNAVAILABLE + + def test_maps_503_to_service_unavailable(self): + """Test that HTTP 503 maps to SERVICE_UNAVAILABLE.""" + assert _http_status_to_error_code(503) == ErrorCode.SERVICE_UNAVAILABLE + + def test_unknown_status_returns_unknown_error(self): + """Test that unknown HTTP status codes map to UNKNOWN_ERROR.""" + assert _http_status_to_error_code(418) == ErrorCode.UNKNOWN_ERROR + assert _http_status_to_error_code(599) == ErrorCode.UNKNOWN_ERROR + + +class TestCreateErrorResponse: + """Test class for create_error_response function.""" + + def test_create_error_response_default(self): + """Test creating error response with default values.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + + assert response.status_code == 401 + assert response.body is not None + + def test_create_error_response_custom_message(self): + """Test creating error response with custom message.""" + custom_message = "Custom error message" + response = create_error_response( + ErrorCode.DIFY_AUTH_ERROR, + message=custom_message + ) + + assert response.status_code == 401 + + def test_create_error_response_with_trace_id(self): + """Test creating error response with trace ID.""" + trace_id = "test-trace-id-123" + response = create_error_response( + ErrorCode.DIFY_AUTH_ERROR, + trace_id=trace_id + ) + + assert response.status_code == 401 + + def test_create_error_response_with_details(self): + """Test creating error response with additional details.""" + details = {"field": "api_key", "issue": "invalid format"} + response = create_error_response( + ErrorCode.DIFY_CONFIG_INVALID, + details=details + ) + + assert response.status_code == 401 + + def test_create_error_response_custom_http_status(self): + """Test creating error response with custom HTTP status.""" + response = create_error_response( + ErrorCode.DIFY_SERVICE_ERROR, + http_status=502 + ) + + assert response.status_code == 502 + + def test_create_error_response_dify_auth_error(self): + """Test creating error response for DIFY_AUTH_ERROR.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + + assert response.status_code == 401 + + def test_create_error_response_dify_config_invalid(self): + """Test creating error response for DIFY_CONFIG_INVALID.""" + response = create_error_response(ErrorCode.DIFY_CONFIG_INVALID) + + assert response.status_code == 401 + + def test_create_error_response_dify_rate_limit(self): + """Test creating error response for DIFY_RATE_LIMIT.""" + response = create_error_response(ErrorCode.DIFY_RATE_LIMIT) + + assert response.status_code == 429 + + def test_create_error_response_validation_error(self): + """Test creating error response for VALIDATION_ERROR.""" + response = create_error_response(ErrorCode.VALIDATION_ERROR) + + assert response.status_code == 400 + + def test_create_error_response_token_expired(self): + """Test creating error response for TOKEN_EXPIRED.""" + response = create_error_response(ErrorCode.TOKEN_EXPIRED) + + assert response.status_code == 401 + + +class TestCreateSuccessResponse: + """Test class for create_success_response function.""" + + def test_create_success_response_default(self): + """Test creating success response with default values.""" + response = create_success_response() + + assert response.status_code == 200 + + def test_create_success_response_with_data(self): + """Test creating success response with data.""" + data = {"key": "value"} + response = create_success_response(data=data) + + assert response.status_code == 200 + + def test_create_success_response_custom_message(self): + """Test creating success response with custom message.""" + response = create_success_response(message="Operation successful") + + assert response.status_code == 200 + + def test_create_success_response_with_trace_id(self): + """Test creating success response with trace ID.""" + trace_id = "test-trace-id-456" + response = create_success_response(trace_id=trace_id) + + assert response.status_code == 200 + + def test_create_success_response_all_params(self): + """Test creating success response with all parameters.""" + data = {"result": "ok"} + message = "Success" + trace_id = "trace-789" + response = create_success_response( + data=data, + message=message, + trace_id=trace_id + ) + + assert response.status_code == 200 + + +class TestExceptionHandlerMiddleware: + """Test class for ExceptionHandlerMiddleware.""" + + @pytest.mark.asyncio + async def test_dispatch_normal_request(self): + """Test that normal requests pass through without error.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + mock_response = MagicMock(spec=Response) + mock_call_next = AsyncMock(return_value=mock_response) + + response = await middleware.dispatch(mock_request, mock_call_next) + + mock_call_next.assert_called_once_with(mock_request) + assert response == mock_response + + @pytest.mark.asyncio + async def test_dispatch_app_exception(self): + """Test handling of AppException.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate AppException being raised + app_exception = AppException( + ErrorCode.DIFY_AUTH_ERROR, + "Dify authentication failed" + ) + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_dispatch_http_exception(self): + """Test handling of FastAPI HTTPException.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate HTTPException being raised + http_exception = HTTPException(status_code=404, detail="Not found") + mock_call_next = AsyncMock(side_effect=http_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_dispatch_generic_exception(self): + """Test handling of generic exceptions.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate generic exception being raised + generic_exception = RuntimeError("Something went wrong") + mock_call_next = AsyncMock(side_effect=generic_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + # Should return 500 with internal error code + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_trace_id_generated(self): + """Test that trace ID is generated for each request.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + mock_response = MagicMock(spec=Response) + mock_call_next = AsyncMock(return_value=mock_response) + + response = await middleware.dispatch(mock_request, mock_call_next) + + # Verify trace_id was set on request.state + assert hasattr(mock_request.state, 'trace_id') + + @pytest.mark.asyncio + async def test_app_exception_with_details(self): + """Test handling of AppException with details.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # AppException with details + app_exception = AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Invalid configuration", + details={"field": "api_key"} + ) + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_different_error_codes_map_to_correct_status(self): + """Test that different error codes produce correct HTTP status.""" + test_cases = [ + (ErrorCode.TOKEN_EXPIRED, 401), + (ErrorCode.TOKEN_INVALID, 401), + (ErrorCode.FORBIDDEN, 403), + (ErrorCode.RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.VALIDATION_ERROR, 400), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + for error_code, expected_status in test_cases: + app_exception = AppException(error_code, "Test error") + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == expected_status, \ + f"Expected {expected_status} for {error_code}, got {response.status_code}"