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
This commit is contained in:
ChuckBuilds
2026-08-10 12:26:53 -04:00
co-authored by Claude Opus 5
parent 0b9f5e2236
commit 376f248b1e
2 changed files with 61 additions and 0 deletions
+48
View File
@@ -150,6 +150,54 @@ class TestHandlersCarryDetail:
"handlers returning the generic message without %s: %r" "handlers returning the generic message without %s: %r"
% ("both a traceback log and the detail", offenders)) % ("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): def test_global_handler_reports_the_underlying_error(self):
from flask import Flask, jsonify from flask import Flask, jsonify
+13
View File
@@ -17,6 +17,7 @@ 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 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
@@ -416,6 +417,18 @@ def handle_exception(error):
`[Errno 5] Input/output error`. Naming the error costs nothing here and is `[Errno 5] Input/output error`. Naming the error costs nothing here and is
frequently the whole diagnosis, so include it alongside the log pointer. 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)