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
Original file line number Diff line number Diff line change
Expand Up @@ -595,10 +595,19 @@ async def keepalive_task(ws: aiohttp.ClientWebSocketResponse) -> None:
# if we want to keep the connection alive even if no audio is sent,
# Deepgram expects a keepalive message.
# https://developers.deepgram.com/reference/listen-live#stream-keepalive
nonlocal closing_ws
try:
while True:
await ws.send_str(SpeechStream._KEEPALIVE_MSG)
await asyncio.sleep(5)
except (aiohttp.ClientError, ConnectionError) as e:
# when no audio is flowing this write is the only thing touching the
# socket, so it is where a drop surfaces first. if the close is
# expected just return; otherwise re-raise as a retryable APIError so
# _main_task reconnects, symmetric with send_task and recv_task.
if closing_ws or self._session.closed:
return
raise APIConnectionError("deepgram connection closed unexpectedly") from e
Comment on lines +603 to +610

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 Keepalive failures expose raw exception data

Keepalive failures preserve raw aiohttp exceptions in __cause__. Task and retry logs can expose URLs, credentials, or provider data from that cause.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked this against the types that can actually reach here, and I don't think it holds for this path, though the concern class is a real one in this repo (#6739, #7031).

send_str() on an established websocket can raise ClientConnectionResetError, ClientOSError or ConnectionResetError. I rendered each through APIError.__str__ and checked both str() and repr() for an API key and for the URL:

cause str() repr()
ClientConnectionResetError clean clean
ClientOSError clean clean
ConnectionResetError clean clean
ClientResponseError (for contrast) clean leaks the key

ClientResponseError is the one that carries credentials, because RequestInfo.repr prints the Authorization header. That is the #6739 leak. It is raised during the HTTP request/response cycle, not by a frame write on an already-upgraded socket, so it cannot arise here, and it is already guarded at the site where it can, in _connect_ws with from None.

Worth noting too that APIError.__str__ deliberately walks __cause__, on the reasoning that the root of the chain is the only place the real failure is named. Dropping the cause here would leave _main_task logging a bare "connection closed unexpectedly" with no indication of why.

Happy to add an explicit isinstance guard against ClientResponseError if you would prefer it belt-and-braces, but as written I believe it is unreachable.

except Exception as e:
logger.warning(f"Deepgram keepalive task exited: {e}")
return
Expand Down Expand Up @@ -668,6 +677,15 @@ async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
body=f"{msg.data=} {msg.extra=}",
)

if msg.type == aiohttp.WSMsgType.ERROR:
if closing_ws or self._session.closed:
return

# the heartbeat closes the socket when a ping goes unanswered,
# and that surfaces here rather than as a close frame.
# ws.exception() is the only place the reason survives.
raise APIConnectionError("deepgram connection lost") from ws.exception()
Comment on lines +680 to +687

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 Heartbeat failures expose raw exception data

WSMsgType.ERROR preserves ws.exception() in __cause__. Task and retry logs can expose URLs, credentials, or provider data in both streams.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same as the keepalive comment above: I don't think this one reaches a credential-bearing exception either.

ws.exception() on a websocket that has already completed its 101 upgrade returns reader and protocol errors: ServerTimeoutError for the heartbeat case this branch exists to catch, plus ClientPayloadError and WebSocketError. None of those carry a URL or headers in str() or repr(). The type that does, ClientResponseError, belongs to the handshake and is already handled with from None in _connect_ws.

Preserving the cause is the whole point of this branch. Without it a heartbeat timeout surfaces as a bare 1006 "closed unexpectedly", which is what happens on main today; with it the session error reads:

deepgram connection lost (caused by ServerTimeoutError: No PONG received after 15.0 seconds)


if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected deepgram message type %s", msg.type)
continue
Expand Down Expand Up @@ -772,6 +790,11 @@ async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
self._session.ws_connect(
_to_deepgram_url(live_config, base_url=self._opts.endpoint_url, websocket=True),
headers={"Authorization": f"Token {self._api_key}"},
# without this, a silently dropped socket (a half-open TCP
# connection, no FIN/RST) is never noticed: recv_task parks on
# ws.receive() forever and the reconnect loop below, which is
# exception-driven, never runs. matches stt_v2.
heartbeat=30.0,
),
self._conn_options.timeout,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,15 @@ async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
body=f"{msg.data=} {msg.extra=}",
)

if msg.type == aiohttp.WSMsgType.ERROR:
if closing_ws or self._session.closed:
return

# the heartbeat closes the socket when a ping goes unanswered,
# and that surfaces here rather than as a close frame.
# ws.exception() is the only place the reason survives.
raise APIConnectionError("deepgram connection lost") from ws.exception()

if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected deepgram message type %s", msg.type)
continue
Expand Down
150 changes: 150 additions & 0 deletions tests/test_plugin_deepgram_stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,153 @@ async def test_flush_finalizes_after_the_buffered_audio():
await _wait_until(lambda: ws.sent() == ["audio", "Finalize"])
finally:
await stream.aclose()


class _RecordingSession:
"""Stands in for the aiohttp session so the connect kwargs are assertable."""

def __init__(self) -> None:
self.closed = False
self.kwargs: dict = {}
self._ws = _LiveWS()

async def ws_connect(self, url: str, **kwargs):
self.kwargs = kwargs
return self._ws


class _DeadSocket:
"""A socket that has silently gone away: writes fail, the read side never notices.

This is the half-open case (no FIN, no RST). recv_task can only park, so the
keepalive write is the one place the drop can surface.
"""

def __init__(self) -> None:
self.closed = False
self._closed = asyncio.Event()

async def send_str(self, data: str) -> None:
import aiohttp

raise aiohttp.ClientConnectionResetError("Cannot write to closing transport")

async def send_bytes(self, data: bytes) -> None:
import aiohttp

raise aiohttp.ClientConnectionResetError("Cannot write to closing transport")

async def receive(self):
await self._closed.wait()
raise AssertionError("the test should never let recv_task resume")

async def close(self) -> None:
self._closed.set()


def _v1_stream(*, http_session=None, connect=None):
"""A real v1 SpeechStream running its real _run loop."""
import dataclasses
from typing import Any, cast

from livekit.agents import DEFAULT_API_CONNECT_OPTIONS
from livekit.plugins.deepgram.stt import STT, SpeechStream

instance = STT(api_key="test-key", language="en-US", sample_rate=16000)
stream = SpeechStream(
stt=instance,
opts=dataclasses.replace(instance._opts, sample_rate=16000),
conn_options=DEFAULT_API_CONNECT_OPTIONS,
api_key="test-key",
http_session=cast(Any, http_session or SimpleNamespace(closed=False)),
base_url="wss://api.deepgram.com/v1/listen",
)
if connect is not None:
stream._connect_ws = connect
return stream


async def test_v1_socket_is_opened_with_a_heartbeat():
# aiohttp defaults heartbeat and receive_timeout to None, so without this the
# read side of a half-open socket parks forever and the reconnect loop in _run,
# which only runs when something raises, never gets a turn. stt_v2 already does
# this; v1 is the Nova-3 path and was the only Deepgram stream left unbounded.
session = _RecordingSession()
stream = _v1_stream(http_session=session)
try:
await _wait_until(lambda: "heartbeat" in session.kwargs)
assert session.kwargs["heartbeat"] == 30.0
finally:
await stream.aclose()


async def test_keepalive_write_drop_reconnects_instead_of_stalling():
# the keepalive used to swallow every exception and return, which left send_task
# parked on the input channel and recv_task parked on receive(): _run stayed
# alive on a socket that was gone, and the session went quiet with no error.
sockets: list[_DeadSocket] = []

async def _connect():
ws = _DeadSocket()
sockets.append(ws)
return ws

stream = _v1_stream(connect=_connect)
try:
await _wait_until(lambda: len(sockets) > 1)
finally:
await stream.aclose()


class _HeartbeatTimeoutSocket:
"""A socket in the state aiohttp leaves it in when a ping goes unanswered.

The heartbeat closes the connection itself, so this arrives as WSMsgType.ERROR
rather than as a close frame, and the reason lives only on ws.exception().
"""

def __init__(self) -> None:
self.closed = False
self.receives = 0

async def send_str(self, data: str) -> None:
pass

async def send_bytes(self, data: bytes) -> None:
pass

def exception(self):
import aiohttp

return aiohttp.ServerTimeoutError("No PONG received after 15.0s")

async def receive(self):
import aiohttp

self.receives += 1
# yield, so that a recv loop which steps over this rather than ending
# fails the test instead of starving the event loop and hanging it
await asyncio.sleep(0)
return aiohttp.WSMessage(aiohttp.WSMsgType.ERROR, self.exception(), None)

async def close(self) -> None:
self.closed = True


async def test_heartbeat_timeout_reconnects_without_spinning():
# the ERROR has to end the recv loop. logging it as an unexpected type and
# continuing only works because aiohttp happens to report CLOSED next, and it
# throws away the one value that says why the socket went away.
sockets: list[_HeartbeatTimeoutSocket] = []

async def _connect():
ws = _HeartbeatTimeoutSocket()
sockets.append(ws)
return ws

stream = _v1_stream(connect=_connect)
try:
await _wait_until(lambda: len(sockets) > 1)
assert sockets[0].receives == 1
finally:
await stream.aclose()