mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
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>
This commit is contained in:
@@ -9,7 +9,7 @@ from typing import Any, Optional, Dict, Tuple
|
||||
from flask import jsonify, request
|
||||
|
||||
from src.web_interface.error_handler import create_error_response, create_success_response
|
||||
from src.web_interface.errors import ErrorCode
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
def success_response(
|
||||
@@ -75,6 +75,40 @@ def error_response(
|
||||
)
|
||||
|
||||
|
||||
def exception_error_response(
|
||||
exc: Exception,
|
||||
error_code: ErrorCode,
|
||||
*,
|
||||
with_context: bool = True,
|
||||
status_code: int = 500
|
||||
):
|
||||
"""
|
||||
error_response() for a caught exception, built by WebInterfaceError.
|
||||
|
||||
The message is the code's fixed, user-facing one -- never the exception
|
||||
text. `details` comes from the exception's own `context` dict when it has
|
||||
one, and `context` records the exception type. with_context=False leaves
|
||||
the context out, as the operation-history routes always have.
|
||||
|
||||
Args:
|
||||
exc: The exception being reported
|
||||
error_code: Error code
|
||||
with_context: Whether to include the context (exception type)
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
Flask jsonify response with status code
|
||||
"""
|
||||
error = WebInterfaceError.from_exception(exc, error_code)
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context if with_context else None,
|
||||
status_code=status_code
|
||||
)
|
||||
|
||||
|
||||
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||
"""
|
||||
Validate request JSON has required fields.
|
||||
|
||||
@@ -7,9 +7,7 @@ Provides helpers for consistent error responses across API endpoints.
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
from src.web_interface.errors import (
|
||||
WebInterfaceError, ErrorCode, ErrorCategory
|
||||
)
|
||||
from src.web_interface.errors import WebInterfaceError, ErrorCode
|
||||
from src.logging_config import get_logger
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
@@ -72,6 +70,39 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
return text
|
||||
|
||||
|
||||
# What a failure nothing anticipated says. The detail beside it carries the
|
||||
# actual diagnosis; this sentence only points at where the traceback went.
|
||||
UNHANDLED_ERROR_MESSAGE = 'An error occurred; see logs for details'
|
||||
|
||||
|
||||
def unhandled_exception_payload(exc: BaseException) -> dict:
|
||||
"""JSON body for an exception no route handled: status, message, details.
|
||||
|
||||
Deliberately no `error_code`. The plugin API client (api_client.js) passes
|
||||
a body that has one straight to the rich error modal, and wraps one that
|
||||
has none as a plain API_ERROR toast; the api_v3 routes answered this shape
|
||||
from their own catch-alls for years, so the UI is built around it.
|
||||
"""
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': UNHANDLED_ERROR_MESSAGE,
|
||||
'details': describe_exception(exc),
|
||||
}
|
||||
|
||||
|
||||
def http_exception_payload(error) -> dict:
|
||||
"""JSON body for a werkzeug HTTPException (405, 400, 415, 413...).
|
||||
|
||||
Same shape web_interface/app.py's global handler returns, so a 4xx raised
|
||||
inside an api_v3 route reads the same as one raised anywhere else.
|
||||
"""
|
||||
return {
|
||||
'status': 'error',
|
||||
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||
'message': error.description,
|
||||
}
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Structured error handling for web interface.
|
||||
|
||||
Provides error codes, categories, and consistent error response formatting.
|
||||
Provides error codes and consistent error response formatting.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
@@ -9,17 +9,6 @@ from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ErrorCategory(Enum):
|
||||
"""Error categories for classification."""
|
||||
CONFIGURATION = "configuration"
|
||||
PLUGIN = "plugin"
|
||||
VALIDATION = "validation"
|
||||
NETWORK = "network"
|
||||
PERMISSION = "permission"
|
||||
SYSTEM = "system"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""Error codes for specific error types."""
|
||||
# Configuration errors
|
||||
@@ -63,12 +52,11 @@ class WebInterfaceError:
|
||||
"""
|
||||
Structured error for web interface responses.
|
||||
|
||||
Provides consistent error format with error codes, categories,
|
||||
messages, and context.
|
||||
Provides consistent error format with error codes, messages, and
|
||||
context.
|
||||
"""
|
||||
error_code: ErrorCode
|
||||
message: str
|
||||
category: ErrorCategory
|
||||
details: Optional[str] = None
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
suggested_fixes: Optional[List[str]] = None
|
||||
@@ -78,7 +66,6 @@ class WebInterfaceError:
|
||||
self,
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
category: Optional[ErrorCategory] = None,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
suggested_fixes: Optional[List[str]] = None,
|
||||
@@ -86,7 +73,6 @@ class WebInterfaceError:
|
||||
):
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
self.category = category or self._infer_category(error_code)
|
||||
self.details = details
|
||||
self.context = context or {}
|
||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||
@@ -96,25 +82,6 @@ class WebInterfaceError:
|
||||
else self._get_default_suggestions(error_code))
|
||||
self.original_error = original_error
|
||||
|
||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
||||
"""Infer error category from error code."""
|
||||
code_str = error_code.value
|
||||
|
||||
if code_str.startswith("CONFIG_"):
|
||||
return ErrorCategory.CONFIGURATION
|
||||
elif code_str.startswith("PLUGIN_"):
|
||||
return ErrorCategory.PLUGIN
|
||||
elif code_str.startswith("VALIDATION_") or code_str.startswith("SCHEMA_") or code_str == "INVALID_INPUT":
|
||||
return ErrorCategory.VALIDATION
|
||||
elif code_str.startswith("NETWORK_") or code_str == "API_ERROR" or code_str == "TIMEOUT":
|
||||
return ErrorCategory.NETWORK
|
||||
elif code_str.startswith("PERMISSION_") or code_str == "FILE_PERMISSION_ERROR":
|
||||
return ErrorCategory.PERMISSION
|
||||
elif code_str.startswith("SYSTEM_") or code_str == "SERVICE_UNAVAILABLE":
|
||||
return ErrorCategory.SYSTEM
|
||||
else:
|
||||
return ErrorCategory.UNKNOWN
|
||||
|
||||
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
||||
"""Get default suggested fixes for error code."""
|
||||
suggestions_map = {
|
||||
@@ -178,7 +145,6 @@ class WebInterfaceError:
|
||||
result = {
|
||||
"status": "error",
|
||||
"error_code": self.error_code.value,
|
||||
"error_category": self.category.value,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
@@ -197,7 +163,7 @@ class WebInterfaceError:
|
||||
def from_exception(
|
||||
cls,
|
||||
exception: Exception,
|
||||
error_code: Optional[ErrorCode] = None,
|
||||
error_code: ErrorCode,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> 'WebInterfaceError':
|
||||
"""
|
||||
@@ -205,13 +171,9 @@ class WebInterfaceError:
|
||||
|
||||
Args:
|
||||
exception: Exception to convert
|
||||
error_code: Optional specific error code
|
||||
error_code: The error code to report
|
||||
context: Optional additional context
|
||||
"""
|
||||
# Infer error code from exception type if not provided
|
||||
if not error_code:
|
||||
error_code = cls._infer_error_code(exception)
|
||||
|
||||
# Build context
|
||||
error_context = context or {}
|
||||
error_context['exception_type'] = type(exception).__name__
|
||||
@@ -252,26 +214,6 @@ class WebInterfaceError:
|
||||
}
|
||||
return messages.get(error_code, "An unexpected error occurred")
|
||||
|
||||
@classmethod
|
||||
def _infer_error_code(cls, exception: Exception) -> ErrorCode:
|
||||
"""Infer error code from exception type."""
|
||||
exception_name = type(exception).__name__
|
||||
|
||||
if "Config" in exception_name:
|
||||
return ErrorCode.CONFIG_LOAD_FAILED
|
||||
elif "Plugin" in exception_name:
|
||||
return ErrorCode.PLUGIN_LOAD_FAILED
|
||||
elif "Permission" in exception_name or "Access" in exception_name:
|
||||
return ErrorCode.PERMISSION_DENIED
|
||||
elif "Validation" in exception_name or "Schema" in exception_name:
|
||||
return ErrorCode.VALIDATION_ERROR
|
||||
elif "Network" in exception_name or "Connection" in exception_name:
|
||||
return ErrorCode.NETWORK_ERROR
|
||||
elif "Timeout" in exception_name:
|
||||
return ErrorCode.TIMEOUT
|
||||
else:
|
||||
return ErrorCode.UNKNOWN_ERROR
|
||||
|
||||
@classmethod
|
||||
def _get_exception_details(cls, exception: Exception) -> Optional[str]:
|
||||
"""Get additional details from exception."""
|
||||
|
||||
Reference in New Issue
Block a user