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:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
c77379099b
commit
c062e1af14
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user