From 65273bdb906445012d9c5742efa98887aa32a59f Mon Sep 17 00:00:00 2001 From: Shreyaaaash Date: Sun, 6 Sep 2026 14:49:21 +0530 Subject: [PATCH 1/2] fix: expose WebSocket close metadata in RealtimeEvents.CLOSE handler - Pass close_code and close_reason to CLOSE event handlers - Update docstring to document the new callback signature for CLOSE events - Fixes issue where CLOSE handlers received zero arguments, making it impossible to distinguish normal closes from errors --- src/elevenlabs/realtime/connection.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/elevenlabs/realtime/connection.py b/src/elevenlabs/realtime/connection.py index 5f70c4a8..ae7b5756 100644 --- a/src/elevenlabs/realtime/connection.py +++ b/src/elevenlabs/realtime/connection.py @@ -87,6 +87,10 @@ def handle_transcript(data): connection.on(RealtimeEvents.PARTIAL_TRANSCRIPT, handle_transcript) ``` + + Note: + For RealtimeEvents.CLOSE, the callback receives two arguments: + (close_code: int | None, close_reason: str | None) """ if event not in self._event_handlers: self._event_handlers[event] = [] @@ -146,7 +150,9 @@ async def _start_message_handler(self) -> None: except Exception as e: self._emit(RealtimeEvents.ERROR, {"error": str(e)}) finally: - self._emit(RealtimeEvents.CLOSE) + close_code = getattr(self.websocket, 'close_code', None) + close_reason = getattr(self.websocket, 'close_reason', None) + self._emit(RealtimeEvents.CLOSE, close_code, close_reason) async def send(self, data: typing.Dict[str, typing.Any]) -> None: """ From 7396655d5f448ab37e7aaf35625a191775259edd Mon Sep 17 00:00:00 2001 From: Shreyaaaash Date: Sun, 6 Sep 2026 14:59:49 +0530 Subject: [PATCH 2/2] fix: properly expose WebSocket close metadata in RealtimeEvents.CLOSE handler - Add exception handling for ConnectionClosed to extract close code and reason - Works with websockets >=13.0 by catching ConnectionClosed exceptions - Extracts close information from received or sent close frames - Stores close code/reason in instance variables for use in finally block - Emits ERROR event for abnormal closures (ConnectionClosedError) - Updates docstring to document CLOSE handler signature - Fixes issue where CLOSE handlers received zero arguments, making it impossible to distinguish normal closes from errors --- src/elevenlabs/realtime/connection.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/elevenlabs/realtime/connection.py b/src/elevenlabs/realtime/connection.py index ae7b5756..eb8ef9d1 100644 --- a/src/elevenlabs/realtime/connection.py +++ b/src/elevenlabs/realtime/connection.py @@ -7,6 +7,13 @@ if typing.TYPE_CHECKING: from websockets.asyncio.client import ClientConnection +# Import websockets exceptions for close code/reason handling +try: + from websockets.exceptions import ConnectionClosed, ConnectionClosedOK, ConnectionClosedError +except ImportError: + # Fallback for older versions + ConnectionClosed = ConnectionClosedOK = ConnectionClosedError = Exception # type: ignore + class RealtimeEvents(str, Enum): """Events emitted by the RealtimeConnection""" @@ -71,6 +78,8 @@ def __init__(self, websocket: "ClientConnection", current_sample_rate: int, ffmp self.ffmpeg_process = ffmpeg_process self._event_handlers: typing.Dict[str, typing.List[typing.Callable]] = {} self._message_task: typing.Optional[asyncio.Task] = None + self._close_code: typing.Optional[int] = None + self._close_reason: typing.Optional[str] = None def on(self, event: str, callback: typing.Callable) -> None: """ @@ -147,11 +156,24 @@ async def _start_message_handler(self) -> None: pass except json.JSONDecodeError as e: self._emit(RealtimeEvents.ERROR, {"error": f"Failed to parse message: {e}"}) + except ConnectionClosed as e: + # Handle websocket close event to extract close code and reason + if hasattr(e, 'rcvd') and e.rcvd is not None: + self._close_code = e.rcvd.code + self._close_reason = e.rcvd.reason + elif hasattr(e, 'sent') and e.sent is not None: + # Fallback to sent close frame if received is not available + self._close_code = e.sent.code + self._close_reason = e.sent.reason + # Emit error if this was an abnormal closure + if isinstance(e, ConnectionClosedError) and e.rcvd is not None: + self._emit(RealtimeEvents.ERROR, {"error": f"WebSocket connection closed with error: {e.rcvd.code} {e.rcvd.reason}"}) except Exception as e: self._emit(RealtimeEvents.ERROR, {"error": str(e)}) finally: - close_code = getattr(self.websocket, 'close_code', None) - close_reason = getattr(self.websocket, 'close_reason', None) + # Use the stored close code and reason, falling back to websocket attributes if available + close_code = self._close_code if self._close_code is not None else getattr(self.websocket, 'close_code', None) + close_reason = self._close_reason if self._close_reason is not None else getattr(self.websocket, 'close_reason', None) self._emit(RealtimeEvents.CLOSE, close_code, close_reason) async def send(self, data: typing.Dict[str, typing.Any]) -> None: