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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user