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
|
import logging
|
||||||
|
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed, InvalidMessage
|
||||||
|
|
||||||
OPENING_HANDSHAKE_FAILED_MESSAGE = "opening handshake failed"
|
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()
|
seen: set[int] = set()
|
||||||
while exc is not None:
|
while exc is not None:
|
||||||
ident = id(exc)
|
ident = id(exc)
|
||||||
if ident in seen:
|
if ident in seen:
|
||||||
return False
|
return False
|
||||||
seen.add(ident)
|
seen.add(ident)
|
||||||
if isinstance(exc, (
|
if isinstance(exc, types):
|
||||||
BrokenPipeError,
|
|
||||||
ConnectionAbortedError,
|
|
||||||
ConnectionResetError,
|
|
||||||
ConnectionClosed,
|
|
||||||
EOFError,
|
|
||||||
)):
|
|
||||||
return True
|
return True
|
||||||
exc = exc.__cause__ or exc.__context__
|
exc = exc.__cause__ or exc.__context__
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class WebSocketHandshakeNoiseFilter(logging.Filter):
|
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:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
if record.getMessage() != OPENING_HANDSHAKE_FAILED_MESSAGE:
|
if record.getMessage() != OPENING_HANDSHAKE_FAILED_MESSAGE:
|
||||||
return True
|
return True
|
||||||
exc_info = record.exc_info
|
exc_info = record.exc_info
|
||||||
exc = exc_info[1] if isinstance(exc_info, tuple) and len(exc_info) >= 2 else None
|
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:
|
def websockets_server_logger() -> logging.Logger:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from websockets.exceptions import InvalidMessage
|
||||||
|
|
||||||
from nanobot.webui.websocket_logging import (
|
from nanobot.webui.websocket_logging import (
|
||||||
OPENING_HANDSHAKE_FAILED_MESSAGE,
|
OPENING_HANDSHAKE_FAILED_MESSAGE,
|
||||||
WebSocketHandshakeNoiseFilter,
|
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))
|
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:
|
def test_websocket_handshake_noise_filter_keeps_real_errors() -> None:
|
||||||
filter_ = WebSocketHandshakeNoiseFilter()
|
filter_ = WebSocketHandshakeNoiseFilter()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user