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)
|
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:
|
def is_auth_enabled() -> bool:
|
||||||
"""Return True when at least one API key is configured."""
|
"""Return True when at least one API key is configured."""
|
||||||
return bool(get_config().auth.api_keys)
|
return bool(get_config().auth.api_keys)
|
||||||
@@ -181,7 +194,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -190,7 +203,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
|||||||
if label is None:
|
if label is None:
|
||||||
try:
|
try:
|
||||||
await websocket.close(code=WS_AUTH_CLOSE_CODE)
|
await websocket.close(code=WS_AUTH_CLOSE_CODE)
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
return label
|
return label
|
||||||
@@ -254,20 +267,29 @@ async def verify_ws_auth(
|
|||||||
# Loopback anonymous: no auth message arrived, but none is required.
|
# Loopback anonymous: no auth message arrived, but none is required.
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_ok"})
|
await websocket.send_json({"type": "auth_ok"})
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
return None
|
return None
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
return None
|
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)
|
logger.debug("WebSocket auth receive error: %s", exc)
|
||||||
return None
|
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.
|
# Parse the auth message.
|
||||||
try:
|
try:
|
||||||
@@ -277,7 +299,7 @@ async def verify_ws_auth(
|
|||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "auth_error", "reason": "invalid JSON in auth message"}
|
{"type": "auth_error", "reason": "invalid JSON in auth message"}
|
||||||
)
|
)
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -286,7 +308,7 @@ async def verify_ws_auth(
|
|||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
|
{"type": "auth_error", "reason": "first message must be {type:'auth'}"}
|
||||||
)
|
)
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -296,7 +318,7 @@ async def verify_ws_auth(
|
|||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "auth_error", "reason": "token must be a string or null"}
|
{"type": "auth_error", "reason": "token must be a string or null"}
|
||||||
)
|
)
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -313,7 +335,7 @@ async def verify_ws_auth(
|
|||||||
"reason": "LAN access requires an API key",
|
"reason": "LAN access requires an API key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -323,13 +345,13 @@ async def verify_ws_auth(
|
|||||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_ok"})
|
await websocket.send_json({"type": "auth_ok"})
|
||||||
except Exception:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
return None
|
return None
|
||||||
logger.debug("WebSocket authenticated as: %s", label)
|
logger.debug("WebSocket authenticated as: %s", label)
|
||||||
return label
|
return label
|
||||||
|
|||||||
Reference in New Issue
Block a user