@@ -586,35 +586,59 @@ async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
586586 raise
587587
588588 async def send_websocket_message (self , message : dict [str , Any ]) -> dict [str , Any ]:
589- """Send message via WebSocket and wait for response."""
590- ws_client = None
591- try :
592- # Use client's own URL and token for WebSocket connection
593- from .websocket_client import HomeAssistantWebSocketClient
589+ """Send message via WebSocket and wait for response.
590+
591+ Uses the global WebSocket singleton to avoid race conditions from
592+ parallel tool calls creating multiple simultaneous connections.
593+ """
594+ from .websocket_client import get_websocket_client
594595
595- ws_client = HomeAssistantWebSocketClient (self .base_url , self .token )
596+ max_retries = 2
597+ retry_delay = 0.5 # seconds
596598
597- # Connect if not already connected
598- if not ws_client .is_connected :
599- await ws_client .connect ()
599+ for attempt in range (max_retries ):
600+ try :
601+ # Use singleton WebSocket client (shared, reused connection)
602+ ws_client = await get_websocket_client ()
600603
601- # Special handling for render_template which returns an event with the actual result
602- if message .get ("type" ) == "render_template" :
603- return await self ._handle_render_template (ws_client , message )
604+ # Special handling for render_template which returns an event with the actual result
605+ if message .get ("type" ) == "render_template" :
606+ return await self ._handle_render_template (ws_client , message )
604607
605- # Extract command type and parameters for other commands
606- message_copy = message .copy ()
607- command_type = message_copy .pop ("type" )
608- result = await ws_client .send_command (command_type , ** message_copy )
608+ # Extract command type and parameters for other commands
609+ message_copy = message .copy ()
610+ command_type = message_copy .pop ("type" )
611+ result = await ws_client .send_command (command_type , ** message_copy )
609612
610- return result
611- except Exception as e :
612- logger .error (f"WebSocket message failed: { e } " )
613- return {"success" : False , "error" : str (e )}
614- finally :
615- # Clean up WebSocket connection
616- if ws_client and ws_client .is_connected :
617- await ws_client .disconnect ()
613+ return result
614+
615+ except Exception as e :
616+ error_str = str (e )
617+
618+ # Detect transient 403 errors (rate limiting / reverse proxy throttling)
619+ if "403" in error_str and "Forbidden" in error_str :
620+ if attempt < max_retries - 1 :
621+ logger .warning (
622+ f"WebSocket 403 error (attempt { attempt + 1 } /{ max_retries } ), "
623+ f"retrying after { retry_delay } s: { error_str } "
624+ )
625+ await asyncio .sleep (retry_delay )
626+ continue
627+ else :
628+ logger .error (f"WebSocket 403 error after { max_retries } attempts: { error_str } " )
629+ return {
630+ "success" : False ,
631+ "error" : f"WebSocket request blocked (403 Forbidden): { error_str } " ,
632+ "suggestions" : [
633+ "This may be caused by a reverse proxy or security filter" ,
634+ "Try simplifying the request (e.g., shorter templates, fewer parameters)" ,
635+ "If using complex templates, try breaking them into smaller parts" ,
636+ "Check if your Home Assistant is behind a reverse proxy with security rules" ,
637+ ],
638+ }
639+
640+ logger .error (f"WebSocket message failed: { e } " )
641+ return {"success" : False , "error" : str (e )}
618642
619643 async def _handle_render_template (
620644 self , ws_client : Any , message : dict [str , Any ]
0 commit comments