From 35b30ebf093a795e1132ffac4ae58d87258123df Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Wed, 27 May 2026 07:54:23 -0700 Subject: [PATCH] fix(websockets): use is_ws_alive across modern websockets>=12 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parked-WS-liveness checks in the SDK still used `ws.closed`, removed from the `websockets` library in v12. Patter pins `websockets>=14,<16` in pyproject.toml, so calls crashed immediately with: AttributeError: 'ClientConnection' object has no attribute 'closed' Promote the existing `_is_parked_ws_alive` helper out of stream_handler into `getpatter/utils/ws.py` as `is_ws_alive`, and use it at every WS liveness check that goes through the `websockets` library: - stream_handler.py:2192 (TTS WS adoption) - stream_handler.py:2230 (STT WS adoption) - providers/elevenlabs_ws_tts.py:453 (parked WS pickup in synthesize) aiohttp-backed providers (assemblyai_stt, cartesia_stt, soniox_stt) still call `.closed` because `aiohttp.ClientWebSocketResponse.closed` remains a valid property — they are unaffected. Adds 8 unit tests covering modern (`state`, `close_code`) and legacy (`closed`) shapes, plus the fail-closed default for unknown shapes. Closes #111. --- .../getpatter/providers/elevenlabs_ws_tts.py | 3 +- libraries/python/getpatter/stream_handler.py | 28 +------ libraries/python/getpatter/utils/ws.py | 34 ++++++++ libraries/python/tests/test_utils_ws.py | 84 +++++++++++++++++++ 4 files changed, 123 insertions(+), 26 deletions(-) create mode 100644 libraries/python/getpatter/utils/ws.py create mode 100644 libraries/python/tests/test_utils_ws.py diff --git a/libraries/python/getpatter/providers/elevenlabs_ws_tts.py b/libraries/python/getpatter/providers/elevenlabs_ws_tts.py index ae1f969f..d832d409 100644 --- a/libraries/python/getpatter/providers/elevenlabs_ws_tts.py +++ b/libraries/python/getpatter/providers/elevenlabs_ws_tts.py @@ -59,6 +59,7 @@ ElevenLabsOutputFormat, resolve_voice_id, ) +from getpatter.utils.ws import is_ws_alive logger = logging.getLogger("getpatter") @@ -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: diff --git a/libraries/python/getpatter/stream_handler.py b/libraries/python/getpatter/stream_handler.py index 7f87983b..9efdde84 100644 --- a/libraries/python/getpatter/stream_handler.py +++ b/libraries/python/getpatter/stream_handler.py @@ -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 @@ -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) @@ -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( diff --git a/libraries/python/getpatter/utils/ws.py b/libraries/python/getpatter/utils/ws.py new file mode 100644 index 00000000..4179becd --- /dev/null +++ b/libraries/python/getpatter/utils/ws.py @@ -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) diff --git a/libraries/python/tests/test_utils_ws.py b/libraries/python/tests/test_utils_ws.py new file mode 100644 index 00000000..5bc1d13b --- /dev/null +++ b/libraries/python/tests/test_utils_ws.py @@ -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