From c062e1af14811f95533152f99616679550b29fdf Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Wed, 15 Jul 2026 15:11:33 -0700 Subject: [PATCH] fix(webui): quiet non-WebSocket handshake noise on public port The WebSocket channel also serves the WebUI over plain HTTP, so on a public endpoint (e.g. a Render *.onrender.com service) the underlying websockets library logs a full-traceback ERROR for every request that isn't a valid GET handshake: HEAD probes ("unsupported HTTP method; expected GET"), port scanners, uptime monitors, and TLS-to-plain-port attempts. These are internet background noise, not server faults. WebSocketHandshakeNoiseFilter already suppressed "opening handshake failed" records caused by mid-handshake disconnects; widen it to also suppress records whose exception chain contains websockets' InvalidMessage, which covers both non-GET methods and malformed/empty requests. Genuine server-side handshake errors still log. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/webui/websocket_logging.py | 42 +++++++++++++++------ tests/utils/test_webui_websocket_logging.py | 19 ++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/nanobot/webui/websocket_logging.py b/nanobot/webui/websocket_logging.py index a9f3ef9b..8c99f823 100644 --- a/nanobot/webui/websocket_logging.py +++ b/nanobot/webui/websocket_logging.py @@ -4,39 +4,59 @@ from __future__ import annotations import logging -from websockets.exceptions import ConnectionClosed +from websockets.exceptions import ConnectionClosed, InvalidMessage OPENING_HANDSHAKE_FAILED_MESSAGE = "opening handshake failed" +# Exceptions that mean the browser hung up mid-handshake (e.g. a restart races an +# open tab) rather than a server fault. +_DISCONNECT_TYPES: tuple[type[BaseException], ...] = ( + BrokenPipeError, + ConnectionAbortedError, + ConnectionResetError, + ConnectionClosed, + EOFError, +) -def _exception_chain_has_disconnect(exc: BaseException | None) -> bool: +# InvalidMessage is raised when the peer never sends a valid WebSocket/HTTP-GET +# opening handshake: HEAD probes ("unsupported HTTP method; expected GET"), +# port scanners, uptime monitors, and TLS-to-plain-port attempts. On a public +# endpoint (e.g. a Render *.onrender.com service) this is constant background +# noise from the internet, not an operational error. +_MALFORMED_HANDSHAKE_TYPES: tuple[type[BaseException], ...] = (InvalidMessage,) + +_SUPPRESSED_TYPES: tuple[type[BaseException], ...] = ( + _DISCONNECT_TYPES + _MALFORMED_HANDSHAKE_TYPES +) + + +def _exception_chain_has(exc: BaseException | None, types: tuple[type[BaseException], ...]) -> bool: seen: set[int] = set() while exc is not None: ident = id(exc) if ident in seen: return False seen.add(ident) - if isinstance(exc, ( - BrokenPipeError, - ConnectionAbortedError, - ConnectionResetError, - ConnectionClosed, - EOFError, - )): + if isinstance(exc, types): return True exc = exc.__cause__ or exc.__context__ return False class WebSocketHandshakeNoiseFilter(logging.Filter): - """Suppress restart-time handshakes where the browser already disconnected.""" + """Suppress opening-handshake failures that are peer noise, not server faults. + + Covers browsers that disconnect during a restart and non-WebSocket requests + (HEAD probes, port scanners, monitors) that hit the public port. Genuine + server-side handshake errors are left to log. + """ def filter(self, record: logging.LogRecord) -> bool: if record.getMessage() != OPENING_HANDSHAKE_FAILED_MESSAGE: return True exc_info = record.exc_info exc = exc_info[1] if isinstance(exc_info, tuple) and len(exc_info) >= 2 else None - return not _exception_chain_has_disconnect(exc) + return not _exception_chain_has(exc, _SUPPRESSED_TYPES) def websockets_server_logger() -> logging.Logger: diff --git a/tests/utils/test_webui_websocket_logging.py b/tests/utils/test_webui_websocket_logging.py index 28e4c985..26778cd0 100644 --- a/tests/utils/test_webui_websocket_logging.py +++ b/tests/utils/test_webui_websocket_logging.py @@ -4,6 +4,8 @@ from __future__ import annotations import logging +from websockets.exceptions import InvalidMessage + from nanobot.webui.websocket_logging import ( OPENING_HANDSHAKE_FAILED_MESSAGE, WebSocketHandshakeNoiseFilter, @@ -34,6 +36,23 @@ def test_websocket_handshake_noise_filter_suppresses_disconnects() -> None: assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, empty_handshake)) +def test_websocket_handshake_noise_filter_suppresses_non_get_probes() -> None: + """HEAD probes reach the WS port as InvalidMessage wrapping a ValueError.""" + filter_ = WebSocketHandshakeNoiseFilter() + head_probe = InvalidMessage("did not receive a valid HTTP request") + head_probe.__cause__ = ValueError("unsupported HTTP method; expected GET; got HEAD") + + assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, head_probe)) + + +def test_websocket_handshake_noise_filter_suppresses_malformed_requests() -> None: + """Port scanners / TLS-to-plain-port probes raise a bare InvalidMessage.""" + filter_ = WebSocketHandshakeNoiseFilter() + malformed = InvalidMessage("did not receive a valid HTTP request") + + assert not filter_.filter(_log_record(OPENING_HANDSHAKE_FAILED_MESSAGE, malformed)) + + def test_websocket_handshake_noise_filter_keeps_real_errors() -> None: filter_ = WebSocketHandshakeNoiseFilter()