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) <noreply@anthropic.com>
This commit is contained in:
Ho1yShif
2026-07-18 17:39:59 +08:00
committed by Xubin Ren
co-authored by Claude Opus 4.8
parent c77379099b
commit c062e1af14
2 changed files with 50 additions and 11 deletions
+31 -11
View File
@@ -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:
@@ -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()