Files
LEDMatrix/src/web_interface/errors.py
T
ChuckandClaude Opus 5.5 4e61d7248a refactor(web): one error-response path for api_v3 (#624)
* refactor(web): answer unhandled api_v3 errors from one blueprint handler

Fifty-three api_v3 routes ended in a copy of the same catch-all: log the
traceback, return {status, "An error occurred; see logs for details",
details: describe_exception(e)} with a 500. They are replaced by one
errorhandler on the api_v3 blueprint that returns exactly that body.

It lives on the blueprint rather than falling through to app.py's global
handler because the two answers differ: the global one adds
error_code: UNKNOWN_ERROR, and api_client.js sends a body with an
error_code to the error modal and one without to a plain toast. A
blueprint handler also gives tests that mount api_v3 on a bare Flask app
the same answer the real app gives.

Only handlers that were byte-for-byte that shape were removed (matched on
the AST, and each rewritten function re-parsed and compared). Handlers
with their own message, extra keys, operation-history records or cleanup
stay, as does execute_plugin_action's step-1 handler, which sits inside
an `except subprocess.TimeoutExpired` arm that would otherwise turn a
plugin's timeout into a 408.

HTTPExceptions raised inside a route go back as themselves in the global
handler's 4xx shape. Where a removed catch-all used to swallow one (only
delete_plugin_asset's non-silent get_json() is reachable), a malformed
request now gets its 415/400 instead of a 500.

Most of the diff is re-indentation from unwrapping the try blocks;
`git diff -w` shows the real change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): plugin action errors name the real failure, not UnboundLocalError

execute_plugin_action bound a local `logger` in its JSON-parsing arm,
which made `logger` local to the whole function. Every other
`logger.error` in it then raised UnboundLocalError, so a failing OAuth
step-1 script was reported as "UnboundLocalError: cannot access local
variable 'logger'" -- from the step-1 handler, and before the previous
commit from the route's outer catch-all too. Use the module logger.

Found by comparing every api_v3 route's forced-failure response before
and after the catch-all consolidation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): drop the error category and exception-name code guessing

WebInterfaceError derived an ErrorCategory from every error code and put
it in each structured error body as `error_category`. Nothing reads it:
not the web UI (static/ and templates/), not the tests beyond the ones
pinning the mapping itself, and not any plugin in ledmatrix-plugins. The
enum, the inference table and the JSON key go.

from_exception() could also guess an error code from the exception's
class name ("Config" -> CONFIG_LOAD_FAILED, and so on). Every caller
passes a code, so the guess never ran; error_code is now required.

suggested_fixes stays: the error dialog in static/v3/js/utils/
error_handler.js lists them.

The REST reference loses error_category and says what an unanticipated
exception in an /api/v3 route answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): one call for the from_exception error responses

Nine plugin routes built a structured error by hand:

    from src.web_interface.errors import WebInterfaceError
    error = WebInterfaceError.from_exception(e, ErrorCode.X)
    return error_response(error.error_code, error.message,
                          details=error.details, context=error.context,
                          status_code=500)

That is now exception_error_response(e, ErrorCode.X) in api_helpers, so
error_response() is the only structured-error entry point the routes
use. The three operation-history routes never passed the context, and
with_context=False keeps their bodies exactly as they were; a test
compares the helper against the hand-written pair for both forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): one api_v3 error-response path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:53:19 -04:00

231 lines
8.4 KiB
Python

"""
Structured error handling for web interface.
Provides error codes and consistent error response formatting.
"""
from enum import Enum
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
class ErrorCode(Enum):
"""Error codes for specific error types."""
# Configuration errors
CONFIG_SAVE_FAILED = "CONFIG_SAVE_FAILED"
CONFIG_LOAD_FAILED = "CONFIG_LOAD_FAILED"
CONFIG_VALIDATION_FAILED = "CONFIG_VALIDATION_FAILED"
CONFIG_ROLLBACK_FAILED = "CONFIG_ROLLBACK_FAILED"
# Plugin errors
PLUGIN_NOT_FOUND = "PLUGIN_NOT_FOUND"
PLUGIN_INSTALL_FAILED = "PLUGIN_INSTALL_FAILED"
PLUGIN_UPDATE_FAILED = "PLUGIN_UPDATE_FAILED"
PLUGIN_UNINSTALL_FAILED = "PLUGIN_UNINSTALL_FAILED"
PLUGIN_LOAD_FAILED = "PLUGIN_LOAD_FAILED"
PLUGIN_OPERATION_CONFLICT = "PLUGIN_OPERATION_CONFLICT"
# Validation errors
VALIDATION_ERROR = "VALIDATION_ERROR"
SCHEMA_VALIDATION_FAILED = "SCHEMA_VALIDATION_FAILED"
INVALID_INPUT = "INVALID_INPUT"
# Network errors
NETWORK_ERROR = "NETWORK_ERROR"
API_ERROR = "API_ERROR"
TIMEOUT = "TIMEOUT"
# Permission errors
PERMISSION_DENIED = "PERMISSION_DENIED"
FILE_PERMISSION_ERROR = "FILE_PERMISSION_ERROR"
# System errors
SYSTEM_ERROR = "SYSTEM_ERROR"
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
# Unknown errors
UNKNOWN_ERROR = "UNKNOWN_ERROR"
@dataclass
class WebInterfaceError:
"""
Structured error for web interface responses.
Provides consistent error format with error codes, messages, and
context.
"""
error_code: ErrorCode
message: str
details: Optional[str] = None
context: Optional[Dict[str, Any]] = None
suggested_fixes: Optional[List[str]] = None
original_error: Optional[Exception] = None
def __init__(
self,
error_code: ErrorCode,
message: str,
details: Optional[str] = None,
context: Optional[Dict[str, Any]] = None,
suggested_fixes: Optional[List[str]] = None,
original_error: Optional[Exception] = None
):
self.error_code = error_code
self.message = message
self.details = details
self.context = context or {}
# `is None`, not truthiness: an explicit [] means "this caller has
# no suggestions to offer", which the default list would override.
self.suggested_fixes = (
suggested_fixes if suggested_fixes is not None
else self._get_default_suggestions(error_code))
self.original_error = original_error
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
"""Get default suggested fixes for error code."""
suggestions_map = {
ErrorCode.CONFIG_SAVE_FAILED: [
"Check file permissions on config directory",
"Check available disk space",
"Verify config file is not locked by another process"
],
ErrorCode.CONFIG_LOAD_FAILED: [
"Check config file exists and is readable",
"Verify config file is valid JSON",
"Check file permissions"
],
ErrorCode.CONFIG_VALIDATION_FAILED: [
"Review validation errors above",
"Check config against schema",
"Verify all required fields are present"
],
ErrorCode.PLUGIN_NOT_FOUND: [
"Verify plugin is installed",
"Check plugin ID is correct",
"Refresh plugin list"
],
ErrorCode.PLUGIN_INSTALL_FAILED: [
"Check internet connection",
"Verify plugin repository URL is correct",
"Check available disk space",
"Review plugin installation logs"
],
ErrorCode.PLUGIN_OPERATION_CONFLICT: [
"Wait for current operation to complete",
"Cancel conflicting operation if needed",
"Check operation status"
],
ErrorCode.VALIDATION_ERROR: [
"Review validation errors",
"Check input format and types",
"Verify required fields are provided"
],
ErrorCode.PERMISSION_DENIED: [
"Check file/directory permissions",
"Verify user has required access",
"Check if running with correct user"
],
ErrorCode.NETWORK_ERROR: [
"Check internet connection",
"Verify API endpoint is accessible",
"Check firewall settings"
],
ErrorCode.TIMEOUT: [
"Retry the operation",
"Check network connection",
"Verify service is responding"
],
}
return suggestions_map.get(error_code, ["Review error details and try again"])
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary for JSON response."""
result = {
"status": "error",
"error_code": self.error_code.value,
"message": self.message,
}
if self.details:
result["details"] = self.details
if self.context:
result["context"] = self.context
if self.suggested_fixes:
result["suggested_fixes"] = self.suggested_fixes
return result
@classmethod
def from_exception(
cls,
exception: Exception,
error_code: ErrorCode,
context: Optional[Dict[str, Any]] = None
) -> 'WebInterfaceError':
"""
Create WebInterfaceError from an exception.
Args:
exception: Exception to convert
error_code: The error code to report
context: Optional additional context
"""
# Build context
error_context = context or {}
error_context['exception_type'] = type(exception).__name__
return cls(
error_code=error_code,
message=cls._safe_message(error_code),
details=cls._get_exception_details(exception),
context=error_context,
original_error=exception
)
@classmethod
def _safe_message(cls, error_code: ErrorCode) -> str:
"""Get a safe, user-facing message for an error code."""
messages = {
ErrorCode.CONFIG_SAVE_FAILED: "Failed to save configuration",
ErrorCode.CONFIG_LOAD_FAILED: "Failed to load configuration",
ErrorCode.CONFIG_VALIDATION_FAILED: "Configuration validation failed",
ErrorCode.CONFIG_ROLLBACK_FAILED: "Failed to rollback configuration",
ErrorCode.PLUGIN_NOT_FOUND: "Plugin not found",
ErrorCode.PLUGIN_INSTALL_FAILED: "Failed to install plugin",
ErrorCode.PLUGIN_UPDATE_FAILED: "Failed to update plugin",
ErrorCode.PLUGIN_UNINSTALL_FAILED: "Failed to uninstall plugin",
ErrorCode.PLUGIN_LOAD_FAILED: "Failed to load plugin",
ErrorCode.PLUGIN_OPERATION_CONFLICT: "A plugin operation is already in progress",
ErrorCode.VALIDATION_ERROR: "Validation error",
ErrorCode.SCHEMA_VALIDATION_FAILED: "Schema validation failed",
ErrorCode.INVALID_INPUT: "Invalid input",
ErrorCode.NETWORK_ERROR: "Network error",
ErrorCode.API_ERROR: "API error",
ErrorCode.TIMEOUT: "Operation timed out",
ErrorCode.PERMISSION_DENIED: "Permission denied",
ErrorCode.FILE_PERMISSION_ERROR: "File permission error",
ErrorCode.SYSTEM_ERROR: "A system error occurred",
ErrorCode.SERVICE_UNAVAILABLE: "Service unavailable",
ErrorCode.UNKNOWN_ERROR: "An unexpected error occurred",
}
return messages.get(error_code, "An unexpected error occurred")
@classmethod
def _get_exception_details(cls, exception: Exception) -> Optional[str]:
"""Get additional details from exception."""
if hasattr(exception, 'context') and isinstance(exception.context, dict):
# Extract relevant details from exception context
details_parts = []
for key, value in exception.context.items():
if key not in ['exception_type']:
details_parts.append(f"{key}: {value}")
if details_parts:
return "; ".join(details_parts)
return None