Skip to main content
Glama
errors.py34.1 kB
"""Unified error handling for SOIL-2 with structured context.""" from pathlib import Path from typing import Any, Dict, List, Optional, Type, Union, NoReturn, Tuple from datetime import datetime from enum import Enum import traceback import logging import time import typer class ErrorSeverity(str, Enum): """Error severity levels for classification and handling.""" INFO = "info" WARNING = "warning" ERROR = "error" CRITICAL = "critical" class ErrorCategory(str, Enum): """Error categories for structured handling.""" VALIDATION = "validation" CONFIGURATION = "configuration" DATABASE = "database" NETWORK = "network" FILESYSTEM = "filesystem" AUTHENTICATION = "authentication" AUTHORIZATION = "authorization" RUNTIME = "runtime" EXTERNAL = "external" NOT_FOUND = "not_found" CONFLICT = "conflict" RESOURCE_LIMIT = "resource_limit" class MnemosyneError(Exception): """ Base exception for all Mnemosyne operations with structured context. Provides consistent error handling across all components with contextual information for debugging and user feedback. """ def __init__( self, message: str, *, context: Optional[Dict[str, Any]] = None, severity: ErrorSeverity = ErrorSeverity.ERROR, category: ErrorCategory = ErrorCategory.RUNTIME, operation: Optional[str] = None, component: Optional[str] = None, user_message: Optional[str] = None, help_text: Optional[str] = None, error_code: Optional[str] = None, ): """ Initialize structured error. Args: message: Technical error message for developers context: Additional context data for debugging severity: Error severity level category: Error category for classification operation: Operation that failed (e.g., "query", "load_file") component: Component where error occurred (e.g., "cli", "server", "store") user_message: User-friendly error message help_text: Suggested resolution or help information error_code: Unique error code for documentation reference """ super().__init__(message) self.message = message self.context = context or {} self.severity = severity self.category = category self.operation = operation self.component = component self.user_message = user_message or message self.help_text = help_text self.error_code = error_code self.exit_code: Optional[int] = None # Set by CLI error handling self.timestamp = datetime.utcnow() self.traceback_info = traceback.format_stack()[:-1] # Exclude current frame # Add automatic context self.context.update( { "timestamp": self.timestamp.isoformat(), "severity": self.severity.value, "category": self.category.value, } ) if self.operation: self.context["operation"] = self.operation if self.component: self.context["component"] = self.component def __str__(self) -> str: """String representation for logging and debugging.""" if self.operation and self.component: return f"[{self.component.upper()}] {self.operation} failed: {self.message}" elif self.operation: return f"Operation '{self.operation}' failed: {self.message}" return self.message def get_user_message(self) -> str: """Get user-friendly error message.""" if self.user_message != self.message: return self.user_message # Generate user-friendly message from technical details if self.operation: return f"Failed to {self.operation}. {self.help_text if self.help_text else ''}" return self.message def get_context_for_logging(self) -> Dict[str, Any]: """Get context information for structured logging.""" log_context = self.context.copy() log_context.update( { "error_type": self.__class__.__name__, "error_message": self.message, "user_message": self.get_user_message(), } ) if self.error_code: log_context["error_code"] = self.error_code if self.help_text: log_context["help_text"] = self.help_text return log_context def with_context(self, **additional_context: Any) -> "MnemosyneError": """Add additional context to existing error.""" self.context.update(additional_context) return self def is_user_error(self) -> bool: """Check if this is a user error vs system error.""" return self.category in [ ErrorCategory.VALIDATION, ErrorCategory.CONFIGURATION, ErrorCategory.AUTHENTICATION, ErrorCategory.AUTHORIZATION, ] # Component-specific exception classes class DatabaseError(MnemosyneError): """Base class for database-related errors.""" def __init__( self, message: str, *, operation: str, database_path: Optional[str] = None, query: Optional[str] = None, component: Optional[str] = None, category: Optional[ErrorCategory] = None, severity: Optional[ErrorSeverity] = None, **kwargs: Any, # Justified: Parent MnemosyneError accepts arbitrary context fields ) -> None: super().__init__( message, operation=operation, component=component or "store", category=category or ErrorCategory.DATABASE, severity=severity or ErrorSeverity.ERROR, # Provide default when None **kwargs, ) if database_path: self.context["database_path"] = database_path if query: # Truncate long queries for logging self.context["query"] = query[:500] + "..." if len(query) > 500 else query class QueryError(DatabaseError): """SPARQL query execution errors.""" def __init__( self, message: str, *, query: str, query_type: Optional[str] = None, operation: Optional[str] = None, # Allow operation override from caller user_message: Optional[str] = None, # Allow user_message override help_text: Optional[str] = None, # Allow help_text override **kwargs: Any, # Justified: Parent DatabaseError accepts arbitrary context fields ) -> None: super().__init__( message, operation=operation or "execute_query", # Use provided operation or default query=query, user_message=user_message or "Database query failed", help_text=help_text or "Check the SPARQL syntax and ensure the database is accessible", error_code="DB001", **kwargs, ) if query_type: self.context["query_type"] = query_type class UpdateError(DatabaseError): """SPARQL update operation errors.""" def __init__( self, message: str, *, update: str, operation: Optional[str] = None, # Allow operation override from caller **kwargs: Any, ) -> None: # Justified: Parent DatabaseError accepts arbitrary context fields super().__init__( message, operation=operation or "execute_update", # Use provided operation or default query=update, user_message="Database update failed", help_text="Check the SPARQL UPDATE syntax and ensure write permissions", error_code="DB002", **kwargs, ) class LoadError(DatabaseError): """Data loading errors.""" def __init__( self, message: str, *, source: str, format: Optional[str] = None, **kwargs: Any, # Justified: Parent DatabaseError accepts arbitrary context fields ) -> None: super().__init__( message, operation="load_data", user_message="Failed to load data into database", help_text="Check the data format and ensure the file is accessible", error_code="DB003", **kwargs, ) self.context.update({"data_source": source, "data_format": format}) class ExportError(DatabaseError): """Data export errors.""" def __init__( self, message: str, *, format: str, destination: Optional[str] = None, **kwargs: Any, # Justified: Parent DatabaseError accepts arbitrary context fields ) -> None: super().__init__( message, operation="export_data", user_message="Failed to export database", help_text="Check the export format and ensure write permissions", error_code="DB004", **kwargs, ) self.context.update({"export_format": format, "destination": destination}) class ConfigurationError(MnemosyneError): """Configuration-related errors.""" def __init__( self, message: str, *, config_key: Optional[str] = None, config_file: Optional[str] = None, component: Optional[str] = None, category: Optional[ErrorCategory] = None, severity: Optional[ErrorSeverity] = None, user_message: Optional[str] = None, help_text: Optional[str] = None, **kwargs: Any, # Justified: kwargs can contain arbitrary structured data ) -> None: super().__init__( message, component=component or "config", category=category or ErrorCategory.CONFIGURATION, severity=severity or ErrorSeverity.ERROR, user_message=user_message or "Configuration error", help_text=help_text or "Check the configuration file and environment variables", error_code="CFG001", **kwargs, ) if config_key: self.context["config_key"] = config_key if config_file: self.context["config_file"] = config_file class ValidationError(ConfigurationError): """Configuration validation errors.""" def __init__( self, message: str, *, field: str, value: Any, expected_type: Optional[ Type[Any] ] = None, # Justified: Type parameter needed for generic Type **kwargs: Any, # Justified: kwargs can contain arbitrary structured data ) -> None: super().__init__( message, config_key=field, user_message=f"Invalid configuration value for '{field}'", help_text="Check the configuration documentation for valid values", error_code="CFG002", **kwargs, ) self.context.update( { "field": field, "invalid_value": str(value), "expected_type": expected_type.__name__ if expected_type else None, } ) class SerializationError(MnemosyneError): """Data serialization/deserialization errors.""" def __init__( self, message: str, *, context: Optional[Dict[str, Any]] = None, operation: str = "serialize", data_type: Optional[str] = None, **kwargs: Any, ) -> None: super().__init__( message, context=context, category=ErrorCategory.RUNTIME, severity=ErrorSeverity.ERROR, operation=operation, component="codec", user_message="Data serialization failed", help_text="Check data format and codec compatibility", error_code="SER001", **kwargs, ) if data_type: self.context["data_type"] = data_type class ServiceDiscoveryError(MnemosyneError): """ Error during service discovery operations. Used for Docker service discovery, endpoint resolution, and health checks. """ def __init__( self, message: str, *, service_name: Optional[str] = None, endpoint: Optional[str] = None, **kwargs, ): super().__init__( message, component="service_discovery", category=ErrorCategory.NETWORK, user_message="Service discovery failed", help_text="Check Docker service availability and network connectivity", error_code="SVC001", **kwargs, ) if service_name: self.context["service_name"] = service_name if endpoint: self.context["endpoint"] = endpoint class CLIError(MnemosyneError): """CLI-specific errors.""" def __init__( self, message: str, *, command: Optional[str] = None, exit_code: int = 1, user_message: Optional[str] = None, # Allow user_message override **kwargs: Any, # Justified: kwargs can contain arbitrary structured data ) -> None: super().__init__( message, component="cli", category=ErrorCategory.RUNTIME, user_message=user_message or message, # CLI errors are already user-friendly **kwargs, ) self.exit_code = exit_code if command: self.context["command"] = command class FileSystemError(CLIError): """File system operation errors.""" def __init__( self, message: str, *, path: str, operation: str, **kwargs: Any, # Justified: kwargs can contain arbitrary structured data ) -> None: super().__init__(message, operation=operation, error_code="FS001", **kwargs) self.context.update({"file_path": path, "fs_operation": operation}) class ServerError(MnemosyneError): """Server and API errors.""" def __init__( self, message: str, *, http_status: int = 500, endpoint: Optional[str] = None, **kwargs: Any, # Justified: kwargs can contain arbitrary structured data ) -> None: super().__init__( message, component="server", category=ErrorCategory.NETWORK, user_message="Server error occurred", **kwargs, ) self.http_status = http_status self.context.update({"http_status": http_status, "endpoint": endpoint}) class APIError(ServerError): """API-specific errors.""" def __init__( self, message: str, *, http_status: int = 400, request_id: Optional[str] = None, **kwargs: Any, # Justified: kwargs can contain arbitrary structured data ) -> None: super().__init__( message, http_status=http_status, user_message="API request failed", help_text="Check the API documentation for correct usage", error_code="API001", **kwargs, ) if request_id: self.context["request_id"] = request_id class RetryableError(MnemosyneError): """Errors that can be retried with backoff strategy.""" def __init__( self, message: str, *, operation: str, retry_count: int = 0, max_retries: int = 3, **kwargs: Any, ) -> None: super().__init__( message, operation=operation, category=ErrorCategory.RUNTIME, severity=ErrorSeverity.WARNING, user_message=f"Operation failed (attempt {retry_count + 1}/{max_retries + 1})", help_text="This operation will be retried automatically", error_code="RETRY001", **kwargs, ) self.retry_count = retry_count self.max_retries = max_retries self.context.update( { "retry_count": retry_count, "max_retries": max_retries, "can_retry": self.can_retry(), } ) def can_retry(self) -> bool: """Check if this error can be retried.""" return self.retry_count < self.max_retries def increment_retry(self) -> "RetryableError": """Create new RetryableError with incremented retry count.""" return RetryableError( self.message, operation=self.operation or "unknown", retry_count=self.retry_count + 1, max_retries=self.max_retries, context=self.context.copy(), ) # Error Handler Protocols class CLIErrorHandler: """ Unified CLI error handling with proper NoReturn typing. Fixes MyPy missing return statement issues while maintaining consistent CLI error behavior. """ def __init__(self, logger: Optional[logging.Logger] = None): self.logger = logger or logging.getLogger(__name__) def handle_error(self, error: Exception, operation: str = "operation") -> NoReturn: """ Handle CLI errors with proper NoReturn typing. Args: error: Exception that occurred operation: Operation description for context Raises: typer.Exit: Always exits with appropriate code """ exit_code = 1 if isinstance(error, MnemosyneError): # Use structured error information user_message = error.get_user_message() exit_code = error.exit_code or 1 # Use 1 if exit_code is None # Log with structured context self.logger.error( f"CLI {operation} failed", extra=error.get_context_for_logging() ) # Display user-friendly message typer.echo(f"❌ {user_message}", err=True) if error.help_text: typer.echo(f"💡 {error.help_text}", err=True) else: # Handle non-SOIL exceptions self.logger.error(f"CLI {operation} failed", exception=error) typer.echo(f"❌ Failed to {operation}: {error}", err=True) raise typer.Exit(code=exit_code) def handle_mnemosyne_error(self, error: MnemosyneError) -> NoReturn: """Handle SOIL-specific errors.""" self.handle_error(error, error.operation or "operation") def validate_path_exists(self, path: Path, description: str = "Path") -> None: """ Validate path exists with proper error handling. Args: path: Path to validate description: Description for error messages Raises: typer.Exit: If path doesn't exist """ if not path.exists(): error = FileSystemError( f"{description} not found: {path}", path=str(path), operation="validate_path", user_message=f"{description} does not exist", help_text=f"Ensure the {description.lower()} exists and is accessible", ) self.handle_mnemosyne_error(error) # Global CLI error handler cli_error_handler = CLIErrorHandler() # Legacy compatibility functions def cli_error(message: str, exit_code: int = 1) -> NoReturn: """ Display CLI error and exit (compatibility function). Args: message: Error message to display exit_code: Exit code (default: 1) Raises: typer.Exit: Always exits """ error = CLIError(message, exit_code=exit_code) cli_error_handler.handle_mnemosyne_error(error) def handle_exception(operation: str, error: Exception, exit_code: int = 1) -> NoReturn: """ Handle exception with operation context (compatibility function). Args: operation: Operation description error: Exception that occurred exit_code: Exit code (default: 1) Raises: typer.Exit: Always exits """ # Format error message for legacy compatibility error_message = f"Failed to {operation}: {error}" cli_error(error_message, exit_code) def validate_path_exists(path: Path, description: str = "Path") -> None: """Validate that a path exists, error if not.""" if not path.exists(): cli_error(f"{description} not found: {path}") def validate_path_not_exists( path: Path, description: str = "Path", allow_force: bool = False ) -> None: """Validate that a path doesn't exist, with optional force override.""" if path.exists() and not allow_force: cli_error(f"{description} already exists at {path}. Use --force to overwrite.") # Server Error Handling class ServerErrorHandler: """Unified server error handling for FastAPI.""" def __init__(self, logger: Optional[logging.Logger] = None): self.logger = logger or logging.getLogger(__name__) async def handle_mnemosyne_error( self, request: Any, error: MnemosyneError # Request object ) -> Tuple[Dict[str, Any], int]: # Return tuple as server expects """ Handle SOIL errors in FastAPI endpoints. Args: request: FastAPI request object error: SOIL error that occurred Returns: Tuple of (error_response_dict, status_code) """ trace_id = getattr(getattr(request, "state", None), "trace_id", None) log_context = error.get_context_for_logging() if trace_id: log_context["trace_id"] = trace_id # Log with structured context self.logger.error( f"API error in {getattr(getattr(request, 'url', {}), 'path', 'unknown') if hasattr(request, 'url') else 'unknown'}", extra=log_context, ) # Determine HTTP status code if isinstance(error, ServerError): status_code = error.http_status elif isinstance(error, ValidationError): status_code = 400 elif isinstance(error, ConfigurationError): status_code = 503 elif isinstance(error, DatabaseError): if isinstance(error, QueryError): status_code = 400 # Bad request for query syntax errors else: status_code = 503 # Service unavailable for database connection issues else: status_code = 500 # Prepare error response error_response = { "error": { "type": error.__class__.__name__, "message": error.get_user_message(), "code": error.error_code, "category": error.category.value, "timestamp": error.timestamp.isoformat(), } } if trace_id: error_response["error"]["trace_id"] = trace_id # Add help text for user errors if error.is_user_error() and error.help_text: error_response["error"]["help"] = error.help_text # Add context for debugging (only in development) if ( hasattr(request, "app") and hasattr(request.app, "state") and hasattr(request.app.state, "debug") and request.app.state.debug ): error_response["debug"] = { "operation": error.operation, "component": error.component, "context": error.context, # type: ignore # Justified: context dict has Any values } from fastapi.responses import JSONResponse response = JSONResponse(status_code=status_code, content=error_response) if trace_id: response.headers["X-Trace-ID"] = trace_id return response async def handle_http_exception( self, request: Any, exc: Any # HTTPException ) -> Any: # JSONResponse """ Handle FastAPI HTTP exceptions. Args: request: FastAPI request object exc: HTTPException that occurred Returns: JSONResponse with error details """ from fastapi.responses import JSONResponse trace_id = getattr(getattr(request, "state", None), "trace_id", None) error_response = { "error": { "type": "HTTPException", "message": str(exc.detail), "status_code": exc.status_code, "timestamp": time.time(), } } if trace_id: error_response["error"]["trace_id"] = trace_id log_kwargs = {"extra": {"trace_id": trace_id}} if trace_id else {} self.logger.warning( f"HTTP exception: {exc.status_code} - {exc.detail}", **log_kwargs, ) response = JSONResponse(status_code=exc.status_code, content=error_response) if trace_id: response.headers["X-Trace-ID"] = trace_id return response async def handle_generic_exception( self, request: Any, exc: Exception ) -> Any: # JSONResponse """ Handle unexpected exceptions. Args: request: FastAPI request object exc: Unexpected exception that occurred Returns: JSONResponse with error details """ from fastapi.responses import JSONResponse import traceback trace_id = getattr(getattr(request, "state", None), "trace_id", None) error_response = { "error": { "type": "InternalServerError", "message": "An unexpected error occurred", "timestamp": time.time(), } } if trace_id: error_response["error"]["trace_id"] = trace_id # Log the full traceback for debugging log_kwargs = {"extra": {"trace_id": trace_id}} if trace_id else {} self.logger.error(f"Unexpected exception: {exc}", exc_info=True, **log_kwargs) response = JSONResponse(status_code=500, content=error_response) if trace_id: response.headers["X-Trace-ID"] = trace_id return response def convert_to_http_exception( self, error: MnemosyneError ) -> Any: # Justified: HTTPException type from external library """ Convert SOIL error to HTTPException for compatibility. Args: error: SOIL error to convert Returns: HTTPException with appropriate status and details """ from fastapi import HTTPException if isinstance(error, ServerError): status_code = error.http_status elif isinstance(error, ValidationError): status_code = 400 elif isinstance(error, DatabaseError): status_code = 503 if error.severity == ErrorSeverity.CRITICAL else 500 else: status_code = 500 detail = error.get_user_message() if error.help_text and error.is_user_error(): detail += f" {error.help_text}" return HTTPException(status_code=status_code, detail=detail) # Store Error Handling class StoreErrorHandler: """Unified store error handling with context preservation.""" def __init__(self, component_name: str = "store"): self.component = component_name self.logger = logging.getLogger(__name__) def wrap_operation_error( self, operation: str, error: Exception, context: Optional[Dict[str, Any]] = None ) -> MnemosyneError: """ Wrap operation errors with appropriate SOIL error type. Args: operation: Operation that failed error: Original exception context: Additional context information Returns: Appropriate SOIL error with context """ if isinstance(error, MnemosyneError): # Already a SOIL error, just add context if context: error.with_context(**context) return error # Create appropriate SOIL error based on operation if operation == "query": return QueryError( str(error), query=context.get("query", "unknown") if context else "unknown", ) elif operation == "update": return UpdateError( str(error), update=context.get("update", "unknown") if context else "unknown", ) elif operation in ["load_file", "load_data"]: return LoadError( str(error), source=context.get("source", "unknown") if context else "unknown", format=context.get("format") if context else None, ) elif operation == "export": return ExportError( str(error), format=context.get("format", "unknown") if context else "unknown", destination=context.get("destination") if context else None, ) else: return DatabaseError(str(error), operation=operation) def handle_operation_error( self, operation: str, error: Exception, context: Optional[Dict[str, Any]] = None ) -> None: """ Handle store operation error and re-raise as SOIL error. Args: operation: Operation that failed error: Original exception context: Additional context information Raises: MnemosyneError: Wrapped error with context """ soil_error = self.wrap_operation_error(operation, error, context) # Log the error self.logger.error( f"Store {operation} failed", extra=soil_error.get_context_for_logging() ) raise soil_error from error # Multi-graph specific errors class GraphNotFoundError(MnemosyneError): """Graph not found error.""" def __init__( self, graph_id: str, *, available_graphs: Optional[List[str]] = None, **kwargs: Any, ) -> None: super().__init__( f"Graph '{graph_id}' not found", category=ErrorCategory.NOT_FOUND, operation="graph_access", component="multi_graph_store", user_message=f"The graph '{graph_id}' does not exist. Use GET /graphs to see available graphs.", help_text="Check the graph ID and ensure the graph has been created", error_code="MG001", **kwargs, ) self.context.update({ "graph_id": graph_id, "available_graphs": available_graphs or [] }) class GraphAlreadyExistsError(MnemosyneError): """Graph already exists error.""" def __init__( self, graph_id: str, **kwargs: Any, ) -> None: super().__init__( f"Graph '{graph_id}' already exists", category=ErrorCategory.CONFLICT, operation="create_graph", component="multi_graph_store", user_message=f"Graph '{graph_id}' already exists. Use a different name or delete the existing graph first.", help_text="Choose a different graph ID or delete the existing graph", error_code="MG002", **kwargs, ) self.context.update({"graph_id": graph_id}) class ResourceLimitExceededError(MnemosyneError): """Resource limit exceeded error.""" def __init__( self, message: str, *, limit_type: str = "resource", current_value: Optional[Any] = None, limit_value: Optional[Any] = None, **kwargs: Any, ) -> None: super().__init__( message, category=ErrorCategory.RESOURCE_LIMIT, operation="resource_check", component="multi_graph_store", user_message=f"Resource limit exceeded: {message}", help_text="Delete unused graphs or increase resource limits", error_code="MG003", **kwargs, ) self.context.update({ "limit_type": limit_type, "current_value": current_value, "limit_value": limit_value, }) class InvalidGraphIdError(MnemosyneError): """Invalid graph ID error.""" def __init__( self, graph_id: str, reason: str, **kwargs: Any, ) -> None: super().__init__( f"Invalid graph ID '{graph_id}': {reason}", category=ErrorCategory.VALIDATION, operation="validate_graph_id", component="multi_graph_store", user_message=f"The graph ID '{graph_id}' is invalid: {reason}", help_text="Use alphanumeric characters, hyphens, and underscores only", error_code="MG004", **kwargs, ) self.context.update({ "graph_id": graph_id, "validation_reason": reason }) # File upload specific errors class FileUploadError(MnemosyneError): """File upload specific errors.""" def __init__( self, message: str, *, filename: str, file_size: Optional[int] = None, file_format: Optional[str] = None, **kwargs: Any, ) -> None: super().__init__( message, category=ErrorCategory.VALIDATION, operation="file_upload", component="api_v1", user_message=f"Failed to upload file '{filename}': {message}", help_text="Check file format and size limits", error_code="FU001", **kwargs, ) self.context.update({ "filename": filename, "file_size": file_size, "file_format": file_format, }) # Global error handlers server_error_handler = ServerErrorHandler() store_error_handler = StoreErrorHandler()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sophia-labs/mnemosyne-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server