Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/elevenlabs/realtime/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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:
"""
Expand All @@ -87,6 +96,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] = []
Expand Down Expand Up @@ -143,10 +156,25 @@ 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}"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped connections skip ERROR event

Medium Severity

The new except ConnectionClosed block intercepts ConnectionClosedError that previously reached except Exception and always emitted ERROR. It now emits ERROR only when e.rcvd is set, so a dropped connection (rcvd is None) no longer fires ERROR and still leaves close metadata empty.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7396655. Configure here.

except Exception as e:
self._emit(RealtimeEvents.ERROR, {"error": str(e)})
finally:
self._emit(RealtimeEvents.CLOSE)
# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLOSE metadata missing on normal close

High Severity

async for on the asyncio client swallows ConnectionClosedOK, so the new handler never records a clean close. _close_code and _close_reason stay unset, and the close_code fallback is missing on ClientConnection before websockets 14.1. CLOSE still receives None, None on supported 13.x, so normal closes remain indistinguishable from drops.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7396655. Configure here.

self._emit(RealtimeEvents.CLOSE, close_code, close_reason)
Comment thread
cursor[bot] marked this conversation as resolved.

async def send(self, data: typing.Dict[str, typing.Any]) -> None:
"""
Expand Down