mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
* 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>
173 lines
5.3 KiB
Python
173 lines
5.3 KiB
Python
"""
|
|
Centralized error handling for web interface.
|
|
|
|
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
|
|
from src.logging_config import get_logger
|
|
from src.redaction import redact_credentials
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# Long enough for an errno string with a path, short enough not to dump a
|
|
# parser's worth of context into a JSON field.
|
|
_MAX_DETAIL_LENGTH = 400
|
|
|
|
|
|
def describe_exception(exc: BaseException,
|
|
max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|
"""
|
|
One-line, safe-to-return description of an exception.
|
|
|
|
The generic "an error occurred; see logs for details" tells a user nothing
|
|
and, when the failure is bad enough, the logs are unreachable too: a device
|
|
whose storage was failing returned that message from every endpoint
|
|
*including* the log viewer, because journalctl could not be executed. The
|
|
underlying `[Errno 5] Input/output error` named the fault immediately.
|
|
|
|
Returns "TypeName: message", credentials redacted and length capped. The
|
|
type alone is worth carrying -- a bare PermissionError says more than any
|
|
generic sentence.
|
|
|
|
Args:
|
|
exc: The exception to describe
|
|
max_length: Truncate beyond this many characters
|
|
|
|
Returns:
|
|
A single-line description, never empty
|
|
"""
|
|
message = str(exc).strip()
|
|
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
|
return redact_text(text, max_length)
|
|
|
|
|
|
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|
"""Make arbitrary text safe to hand back over HTTP.
|
|
|
|
Split out of describe_exception because exceptions are not the only thing
|
|
worth returning: a subprocess's stderr, or a message a helper script
|
|
printed, is just as useful to a user and just as capable of carrying a
|
|
token or a password in it.
|
|
|
|
Args:
|
|
text: The text to redact
|
|
max_length: Truncate beyond this many characters
|
|
|
|
Returns:
|
|
A single line, credentials replaced, length capped.
|
|
"""
|
|
text = redact_credentials(text)
|
|
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
|
text = ' '.join(text.split())
|
|
if len(text) > max_length:
|
|
text = text[:max_length - 1].rstrip() + '…'
|
|
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,
|
|
details: Optional[str] = None,
|
|
context: Optional[dict] = None,
|
|
suggested_fixes: Optional[list] = None,
|
|
status_code: int = 500
|
|
) -> tuple:
|
|
"""
|
|
Create a standardized error response.
|
|
|
|
Args:
|
|
error_code: Error code
|
|
message: Error message
|
|
details: Optional detailed error information
|
|
context: Optional context dictionary
|
|
suggested_fixes: Optional list of suggested fixes
|
|
status_code: HTTP status code
|
|
|
|
Returns:
|
|
Tuple of (jsonify response, status_code)
|
|
"""
|
|
error = WebInterfaceError(
|
|
error_code=error_code,
|
|
message=message,
|
|
details=details,
|
|
context=context or {},
|
|
suggested_fixes=suggested_fixes
|
|
)
|
|
|
|
return jsonify(error.to_dict()), status_code
|
|
|
|
|
|
def create_success_response(
|
|
data: Any = None,
|
|
message: Optional[str] = None,
|
|
metadata: Optional[dict] = None
|
|
) -> dict:
|
|
"""
|
|
Create a standardized success response.
|
|
|
|
Args:
|
|
data: Response data
|
|
message: Optional success message
|
|
metadata: Optional metadata (timing, version, etc.)
|
|
|
|
Returns:
|
|
Dictionary for jsonify
|
|
"""
|
|
response = {
|
|
"status": "success"
|
|
}
|
|
|
|
# All three use `is not None` rather than truthiness: "" and {} are
|
|
# values a caller chose to send, and dropping them silently would make
|
|
# the response shape depend on the data.
|
|
if data is not None:
|
|
response["data"] = data
|
|
|
|
if message is not None:
|
|
response["message"] = message
|
|
|
|
if metadata is not None:
|
|
response["metadata"] = metadata
|
|
|
|
return response
|
|
|