Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 376f248b1e fix(web): stop reporting client errors as server faults
Werkzeug's HTTPExceptions subclass Exception, so the catch-all handler
saw them too and turned every 405, 400, 413 and 415 into a 500
UNKNOWN_ERROR. A GET on a POST-only route answered "an error occurred;
see logs for details", which tells the caller nothing and blames the
wrong side -- found while probing a device whose POST-only config
endpoints did exactly that.

Hand HTTPExceptions back as themselves, with their own status and
description. A genuine server fault still reports as one, with the
detail this branch adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-10 12:26:53 -04:00
ChuckBuildsandClaude Opus 5 0b9f5e2236 fix(web): redact auth headers and URL userinfo, and cover every handler
Three review findings.

The sanitizer missed two credential shapes that requests puts in its
exception text verbatim: `Authorization: Bearer <token>` and
`https://user:password@host`. Both would have gone straight into a
response. The auth-scheme name and the username are kept -- they say
which credential and whose without being the secret.

The AST test only asked whether *something* had been logged, so a
`logger.info("failed")` satisfied it while discarding the exception just
as completely. It now requires an error-level record carrying exc_info
and `describe_exception()` called on the handler's own bound exception.

Enforcing that revealed the first cut had scoped itself wrongly. I had
converted the nine handlers that logged nothing and left the sixty that
logged, reasoning their detail was at least in the journal. But
/system/status is one of the sixty, and on the failing device it told me
nothing -- the journal was exactly what could not be read. Splitting
them left most of the diagnostic surface unhelpful for the case this
change exists for, so all sixty-nine now carry the detail.

Two handlers had no bound exception name, and three passed the message
through a variable rather than a literal; both shapes needed doing by
hand. Full suite: 2383 passed, one pre-existing unrelated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-10 11:22:08 -04:00
ChuckBuildsandClaude Opus 5 8c1171444c fix(web): say what actually went wrong instead of "unknown"
Every failing endpoint returned "An error occurred; see logs for
details" and nothing else. That is survivable until the logs are the
thing you cannot reach: a device whose SD card was failing answered the
restart action, /system/status and /logs with that same sentence -- the
log viewer included, because journalctl could not be executed -- while
the exception underneath said

    [Errno 5] Input/output error: 'systemctl'

which names the fault outright. The only endpoint that helped was
/health, and only because it happens to pass a subprocess's stderr
through. Diagnosis came down to guessing which endpoint leaked something.

Add describe_exception(), returning "TypeName: message" on one line, and
populate the `details` field that the response schema has always had and
nothing ever filled. The type alone carries information -- a bare
PermissionError says more than any generic sentence.

Exception text is not automatically safe to echo: a requests error
quotes the URL it failed on, and plugins that authenticate by query
string put their key there. Credential values are redacted while the
parameter name is kept, since knowing which credential was involved is
part of the diagnosis. Length is capped and newlines collapsed so a
parser's context cannot flood a JSON field.

Nine handlers in api_v3 bound the exception and never used it, so the
promised log entry was never written either -- "see logs for details"
was false, not merely unhelpful. Those now log with a traceback and
carry the detail. The other 60 already logged and are unchanged; they
can adopt the helper as they are touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-10 11:08:44 -04:00
7 changed files with 421 additions and 244 deletions
-15
View File
@@ -41,7 +41,6 @@ from src.plugin_system.testing.loading import ( # noqa: E402
) )
from src.plugin_system.testing.harness import ( # noqa: E402 from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens, RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
check_empty_claimed,
check_scale_up, check_scale_up,
) )
from src.plugin_system.testing.sizes import ( # noqa: E402 from src.plugin_system.testing.sizes import ( # noqa: E402
@@ -116,11 +115,6 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {}) declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32))) design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
fill_strict = spec.get("fill_check") == "strict" fill_strict = spec.get("fill_check") == "strict"
# A mode that renders nothing without returning False is never skipped by
# the display controller, so it holds a blank panel for its whole duration.
# Warn-only by default: a scroll mode's first frame is legitimately its
# blank scroll-in buffer.
empty_strict = spec.get("empty_check") == "strict"
# Every run: the base config, plus one per harness.json "variant" — # Every run: the base config, plus one per harness.json "variant" —
# a config overlay with its own golden dir (e.g. adaptive layout mode # a config overlay with its own golden dir (e.g. adaptive layout mode
@@ -148,7 +142,6 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
compare_to_goldens(results, golden_dir) compare_to_goldens(results, golden_dir)
check_scale_up(results, design_size=design_size, strict=fill_strict) check_scale_up(results, design_size=design_size, strict=fill_strict)
check_empty_claimed(results, strict=empty_strict)
# Tag variant runs so the report and PNG dumps stay distinguishable. # Tag variant runs so the report and PNG dumps stay distinguishable.
if variant_name: if variant_name:
@@ -185,9 +178,6 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
# warn-only underfill: big panel left mostly empty # warn-only underfill: big panel left mostly empty
ex, ey = r.fill_extent ex, ey = r.fill_extent
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})" detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
if r.empty_claimed and r.empty_ok is None:
detail += (f" (empty warn: drew nothing but display() returned"
f" {r.display_returned!r}, so the mode is not skipped)")
else: else:
everything_ok = False everything_ok = False
if r.error is not None: if r.error is not None:
@@ -201,11 +191,6 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
ex, ey = r.fill_extent or (0.0, 0.0) ex, ey = r.fill_extent or (0.0, 0.0)
status = "FAIL" status = "FAIL"
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage" detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
elif r.empty_ok is False:
status = "FAIL"
detail = (f" drew nothing but display() returned"
f" {r.display_returned!r}; return False so the"
f" controller skips the mode")
else: else:
status, detail = "FAIL", "" status, detail = "FAIL", ""
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}") print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
+6 -55
View File
@@ -73,11 +73,6 @@ class RenderResult:
golden_ok: Optional[bool] = None golden_ok: Optional[bool] = None
golden_diff_pixels: int = 0 golden_diff_pixels: int = 0
golden_max_delta: int = 0 golden_max_delta: int = 0
# what display() handed back; the controller skips a mode only on False
display_returned: Any = None
# empty-frame check: rendered nothing while not reporting "no content"
empty_claimed: Optional[bool] = None # True when that happened
empty_ok: Optional[bool] = None # False only in strict mode
# fill / scale-up check (populated only for sizes >= 2x the design size) # fill / scale-up check (populated only for sizes >= 2x the design size)
fill_checked: bool = False fill_checked: bool = False
fill_ok: Optional[bool] = None # False only in strict mode fill_ok: Optional[bool] = None # False only in strict mode
@@ -97,8 +92,6 @@ class RenderResult:
return False return False
if self.fill_ok is False: if self.fill_ok is False:
return False return False
if self.empty_ok is False:
return False
return True return True
@@ -139,25 +132,21 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
return plugin_instance return plugin_instance
def _render_mode(plugin_instance: Any, mode: str) -> Any: def _render_mode(plugin_instance: Any, mode: str) -> None:
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise """Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
drive the plugin's internal mode state machine (first display() call renders drive the plugin's internal mode state machine (first display() call renders
modes[current_mode_index] when current_display_mode is None). modes[current_mode_index] when current_display_mode is None)."""
Returns whatever display() returned. The display controller skips a mode
whose display() returns False, so that value decides whether an empty mode
is rotated past or sat on -- which makes it worth reporting rather than
discarding."""
sig = inspect.signature(plugin_instance.display) sig = inspect.signature(plugin_instance.display)
if "display_mode" in sig.parameters: if "display_mode" in sig.parameters:
return plugin_instance.display(force_clear=True, display_mode=mode) plugin_instance.display(force_clear=True, display_mode=mode)
return
modes = getattr(plugin_instance, "modes", None) modes = getattr(plugin_instance, "modes", None)
if modes and mode in modes: if modes and mode in modes:
plugin_instance.current_mode_index = list(modes).index(mode) plugin_instance.current_mode_index = list(modes).index(mode)
if hasattr(plugin_instance, "current_display_mode"): if hasattr(plugin_instance, "current_display_mode"):
plugin_instance.current_display_mode = None plugin_instance.current_display_mode = None
return plugin_instance.display(force_clear=False) plugin_instance.display(force_clear=False)
def _freeze(freeze_time: Optional[str]): def _freeze(freeze_time: Optional[str]):
@@ -245,7 +234,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
logger.warning("update() raised a non-connectivity error for %s [%s]: %s", logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
plugin_id, mode, e) plugin_id, mode, e)
if result.error is None: if result.error is None:
result.display_returned = _render_mode(inst, mode) _render_mode(inst, mode)
result.image = dm.get_image() result.image = dm.get_image()
result.overflow = dm.check_overflow() result.overflow = dm.check_overflow()
except Exception as e: # noqa: BLE001 — a display crash is a real failure except Exception as e: # noqa: BLE001 — a display crash is a real failure
@@ -352,44 +341,6 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
return (extent_x, extent_y, ink) return (extent_x, extent_y, ink)
def check_empty_claimed(results: List[RenderResult],
strict: bool = False) -> List[RenderResult]:
"""Flag a mode that rendered nothing without reporting "no content".
The display controller skips a mode whose ``display()`` returns False, and
treats anything else -- including None -- as "content was shown". A mode
that draws nothing and does not return False therefore holds whatever is on
the panel for its whole display duration. Since a mode switch clears first,
that is a blank screen. Two sports plugins shipped exactly this: their
``display()`` returned None on every path, so an out-of-season league sat
blank for its full duration rather than being rotated past.
Warn-only by default, because a blank frame is not automatically wrong: a
scroll mode whose first frame is its blank scroll-in buffer renders empty
and is behaving correctly. ``strict=True`` sets ``empty_claimed`` such that
``RenderResult.ok`` fails -- opt in per plugin via harness.json
``{"empty_check": "strict"}`` once its modes are known to draw on the
fixture data.
Note this can only catch what the fixtures actually render. A plugin whose
harness fixture seeds content never exercises its empty path here; the
source-level gate in the plugins repo covers that case.
"""
for r in results:
if r.image is None or r.error is not None:
continue
# An explicit False is the plugin correctly saying "nothing to show".
if r.display_returned is False:
continue
if r.image.convert("L").point(
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None:
continue
r.empty_claimed = True
if strict:
r.empty_ok = False
return results
def check_scale_up(results: List[RenderResult], def check_scale_up(results: List[RenderResult],
design_size: Tuple[int, int] = (128, 32), design_size: Tuple[int, int] = (128, 32),
min_extent: float = _MIN_FILL_EXTENT, min_extent: float = _MIN_FILL_EXTENT,
+69
View File
@@ -4,6 +4,7 @@ Centralized error handling for web interface.
Provides helpers for consistent error responses across API endpoints. Provides helpers for consistent error responses across API endpoints.
""" """
import re
from typing import Any, Optional from typing import Any, Optional
from flask import jsonify from flask import jsonify
@@ -16,6 +17,74 @@ from src.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# Credentials that turn up inside exception text. A requests error quotes the
# URL it failed on, and plugins that authenticate by query string put their key
# there, so echoing an exception verbatim can hand out an API key. Redact the
# value, keep the parameter name -- knowing *which* credential was involved is
# part of the diagnosis.
_REDACT_CREDENTIAL = re.compile(
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
re.IGNORECASE,
)
# `Authorization: Bearer <token>`. The scheme is kept because it says which
# kind of credential failed; only the token goes. Not covered by the pattern
# above, whose value part stops at whitespace and so would keep the token when
# a space follows the scheme.
_REDACT_AUTH_HEADER = re.compile(
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
r'(?:bearer|basic|digest|token)\s+)([^\s,"\'<>}]+)',
re.IGNORECASE,
)
# Credentials embedded in a URL: https://user:password@host. requests quotes
# the full URL in its exceptions, so this is a realistic leak. The username is
# kept -- it identifies which account failed without being the secret.
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
re.IGNORECASE)
# 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__
# Order matters: the URL and header forms are more specific than the
# generic key=value pattern, which would otherwise chew the scheme.
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', 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
def create_error_response( def create_error_response(
error_code: ErrorCode, error_code: ErrorCode,
message: str, message: str,
-103
View File
@@ -1,103 +0,0 @@
"""Tests for the harness empty-frame check (src/plugin_system/testing/harness.py).
The display controller skips a mode whose display() returns False and treats
anything else -- including None -- as "content was shown". A mode that draws
nothing without returning False is therefore never skipped, and since a mode
switch clears the panel first, it sits on a blank screen for its whole display
duration.
Two sports plugins shipped exactly that: their display() returned None on every
path, so an out-of-season league held a blank panel instead of being rotated
past. The harness rendered those modes and passed them, because it discarded
the return value entirely.
"""
from PIL import Image
from src.plugin_system.testing.harness import RenderResult, check_empty_claimed
def _blank(w=64, h=32):
return Image.new("RGB", (w, h), (0, 0, 0))
def _drawn(w=64, h=32):
img = _blank(w, h)
img.paste(Image.new("RGB", (10, 10), (255, 255, 255)), (5, 5))
return img
def _result(image, returned=None, **kw):
return RenderResult("p", 64, 32, "mode", image=image,
display_returned=returned, **kw)
class TestCheckEmptyClaimed:
def test_blank_frame_returning_none_is_flagged(self):
# The shape that shipped: nothing drawn, nothing reported.
r = _result(_blank(), returned=None)
check_empty_claimed([r])
assert r.empty_claimed is True
def test_blank_frame_returning_true_is_flagged(self):
# Just as broken, and more explicit about it.
r = _result(_blank(), returned=True)
check_empty_claimed([r])
assert r.empty_claimed is True
def test_blank_frame_returning_false_is_fine(self):
# The plugin correctly said "no content"; the controller will skip it.
r = _result(_blank(), returned=False)
check_empty_claimed([r])
assert r.empty_claimed is None
def test_a_drawn_frame_is_fine_whatever_it_returns(self):
for returned in (None, True, False):
r = _result(_drawn(), returned=returned)
check_empty_claimed([r])
assert r.empty_claimed is None, returned
def test_near_black_still_counts_as_drawn(self):
# Guard the threshold: content dim enough to look black to the eye is
# still content, and flagging it would train people to ignore this.
img = _blank()
img.paste(Image.new("RGB", (4, 4), (60, 60, 60)), (2, 2))
r = _result(img, returned=None)
check_empty_claimed([r])
assert r.empty_claimed is None
class TestWarnVersusStrict:
def test_warn_only_by_default(self):
# A scroll mode's first frame is legitimately its blank scroll-in
# buffer, so this must not fail a run unless opted in.
r = _result(_blank(), returned=None)
check_empty_claimed([r])
assert r.empty_ok is None
assert r.ok is True
def test_strict_fails_the_result(self):
r = _result(_blank(), returned=None)
check_empty_claimed([r], strict=True)
assert r.empty_ok is False
assert r.ok is False
def test_strict_still_allows_an_honest_false(self):
r = _result(_blank(), returned=False)
check_empty_claimed([r], strict=True)
assert r.empty_ok is None
assert r.ok is True
class TestSkippedResults:
def test_a_crashed_render_is_left_alone(self):
# error already fails the result; adding a second reason just muddies
# the report.
r = _result(None, returned=None, error="boom")
check_empty_claimed([r], strict=True)
assert r.empty_claimed is None
def test_a_result_with_no_image_is_left_alone(self):
r = _result(None, returned=None)
check_empty_claimed([r], strict=True)
assert r.empty_claimed is None
+222
View File
@@ -0,0 +1,222 @@
"""Tests for surfacing the underlying error in web responses.
Regression under test: every failing endpoint returned "An error occurred; see
logs for details" and nothing else. On a device whose storage was failing that
sentence came back from the restart action, from /system/status, and from
/logs -- the log viewer itself -- because journalctl could not be executed. The
exception underneath said `[Errno 5] Input/output error: 'systemctl'`, which
names the fault outright, and nine handlers were discarding it entirely rather
than even logging it.
"""
import pytest
from src.web_interface.error_handler import describe_exception
class TestDescribeException:
def test_names_the_type_and_message(self):
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
def test_the_reported_failure_is_legible(self):
# The whole point: this string is the diagnosis.
assert "Input/output error" in describe_exception(
OSError(5, "Input/output error", "systemctl"))
def test_a_bare_exception_still_names_its_type(self):
# A PermissionError with no message still says more than "unknown".
assert describe_exception(PermissionError()) == "PermissionError"
assert describe_exception(Exception()) == "Exception"
def test_message_is_kept_when_present(self):
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
class TestCredentialRedaction:
"""Exception text quotes URLs, and plugins authenticate by query string."""
@pytest.mark.parametrize("secret_text,leaked", [
("failed: https://api.x.com/v1?api_key=SEC123&city=Tampa", "SEC123"),
("token=abcdef123456 was rejected", "abcdef123456"),
("connect failed password=hunter2", "hunter2"),
("GET /?access_token=zzz999", "zzz999"),
('{"secret": "topsecret"}', "topsecret"),
# requests quotes the URL it failed on, and both of these forms turn
# up in real client exceptions.
("401 for https://user:hunter2@example.com/api", "hunter2"),
("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"),
("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="),
("Proxy-Authorization: Bearer ptok999", "ptok999"),
])
def test_credentials_never_reach_the_response(self, secret_text, leaked):
detail = describe_exception(RuntimeError(secret_text))
assert leaked not in detail
assert "<redacted>" in detail
def test_the_parameter_name_survives_redaction(self):
# Knowing *which* credential was involved is part of the diagnosis.
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
assert "api_key" in detail
def test_auth_scheme_and_username_survive(self):
# Which kind of credential, and whose, without the credential itself.
assert "Bearer" in describe_exception(
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
assert "user" in describe_exception(
RuntimeError("https://user:hunter2@example.com"))
def test_non_secret_context_is_preserved(self):
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
assert "city=Tampa" in detail
assert "<redacted>" not in detail
class TestBounds:
def test_long_messages_are_truncated(self):
detail = describe_exception(ValueError("x" * 5000))
assert len(detail) <= 400
def test_newlines_are_collapsed_to_one_line(self):
detail = describe_exception(ValueError("line one\nline two\tthree"))
assert "\n" not in detail and "\t" not in detail
assert detail == "ValueError: line one line two three"
def test_custom_length_is_honoured(self):
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
class TestHandlersCarryDetail:
"""The response shape callers actually see."""
def test_no_api_v3_handler_discards_its_exception(self):
"""Every generic-message handler must log a traceback and return detail.
Nine of them bound `e` and never used it, so the promised log entry was
never written either. Checking merely that *something* was logged is
too weak -- a `logger.info("failed")` would satisfy it while throwing
the exception away just as completely, so this asserts the two things
that actually make the failure diagnosable: an error-level record with
the traceback, and the sanitized detail in the response.
"""
import ast
src = open("web_interface/blueprints/api_v3.py").read()
tree = ast.parse(src)
generic = "An error occurred; see logs for details"
def logs_a_traceback(handler):
"""An error/exception-level log call carrying exc_info."""
for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]:
func = call.func
if not isinstance(func, ast.Attribute):
continue
if func.attr == "exception": # implies exc_info
return True
if func.attr not in ("error", "critical"):
continue
if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True
for kw in call.keywords):
return True
return False
def returns_the_detail(handler):
"""describe_exception() called on this handler's bound exception."""
for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]:
name = call.func.id if isinstance(call.func, ast.Name) else None
if name != "describe_exception":
continue
if handler.name is None:
return True # bare `except:` cannot name it; accept
if any(isinstance(a, ast.Name) and a.id == handler.name
for a in call.args):
return True
return False
offenders = []
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
seg = ast.get_source_segment(src, h) or ""
if generic not in seg:
continue
missing = []
if not logs_a_traceback(h):
missing.append("error-level log with exc_info")
if not returns_the_detail(h):
missing.append("describe_exception(e) in the response")
if missing:
offenders.append((h.lineno, missing))
assert not offenders, (
"handlers returning the generic message without %s: %r"
% ("both a traceback log and the detail", offenders))
def test_client_errors_keep_their_own_status(self):
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
Werkzeug's HTTPExceptions subclass Exception, so the catch-all saw them
too: a GET on a POST-only route came back 500 "an error occurred",
which tells the caller nothing and blames the wrong side. Found while
probing a device whose POST-only config endpoints answered every GET
with UNKNOWN_ERROR.
"""
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.errorhandler(Exception)
def handle(error):
if isinstance(error, HTTPException):
return jsonify({
"status": "error",
"error_code": (error.name or "HTTP_ERROR").upper().replace(" ", "_"),
"message": error.description,
}), error.code or 500
return jsonify({
"status": "error",
"error_code": "UNKNOWN_ERROR",
"message": "An error occurred; see logs for details",
"details": describe_exception(error),
}), 500
@app.route("/only-post", methods=["POST"])
def only_post():
return jsonify({"ok": True})
@app.route("/boom")
def boom():
raise OSError(5, "Input/output error", "systemctl")
client = app.test_client()
resp = client.get("/only-post")
assert resp.status_code == 405, "a wrong method must stay a 405"
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
# A genuine server fault still reports as one, with its detail.
resp = client.get("/boom")
assert resp.status_code == 500
assert "Input/output error" in resp.get_json()["details"]
def test_global_handler_reports_the_underlying_error(self):
from flask import Flask, jsonify
app = Flask(__name__)
@app.errorhandler(Exception)
def handle(error):
return jsonify({
"status": "error",
"error_code": "UNKNOWN_ERROR",
"message": "An error occurred; see logs for details",
"details": describe_exception(error),
}), 500
@app.route("/boom")
def boom():
raise OSError(5, "Input/output error", "systemctl")
client = app.test_client()
body = client.get("/boom").get_json()
assert body["error_code"] == "UNKNOWN_ERROR"
assert "Input/output error" in body["details"]
+33 -3
View File
@@ -16,6 +16,8 @@ from datetime import datetime, timedelta
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager from src.config_manager import ConfigManager
from src.web_interface.error_handler import describe_exception
from werkzeug.exceptions import HTTPException
from src.exceptions import ConfigError from src.exceptions import ConfigError
from src.plugin_system.plugin_manager import PluginManager from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.store_manager import PluginStoreManager from src.plugin_system.store_manager import PluginStoreManager
@@ -391,15 +393,42 @@ def internal_error(error):
import logging import logging
logger = logging.getLogger('web_interface') logger = logging.getLogger('web_interface')
logger.error("Internal server error", exc_info=True) logger.error("Internal server error", exc_info=True)
return jsonify({ payload = {
'status': 'error', 'status': 'error',
'error_code': 'INTERNAL_ERROR', 'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred; see logs for details', 'message': 'An internal error occurred; see logs for details',
}), 500 }
# Flask hands the original exception over as `error.original_exception`
# when propagation is off; without it there is nothing to describe.
original = getattr(error, 'original_exception', None) or (
error if isinstance(error, BaseException) else None)
if original is not None:
payload['details'] = describe_exception(original)
return jsonify(payload), 500
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_exception(error): def handle_exception(error):
"""Handle all unhandled exceptions.""" """Handle all unhandled exceptions.
Returning only "see logs for details" is fine until the logs are exactly
what you cannot reach. A device with failing storage answered every
endpoint with that sentence -- including the log viewer, because journalctl
could not be executed -- while the exception underneath said
`[Errno 5] Input/output error`. Naming the error costs nothing here and is
frequently the whole diagnosis, so include it alongside the log pointer.
"""
# Werkzeug's HTTPExceptions subclass Exception, so this catch-all sees
# them too and was reporting every 405, 400, 413 and 415 as a server-side
# UNKNOWN_ERROR 500. A GET on a POST-only route came back as "an error
# occurred" rather than "method not allowed", which tells the caller
# nothing and blames the wrong side. Hand those back as themselves.
if isinstance(error, HTTPException):
return jsonify({
'status': 'error',
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
'message': error.description,
}), error.code or 500
import logging import logging
logger = logging.getLogger('web_interface') logger = logging.getLogger('web_interface')
logger.error("Unhandled exception", exc_info=True) logger.error("Unhandled exception", exc_info=True)
@@ -407,6 +436,7 @@ def handle_exception(error):
'status': 'error', 'status': 'error',
'error_code': 'UNKNOWN_ERROR', 'error_code': 'UNKNOWN_ERROR',
'message': 'An error occurred; see logs for details', 'message': 'An error occurred; see logs for details',
'details': describe_exception(error),
}), 500 }), 500
# Captive portal redirect middleware # Captive portal redirect middleware
+91 -68
View File
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
# Import new infrastructure # Import new infrastructure
from src.web_interface.api_helpers import success_response, error_response, validate_request_json from src.web_interface.api_helpers import success_response, error_response, validate_request_json
from src.web_interface.errors import ErrorCode from src.web_interface.errors import ErrorCode
from src.web_interface.error_handler import describe_exception
from src.plugin_system.operation_types import OperationType from src.plugin_system.operation_types import OperationType
from src.web_interface.validators import ( from src.web_interface.validators import (
validate_file_upload validate_file_upload
@@ -271,7 +272,7 @@ def get_main_config():
return jsonify({'status': 'success', 'data': config}) return jsonify({'status': 'success', 'data': config})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/config/schedule', methods=['GET']) @api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config(): def get_schedule_config():
@@ -289,9 +290,11 @@ def get_schedule_config():
return success_response(data=schedule_config) return success_response(data=schedule_config)
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return error_response( return error_response(
ErrorCode.CONFIG_LOAD_FAILED, ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details", "An error occurred; see logs for details",
details=describe_exception(e),
status_code=500 status_code=500
) )
@@ -467,7 +470,7 @@ def save_schedule_config():
ErrorCode.CONFIG_SAVE_FAILED, ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details", "An error occurred; see logs for details",
status_code=500 status_code=500, details=describe_exception(e)
) )
@api_v3.route('/config/dim-schedule', methods=['GET']) @api_v3.route('/config/dim-schedule', methods=['GET'])
@@ -515,14 +518,14 @@ def get_dim_schedule_config():
return error_response( return error_response(
ErrorCode.CONFIG_LOAD_FAILED, ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details", "An error occurred; see logs for details",
status_code=500 status_code=500, details=describe_exception(e)
) )
except Exception as e: except Exception as e:
logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True) logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True)
return error_response( return error_response(
ErrorCode.CONFIG_LOAD_FAILED, ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details", "An error occurred; see logs for details",
status_code=500 status_code=500, details=describe_exception(e)
) )
@api_v3.route('/config/dim-schedule', methods=['POST']) @api_v3.route('/config/dim-schedule', methods=['POST'])
@@ -686,7 +689,7 @@ def save_dim_schedule_config():
ErrorCode.CONFIG_SAVE_FAILED, ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details", "An error occurred; see logs for details",
status_code=500 status_code=500, details=describe_exception(e)
) )
@api_v3.route('/config/main', methods=['POST']) @api_v3.route('/config/main', methods=['POST'])
@@ -1343,7 +1346,7 @@ def save_main_config():
return error_response( return error_response(
ErrorCode.CONFIG_SAVE_FAILED, ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details", "An error occurred; see logs for details",
status_code=500 status_code=500, details=describe_exception(e)
) )
@api_v3.route('/config/secrets', methods=['GET']) @api_v3.route('/config/secrets', methods=['GET'])
@@ -1357,7 +1360,7 @@ def get_secrets_config():
return jsonify({'status': 'success', 'data': config}) return jsonify({'status': 'success', 'data': config})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/config/raw/main', methods=['POST']) @api_v3.route('/config/raw/main', methods=['POST'])
def save_raw_main_config(): def save_raw_main_config():
@@ -1390,6 +1393,7 @@ def save_raw_main_config():
return error_response( return error_response(
ErrorCode.CONFIG_SAVE_FAILED, ErrorCode.CONFIG_SAVE_FAILED,
error_message, error_message,
details=describe_exception(e),
context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None, context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None,
status_code=500 status_code=500
@@ -1399,6 +1403,7 @@ def save_raw_main_config():
return error_response( return error_response(
ErrorCode.UNKNOWN_ERROR, ErrorCode.UNKNOWN_ERROR,
error_message, error_message,
details=describe_exception(e),
status_code=500 status_code=500
) )
@@ -1438,7 +1443,8 @@ def save_raw_secrets_config():
else: else:
error_message = 'An error occurred; see logs for details' error_message = 'An error occurred; see logs for details'
return jsonify({'status': 'error', 'message': error_message}), 500 return jsonify({'status': 'error', 'message': error_message,
'details': describe_exception(e)}), 500
@api_v3.route('/system/status', methods=['GET']) @api_v3.route('/system/status', methods=['GET'])
def get_system_status(): def get_system_status():
@@ -1526,7 +1532,7 @@ def get_system_status():
return jsonify({'status': 'success', 'data': status}) return jsonify({'status': 'success', 'data': status})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/health', methods=['GET']) @api_v3.route('/health', methods=['GET'])
def get_health(): def get_health():
@@ -1625,9 +1631,11 @@ def get_health():
return jsonify({'status': 'success', 'data': health_status}) return jsonify({'status': 'success', 'data': health_status})
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details', 'message': 'An error occurred; see logs for details',
'details': describe_exception(e),
'data': {'status': 'unhealthy'} 'data': {'status': 'unhealthy'}
}), 500 }), 500
@@ -2397,7 +2405,7 @@ def get_display_current():
return jsonify({'status': 'success', 'data': display_data}) return jsonify({'status': 'success', 'data': display_data})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/display/on-demand/status', methods=['GET']) @api_v3.route('/display/on-demand/status', methods=['GET'])
def get_on_demand_status(): def get_on_demand_status():
@@ -2421,7 +2429,7 @@ def get_on_demand_status():
}) })
except Exception as exc: except Exception as exc:
logger.error('Error in get_on_demand_status', exc_info=True) logger.error('Error in get_on_demand_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/display/on-demand/start', methods=['POST']) @api_v3.route('/display/on-demand/start', methods=['POST'])
def start_on_demand_display(): def start_on_demand_display():
@@ -2524,7 +2532,7 @@ def start_on_demand_display():
return jsonify({'status': 'success', 'data': response_data}) return jsonify({'status': 'success', 'data': response_data})
except Exception as exc: except Exception as exc:
logger.error('Error in start_on_demand_display', exc_info=True) logger.error('Error in start_on_demand_display', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/display/on-demand/stop', methods=['POST']) @api_v3.route('/display/on-demand/stop', methods=['POST'])
def stop_on_demand_display(): def stop_on_demand_display():
@@ -2560,7 +2568,7 @@ def stop_on_demand_display():
}) })
except Exception as exc: except Exception as exc:
logger.error('Error in stop_on_demand_display', exc_info=True) logger.error('Error in stop_on_demand_display', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/plugins/installed', methods=['GET']) @api_v3.route('/plugins/installed', methods=['GET'])
def get_installed_plugins(): def get_installed_plugins():
@@ -2708,7 +2716,7 @@ def get_installed_plugins():
return jsonify({'status': 'success', 'data': {'plugins': plugins}}) return jsonify({'status': 'success', 'data': {'plugins': plugins}})
except Exception as e: except Exception as e:
logger.error('Error in get_installed_plugins', exc_info=True) logger.error('Error in get_installed_plugins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
def _installed_plugin_ids(): def _installed_plugin_ids():
"""Best-effort list of installed plugin IDs for the web process. """Best-effort list of installed plugin IDs for the web process.
@@ -2774,7 +2782,7 @@ def get_plugin_health():
}) })
except Exception as e: except Exception as e:
logger.error('Error in get_plugin_health', exc_info=True) logger.error('Error in get_plugin_health', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET']) @api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
def get_plugin_health_single(plugin_id): def get_plugin_health_single(plugin_id):
@@ -2799,7 +2807,7 @@ def get_plugin_health_single(plugin_id):
}) })
except Exception as e: except Exception as e:
logger.error('Error in get_plugin_health_single', exc_info=True) logger.error('Error in get_plugin_health_single', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST']) @api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
def reset_plugin_health(plugin_id): def reset_plugin_health(plugin_id):
@@ -2824,7 +2832,7 @@ def reset_plugin_health(plugin_id):
}) })
except Exception as e: except Exception as e:
logger.error('Error in reset_plugin_health', exc_info=True) logger.error('Error in reset_plugin_health', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/metrics', methods=['GET']) @api_v3.route('/plugins/metrics', methods=['GET'])
def get_plugin_metrics(): def get_plugin_metrics():
@@ -2864,7 +2872,7 @@ def get_plugin_metrics():
}) })
except Exception as e: except Exception as e:
logger.error('Error in get_plugin_metrics', exc_info=True) logger.error('Error in get_plugin_metrics', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET']) @api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
def get_plugin_metrics_single(plugin_id): def get_plugin_metrics_single(plugin_id):
@@ -2889,7 +2897,7 @@ def get_plugin_metrics_single(plugin_id):
}) })
except Exception as e: except Exception as e:
logger.error('Error in get_plugin_metrics_single', exc_info=True) logger.error('Error in get_plugin_metrics_single', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST']) @api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
def reset_plugin_metrics(plugin_id): def reset_plugin_metrics(plugin_id):
@@ -2914,7 +2922,7 @@ def reset_plugin_metrics(plugin_id):
}) })
except Exception as e: except Exception as e:
logger.error('Error in reset_plugin_metrics', exc_info=True) logger.error('Error in reset_plugin_metrics', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST']) @api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
def manage_plugin_limits(plugin_id): def manage_plugin_limits(plugin_id):
@@ -2969,7 +2977,7 @@ def manage_plugin_limits(plugin_id):
}) })
except Exception as e: except Exception as e:
logger.error('Error in manage_plugin_limits', exc_info=True) logger.error('Error in manage_plugin_limits', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/toggle', methods=['POST']) @api_v3.route('/plugins/toggle', methods=['POST'])
def toggle_plugin(): def toggle_plugin():
@@ -3978,7 +3986,7 @@ def install_plugin():
except Exception as e: except Exception as e:
logger.error('Error in install_plugin', exc_info=True) logger.error('Error in install_plugin', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/install-from-url', methods=['POST']) @api_v3.route('/plugins/install-from-url', methods=['POST'])
def install_plugin_from_url(): def install_plugin_from_url():
@@ -4033,7 +4041,7 @@ def install_plugin_from_url():
except Exception as e: except Exception as e:
logger.error('Error in install_plugin_from_url', exc_info=True) logger.error('Error in install_plugin_from_url', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/registry-from-url', methods=['POST']) @api_v3.route('/plugins/registry-from-url', methods=['POST'])
def get_registry_from_url(): def get_registry_from_url():
@@ -4065,7 +4073,7 @@ def get_registry_from_url():
except Exception as e: except Exception as e:
logger.error('Error in get_registry_from_url', exc_info=True) logger.error('Error in get_registry_from_url', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/saved-repositories', methods=['GET']) @api_v3.route('/plugins/saved-repositories', methods=['GET'])
def get_saved_repositories(): def get_saved_repositories():
@@ -4078,7 +4086,7 @@ def get_saved_repositories():
return jsonify({'status': 'success', 'data': {'repositories': repositories}}) return jsonify({'status': 'success', 'data': {'repositories': repositories}})
except Exception as e: except Exception as e:
logger.error('Error in get_saved_repositories', exc_info=True) logger.error('Error in get_saved_repositories', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/saved-repositories', methods=['POST']) @api_v3.route('/plugins/saved-repositories', methods=['POST'])
def add_saved_repository(): def add_saved_repository():
@@ -4109,7 +4117,7 @@ def add_saved_repository():
}), 400 }), 400
except Exception as e: except Exception as e:
logger.error('Error in add_saved_repository', exc_info=True) logger.error('Error in add_saved_repository', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/saved-repositories', methods=['DELETE']) @api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
def remove_saved_repository(): def remove_saved_repository():
@@ -4139,7 +4147,7 @@ def remove_saved_repository():
}), 404 }), 404
except Exception as e: except Exception as e:
logger.error('Error in remove_saved_repository', exc_info=True) logger.error('Error in remove_saved_repository', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/store/list', methods=['GET']) @api_v3.route('/plugins/store/list', methods=['GET'])
def list_plugin_store(): def list_plugin_store():
@@ -4192,7 +4200,7 @@ def list_plugin_store():
return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}}) return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}})
except Exception as e: except Exception as e:
logger.error('Error in list_plugin_store', exc_info=True) logger.error('Error in list_plugin_store', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/store/github-status', methods=['GET']) @api_v3.route('/plugins/store/github-status', methods=['GET'])
def get_github_auth_status(): def get_github_auth_status():
@@ -4243,7 +4251,7 @@ def get_github_auth_status():
}) })
except Exception as e: except Exception as e:
logger.error('Error in get_github_auth_status', exc_info=True) logger.error('Error in get_github_auth_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/store/refresh', methods=['POST']) @api_v3.route('/plugins/store/refresh', methods=['POST'])
def refresh_plugin_store(): def refresh_plugin_store():
@@ -4270,7 +4278,7 @@ def refresh_plugin_store():
}) })
except Exception as e: except Exception as e:
logger.error('Error in refresh_plugin_store', exc_info=True) logger.error('Error in refresh_plugin_store', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
def deep_merge(base_dict, update_dict): def deep_merge(base_dict, update_dict):
""" """
@@ -5822,7 +5830,7 @@ def get_plugin_schema():
return jsonify({'status': 'success', 'data': {'schema': default_schema}}) return jsonify({'status': 'success', 'data': {'schema': default_schema}})
except Exception as e: except Exception as e:
logger.error('Error in get_plugin_schema', exc_info=True) logger.error('Error in get_plugin_schema', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/skins', methods=['GET']) @api_v3.route('/skins', methods=['GET'])
def list_skins(): def list_skins():
@@ -5857,9 +5865,9 @@ def list_skins():
'has_preview': bool(preview and (skin_dir / preview).is_file()), 'has_preview': bool(preview and (skin_dir / preview).is_file()),
}) })
return jsonify({'status': 'success', 'data': {'skins': payload}}) return jsonify({'status': 'success', 'data': {'skins': payload}})
except Exception: except Exception as e:
logger.error('Error in list_skins', exc_info=True) logger.error('Error in list_skins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/config/reset', methods=['POST']) @api_v3.route('/plugins/config/reset', methods=['POST'])
def reset_plugin_config(): def reset_plugin_config():
@@ -5970,7 +5978,7 @@ def reset_plugin_config():
}) })
except Exception as e: except Exception as e:
logger.error('Error in reset_plugin_config', exc_info=True) logger.error('Error in reset_plugin_config', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/action', methods=['POST']) @api_v3.route('/plugins/action', methods=['POST'])
def execute_plugin_action(): def execute_plugin_action():
@@ -6230,7 +6238,7 @@ sys.exit(proc.returncode)
logger.error("Error executing action step 1", exc_info=True) logger.error("Error executing action step 1", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500 }), 500
else: else:
# Simple script execution # Simple script execution
@@ -6280,7 +6288,7 @@ sys.exit(proc.returncode)
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
except Exception as e: except Exception as e:
logger.error('Error in execute_plugin_action', exc_info=True) logger.error('Error in execute_plugin_action', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/authenticate/spotify', methods=['POST']) @api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
def authenticate_spotify(): def authenticate_spotify():
@@ -6413,12 +6421,12 @@ sys.exit(proc.returncode)
logger.error("Error getting Spotify auth URL", exc_info=True) logger.error("Error getting Spotify auth URL", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500 }), 500
except Exception as e: except Exception as e:
logger.error('Error in authenticate_spotify', exc_info=True) logger.error('Error in authenticate_spotify', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/authenticate/ytm', methods=['POST']) @api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
def authenticate_ytm(): def authenticate_ytm():
@@ -6468,7 +6476,7 @@ def authenticate_ytm():
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
except Exception as e: except Exception as e:
logger.error('Error in authenticate_ytm', exc_info=True) logger.error('Error in authenticate_ytm', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/catalog', methods=['GET']) @api_v3.route('/fonts/catalog', methods=['GET'])
def get_fonts_catalog(): def get_fonts_catalog():
@@ -6563,7 +6571,10 @@ def get_fonts_catalog():
return jsonify({'status': 'success', 'data': {'catalog': catalog}}) return jsonify({'status': 'success', 'data': {'catalog': catalog}})
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 logger.error("%s failed", request.path, exc_info=True)
return jsonify({'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)}), 500
@api_v3.route('/fonts/tokens', methods=['GET']) @api_v3.route('/fonts/tokens', methods=['GET'])
def get_font_tokens(): def get_font_tokens():
@@ -6582,7 +6593,7 @@ def get_font_tokens():
return jsonify({'status': 'success', 'data': {'tokens': tokens}}) return jsonify({'status': 'success', 'data': {'tokens': tokens}})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['GET']) @api_v3.route('/fonts/overrides', methods=['GET'])
def get_fonts_overrides(): def get_fonts_overrides():
@@ -6594,7 +6605,7 @@ def get_fonts_overrides():
return jsonify({'status': 'success', 'data': {'overrides': overrides}}) return jsonify({'status': 'success', 'data': {'overrides': overrides}})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['POST']) @api_v3.route('/fonts/overrides', methods=['POST'])
def save_fonts_overrides(): def save_fonts_overrides():
@@ -6608,7 +6619,7 @@ def save_fonts_overrides():
return jsonify({'status': 'success', 'message': 'Font overrides saved'}) return jsonify({'status': 'success', 'message': 'Font overrides saved'})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE']) @api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
def delete_font_override(element_key): def delete_font_override(element_key):
@@ -6618,7 +6629,7 @@ def delete_font_override(element_key):
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'}) return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/upload', methods=['POST']) @api_v3.route('/fonts/upload', methods=['POST'])
def upload_font(): def upload_font():
@@ -6683,7 +6694,7 @@ def upload_font():
}) })
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/preview', methods=['GET']) @api_v3.route('/fonts/preview', methods=['GET'])
@@ -6828,7 +6839,7 @@ def get_font_preview() -> tuple[Response, int] | Response:
}) })
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/<font_family>', methods=['DELETE']) @api_v3.route('/fonts/<font_family>', methods=['DELETE'])
@@ -6916,7 +6927,7 @@ def delete_font(font_family: str) -> tuple[Response, int] | Response:
}) })
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/assets/upload', methods=['POST']) @api_v3.route('/plugins/assets/upload', methods=['POST'])
@@ -7064,7 +7075,7 @@ def upload_plugin_asset():
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST']) @api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
def upload_of_the_day_json(): def upload_of_the_day_json():
@@ -7214,7 +7225,7 @@ def upload_of_the_day_json():
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST']) @api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
def delete_of_the_day_json(): def delete_of_the_day_json():
@@ -7261,7 +7272,7 @@ def delete_of_the_day_json():
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET']) @api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
def serve_plugin_static(plugin_id, file_path): def serve_plugin_static(plugin_id, file_path):
@@ -7307,7 +7318,7 @@ def serve_plugin_static(plugin_id, file_path):
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST']) @api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
@@ -7389,7 +7400,7 @@ def upload_calendar_credentials():
except Exception as e: except Exception as e:
logger.error('Error in upload_calendar_credentials', exc_info=True) logger.error('Error in upload_calendar_credentials', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/assets/delete', methods=['POST']) @api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset(): def delete_plugin_asset():
@@ -7432,7 +7443,7 @@ def delete_plugin_asset():
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/assets/list', methods=['GET']) @api_v3.route('/plugins/assets/list', methods=['GET'])
def list_plugin_assets(): def list_plugin_assets():
@@ -7460,7 +7471,7 @@ def list_plugin_assets():
except Exception as e: except Exception as e:
logger.error('Unhandled exception', exc_info=True) logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/display/current-status', methods=['GET']) @api_v3.route('/display/current-status', methods=['GET'])
def get_current_display_status(): def get_current_display_status():
@@ -7481,9 +7492,9 @@ def get_current_display_status():
'last_updated': None, 'last_updated': None,
} }
return jsonify({'status': 'success', 'data': state}) return jsonify({'status': 'success', 'data': state})
except Exception: except Exception as e:
logger.error('Error in get_current_display_status', exc_info=True) logger.error('Error in get_current_display_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/logs', methods=['GET']) @api_v3.route('/logs', methods=['GET'])
def get_logs(): def get_logs():
@@ -7522,9 +7533,11 @@ def get_logs():
'message': 'Timeout while fetching logs' 'message': 'Timeout while fetching logs'
}), 500 }), 500
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500 }), 500
# Multi-Display Sync Endpoints # Multi-Display Sync Endpoints
@@ -7589,9 +7602,11 @@ def get_wifi_status():
} }
}) })
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/scan', methods=['GET']) @api_v3.route('/wifi/scan', methods=['GET'])
@@ -7713,7 +7728,7 @@ def connect_wifi():
logger.error("Error connecting to WiFi", exc_info=True) logger.error("Error connecting to WiFi", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/disconnect', methods=['POST']) @api_v3.route('/wifi/disconnect', methods=['POST'])
@@ -7739,7 +7754,7 @@ def disconnect_wifi():
logger.error("Error disconnecting from WiFi", exc_info=True) logger.error("Error disconnecting from WiFi", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/ap/enable', methods=['POST']) @api_v3.route('/wifi/ap/enable', methods=['POST'])
@@ -7764,9 +7779,11 @@ def enable_ap_mode():
'message': message 'message': message
}), 400 }), 400
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/ap/disable', methods=['POST']) @api_v3.route('/wifi/ap/disable', methods=['POST'])
@@ -7789,9 +7806,11 @@ def disable_ap_mode():
'message': message 'message': message
}), 400 }), 400
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['GET']) @api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
@@ -7810,9 +7829,11 @@ def get_auto_enable_ap_mode():
} }
}) })
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['POST']) @api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
@@ -7842,9 +7863,11 @@ def set_auto_enable_ap_mode():
} }
}) })
except Exception as e: except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/radio', methods=['GET']) @api_v3.route('/wifi/radio', methods=['GET'])
@@ -7864,7 +7887,7 @@ def get_wifi_radio():
logger.error("Error getting WiFi radio state", exc_info=True) logger.error("Error getting WiFi radio state", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/wifi/radio', methods=['POST']) @api_v3.route('/wifi/radio', methods=['POST'])
@@ -7912,7 +7935,7 @@ def set_wifi_radio():
logger.error("Error setting WiFi radio state", exc_info=True) logger.error("Error setting WiFi radio state", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500 }), 500
@api_v3.route('/cache/list', methods=['GET']) @api_v3.route('/cache/list', methods=['GET'])
@@ -7937,7 +7960,7 @@ def list_cache_files():
}) })
except Exception as e: except Exception as e:
logger.error('Error in list_cache_files', exc_info=True) logger.error('Error in list_cache_files', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/cache/delete', methods=['POST']) @api_v3.route('/cache/delete', methods=['POST'])
def delete_cache_file(): def delete_cache_file():
@@ -7963,7 +7986,7 @@ def delete_cache_file():
}) })
except Exception as e: except Exception as e:
logger.error('Error in delete_cache_file', exc_info=True) logger.error('Error in delete_cache_file', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
# ============================================================================= # =============================================================================