-
Notifications
You must be signed in to change notification settings - Fork 3.7k
(deepgram stt): detect a silently dropped socket instead of hanging #7206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| except Exception as e: | ||
| logger.warning(f"Deepgram keepalive task exited: {e}") | ||
| return | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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: |
||
|
|
||
| if msg.type != aiohttp.WSMsgType.TEXT: | ||
| logger.warning("unexpected deepgram message type %s", msg.type) | ||
| continue | ||
|
|
@@ -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, | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
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.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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 raiseClientConnectionResetError,ClientOSErrororConnectionResetError. I rendered each throughAPIError.__str__and checked bothstr()andrepr()for an API key and for the URL:str()repr()ClientConnectionResetErrorClientOSErrorConnectionResetErrorClientResponseError(for contrast)ClientResponseErroris the one that carries credentials, becauseRequestInfo.reprprints theAuthorizationheader. 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_wswithfrom 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_tasklogging a bare "connection closed unexpectedly" with no indication of why.Happy to add an explicit
isinstanceguard againstClientResponseErrorif you would prefer it belt-and-braces, but as written I believe it is unreachable.