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:
Chuck
2026-09-24 15:53:19 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ece416c4e5
commit 4e61d7248a
17 changed files with 2437 additions and 2418 deletions
+35 -1
View File
@@ -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.
+34 -3
View File
@@ -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,
+5 -63
View File
@@ -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."""