refactor(api/auth): narrow WS exception catches + observability log

The 11 except Exception sites around websocket.send_json and
websocket.close are now except _WS_SEND_BENIGN_EXC — a narrow tuple of
WebSocketDisconnect, RuntimeError, ConnectionError, OSError. Real
programming errors (AttributeError, TypeError) no longer silently
disappear inside the handshake path. The receive_text branch grows a
narrow `(RuntimeError, ConnectionError, OSError)` case plus a final
`except Exception: logger.exception(...)` catch-all so genuinely
unexpected error shapes are recorded with a stack trace instead of
being swallowed.
This commit is contained in:
2026-05-23 01:14:43 +03:00
parent d38021f061
commit ea7ee88490
+33 -11
View File
@@ -19,6 +19,19 @@ logger = get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Exceptions that legitimately fire when we try to send / close a WebSocket
# that is already shutting down: the peer dropped, the connect-state moved
# under us, the underlying socket is gone, the JSON encoder choked, etc.
# Keeping this tuple narrow means a genuine programming error (AttributeError,
# TypeError) bubbles up to the caller instead of silently disappearing.
_WS_SEND_BENIGN_EXC: tuple[type[BaseException], ...] = (
WebSocketDisconnect,
RuntimeError,
ConnectionError,
OSError,
)
def is_auth_enabled() -> bool:
"""Return True when at least one API key is configured."""
return bool(get_config().auth.api_keys)
@@ -181,7 +194,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -190,7 +203,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
if label is None:
try:
await websocket.close(code=WS_AUTH_CLOSE_CODE)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
return label
@@ -254,20 +267,29 @@ async def verify_ws_auth(
# Loopback anonymous: no auth message arrived, but none is required.
try:
await websocket.send_json({"type": "auth_ok"})
except Exception:
except _WS_SEND_BENIGN_EXC:
return None
return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
except WebSocketDisconnect:
return None
except Exception as exc:
except (RuntimeError, ConnectionError, OSError) as exc:
# The peer hung up mid-handshake or the underlying socket is gone.
# Promote anything outside this set to a hard failure with a stack
# trace so we can see real bugs (decode errors, type errors, …).
logger.debug("WebSocket auth receive error: %s", exc)
return None
except Exception:
# Unexpected — log the full traceback so we can see what we missed
# without leaving the connection half-open. Re-raise nothing; the
# caller will close on the None return.
logger.exception("Unexpected error during WebSocket auth handshake")
return None
# Parse the auth message.
try:
@@ -277,7 +299,7 @@ async def verify_ws_auth(
await websocket.send_json(
{"type": "auth_error", "reason": "invalid JSON in auth message"}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -286,7 +308,7 @@ async def verify_ws_auth(
await websocket.send_json(
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -296,7 +318,7 @@ async def verify_ws_auth(
await websocket.send_json(
{"type": "auth_error", "reason": "token must be a string or null"}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -313,7 +335,7 @@ async def verify_ws_auth(
"reason": "LAN access requires an API key",
}
)
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
@@ -323,13 +345,13 @@ async def verify_ws_auth(
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except Exception:
except _WS_SEND_BENIGN_EXC:
pass
return None
try:
await websocket.send_json({"type": "auth_ok"})
except Exception:
except _WS_SEND_BENIGN_EXC:
return None
logger.debug("WebSocket authenticated as: %s", label)
return label