Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion libraries/python/getpatter/providers/elevenlabs_ws_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
ElevenLabsOutputFormat,
resolve_voice_id,
)
from getpatter.utils.ws import is_ws_alive

logger = logging.getLogger("getpatter")

Expand Down Expand Up @@ -450,7 +451,7 @@ async def synthesize(self, text: str) -> AsyncGenerator[bytes, None]:
self._adopted_connection = None
bos_already_sent = False
ws = None
if parked is not None and not parked.ws.closed:
if parked is not None and is_ws_alive(parked.ws):
ws = parked.ws
bos_already_sent = parked.bos_sent
else:
Expand Down
28 changes: 3 additions & 25 deletions libraries/python/getpatter/stream_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,29 +47,7 @@
logger = logging.getLogger("getpatter")


def _is_parked_ws_alive(ws: object) -> bool:
"""Best-effort liveness check across ``websockets`` library versions.

The legacy client (``websockets<11``) exposes ``ws.closed: bool``.
The current asyncio client (``websockets>=12``) exposes ``ws.state``
(an ``IntEnum`` with ``OPEN == 1``) and ``ws.close_code`` (``None``
while still open). Return ``True`` only when we can confirm the
socket is OPEN — never default to True on unknown shapes, otherwise
we'd hand a dead socket to the live adapter.
"""
state = getattr(ws, "state", None)
if state is not None:
try:
return int(state) == 1
except Exception:
return getattr(state, "name", "").upper() == "OPEN"
close_code = getattr(ws, "close_code", "__unset__")
if close_code != "__unset__":
return close_code is None
closed = getattr(ws, "closed", None)
if closed is None:
return False
return not bool(closed)
from getpatter.utils.ws import is_ws_alive as _is_parked_ws_alive # noqa: E402


# Minimum wall-clock duration (seconds) the agent must have been speaking
Expand Down Expand Up @@ -2189,7 +2167,7 @@ async def start(self) -> None:
parked_tts = (parked or {}).get("tts")
if parked_tts is not None and self._tts is not None:
adopt = getattr(self._tts, "adopt_websocket", None)
ws_alive = parked_tts.ws is not None and not parked_tts.ws.closed
ws_alive = parked_tts.ws is not None and _is_parked_ws_alive(parked_tts.ws)
if callable(adopt) and ws_alive:
try:
adopt(parked_tts)
Expand Down Expand Up @@ -2227,7 +2205,7 @@ async def start(self) -> None:
and len(parked_stt) == 2
):
session, ws = parked_stt
if not ws.closed:
if _is_parked_ws_alive(ws):
try:
adopt_stt(session, ws)
logger.info(
Expand Down
34 changes: 34 additions & 0 deletions libraries/python/getpatter/utils/ws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""WebSocket compatibility utilities.

Patter pins ``websockets>=14,<16`` in ``pyproject.toml``. The modern
``websockets`` client (``websockets>=12``) no longer exposes a
``ws.closed`` property — it surfaces ``state`` (an ``IntEnum`` where
``OPEN == 1``) and ``close_code`` (``None`` while the socket is open).

This helper papers over both APIs so SDK code can ask *"is this
WebSocket still alive?"* without sprinkling version checks everywhere.
"""

from __future__ import annotations


def is_ws_alive(ws: object) -> bool:
"""Best-effort liveness check across ``websockets`` library versions.

Returns ``True`` only when we can confirm the socket is OPEN. Never
defaults to ``True`` on unknown shapes — handing a dead socket to a
live adapter is worse than re-opening a fresh one.
"""
state = getattr(ws, "state", None)
if state is not None:
try:
return int(state) == 1
except Exception:
return getattr(state, "name", "").upper() == "OPEN"
close_code = getattr(ws, "close_code", "__unset__")
if close_code != "__unset__":
return close_code is None
closed = getattr(ws, "closed", None)
if closed is None:
return False
return not bool(closed)
84 changes: 84 additions & 0 deletions libraries/python/tests/test_utils_ws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Compat helper covers both modern (``state``) and legacy (``closed``)
``websockets`` client shapes. Regression for upstream issue #111."""

from __future__ import annotations

from getpatter.utils.ws import is_ws_alive


class _ModernOpenWS:
"""Mimics ``websockets>=12`` `ClientConnection` (state=1 means OPEN)."""

state = 1


class _ModernClosedWS:
state = 3 # CLOSED


class _ModernByCloseCodeOpen:
"""Some shapes only expose ``close_code`` (None == still open)."""

close_code = None


class _ModernByCloseCodeClosed:
close_code = 1000


class _LegacyOpenWS:
"""Mimics ``websockets<11`` API (``closed`` bool property)."""

closed = False


class _LegacyClosedWS:
closed = True


class _UnknownShape:
"""Neither state nor close_code nor closed — must fail closed."""


def test_modern_open():
assert is_ws_alive(_ModernOpenWS()) is True


def test_modern_closed():
assert is_ws_alive(_ModernClosedWS()) is False


def test_modern_open_via_close_code():
assert is_ws_alive(_ModernByCloseCodeOpen()) is True


def test_modern_closed_via_close_code():
assert is_ws_alive(_ModernByCloseCodeClosed()) is False


def test_legacy_open():
assert is_ws_alive(_LegacyOpenWS()) is True


def test_legacy_closed():
assert is_ws_alive(_LegacyClosedWS()) is False


def test_unknown_shape_defaults_closed():
"""Unknown WS shapes must NOT be reported alive — handing a dead socket
to the live adapter is worse than re-opening."""
assert is_ws_alive(_UnknownShape()) is False


def test_state_intenum_open():
"""``state`` may be an IntEnum where OPEN == 1."""
from enum import IntEnum

class _State(IntEnum):
OPEN = 1
CLOSED = 3

class WS:
state = _State.OPEN

assert is_ws_alive(WS()) is True
Loading