From ea7ee88490183a7cd55840f68b42bbdd3092fb03 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 23 May 2026 01:14:43 +0300 Subject: [PATCH] refactor(api/auth): narrow WS exception catches + observability log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/ledgrab/api/auth.py | 44 +++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index 3d10745..e22dcb8 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -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