From 376f248b1e31a3269a97d1cbb9a8796f06c33269 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 10 Aug 2026 12:26:53 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- test/test_web_error_detail.py | 48 +++++++++++++++++++++++++++++++++++ web_interface/app.py | 13 ++++++++++ 2 files changed, 61 insertions(+) diff --git a/test/test_web_error_detail.py b/test/test_web_error_detail.py index 282097c6..f193282a 100644 --- a/test/test_web_error_detail.py +++ b/test/test_web_error_detail.py @@ -150,6 +150,54 @@ class TestHandlersCarryDetail: "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 diff --git a/web_interface/app.py b/web_interface/app.py index ecfb7f39..8ed9cb8c 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -17,6 +17,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) 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.plugin_system.plugin_manager import PluginManager from src.plugin_system.store_manager import PluginStoreManager @@ -416,6 +417,18 @@ def handle_exception(error): `[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 logger = logging.getLogger('web_interface') logger.error("Unhandled exception", exc_info=True)