Skip to content

fix(stt): detect half-open websockets in OpenAI, ElevenLabs and Inworld STT - #7912

Closed
ninglinLiu wants to merge 4 commits into
livekit:mainfrom
ninglinLiu:cursor/fix-halfopen-websocket-stt-193a
Closed

ninglinLiu wants to merge 4 commits into
livekit:mainfrom
ninglinLiu:cursor/fix-halfopen-websocket-stt-193a

Conversation

@ninglinLiu

@ninglinLiu ninglinLiu commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

If a streaming STT websocket goes half-open (the provider, or a NAT in between, disappears without a FIN/RST), the OpenAI realtime, ElevenLabs and Inworld STT streams wait on ws.receive() forever. Nothing raises, so the stream never reconnects and stt.FallbackAdapter never fails over; the agent just stops hearing the user (#7900).

Deepgram, Soniox, Telnyx, xAI and Simplismart were already fixed the same way (#7206, #7357, #7359, #7364, #7369).

Fix

Same pattern as those merged fixes, applied to the three remaining plugins:

  1. Pass heartbeat=30.0 to ws_connect in openai/stt.py, elevenlabs/stt.py and inworld/stt.py. aiohttp defaults heartbeat to None, so without it an unanswered socket is never noticed.
  2. Treat WSMsgType.ERROR in the receive loop as a retryable APIConnectionError, chained from ws.exception(). That is where aiohttp reports the missed pong. It already returns quietly when we are closing the socket ourselves. Before this change, OpenAI/ElevenLabs logged it as an "unexpected message type" and continued, and Inworld logged it and returned without triggering a reconnect.

I kept the change inline per plugin, the same way as the existing fixes, and kept the 30s interval they use. The issue asks whether a shared helper in livekit.agents.utils and/or a shorter interval would be better. I'm happy to rework this PR either way if you prefer.

Tests

Unit tests live in each plugin's existing test file, sharing one set of fake aiohttp sockets and session in tests/fake_ws.py:

  • tests/test_plugin_openai_stt_context.py
  • tests/test_plugin_elevenlabs_stt.py
  • tests/test_plugin_inworld_stt.py (new; Inworld had no STT test file yet)

Each checks that the socket is opened with heartbeat=30.0, and that a heartbeat-timeout ERROR frame ends the receive loop and leads to a reconnect without spinning.

uv run pytest tests/test_plugin_openai_stt_context.py tests/test_plugin_elevenlabs_stt.py tests/test_plugin_inworld_stt.py
ruff check . && ruff format --check .

Fixes #7900

cursoragent and others added 2 commits October 17, 2026 14:17
…rld STT plugins

Add heartbeat=30.0 to ws_connect and handle WSMsgType.ERROR by raising
retryable APIConnectionError in OpenAI realtime, ElevenLabs, and Inworld
STT plugins. Without heartbeat, a silently dropped socket (half-open TCP
connection, no FIN/RST) is never noticed: recv_task parks on ws.receive()
forever and the reconnect loop, which is exception-driven, never runs.

The heartbeat closes the socket when a ping goes unanswered, and that
surfaces as WSMsgType.ERROR. ws.exception() is the only place the reason
survives, so we chain it to the APIConnectionError.

Add regression tests for each plugin proving they notice a silent websocket
via heartbeat and raise retryable APIConnectionError within bounded time.

Fixes livekit#7900

Co-authored-by: NingLin Liu <x07514475479@gmail.com>
Run ruff format on 4 files per upstream CI requirements:
- livekit-plugins-elevenlabs/livekit/plugins/elevenlabs/stt.py
- tests/test_plugin_openai_stt_halfopen.py
- tests/test_plugin_elevenlabs_stt_halfopen.py
- tests/test_plugin_inworld_stt_halfopen.py

Restructured the Inworld test's _stream function to keep the
type: ignore[arg-type] comment on the http_session argument line
where mypy will apply it, while satisfying ruff format requirements.

Co-authored-by: NingLin Liu <x07514475479@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@DEVJHAWAR11

Copy link
Copy Markdown
Contributor

Ran the probe from #7900 against this branch, and it works:

plugin main this PR
openai STT no error after 75s APIConnectionError after 47.5s
elevenlabs STT no error after 75s APIConnectionError after 45.9s
inworld STT no error after 75s APIConnectionError after 45.9s

So it now matches the other fixed plugins at about 45s. I'm happy to follow up with the shared helper and a shorter heartbeat across all the STT plugins if that's the direction you want, so it can land separately from this one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

let's move the tests into the main plugin tests.. vs a dedicated file for halfopen.

also there are a ton of duplications of helper functions/etc in these tests.

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.

Done in edc69d4. The tests now live in each plugin's existing test file (test_plugin_openai_stt_context.py, test_plugin_elevenlabs_stt.py, and a new test_plugin_inworld_stt.py since Inworld had no STT test file), and the fake sockets, session and wait_until helper are shared from tests/fake_ws.py instead of being copied three times. The _halfopen files are gone.

Copy link
Copy Markdown
Member

Thanks @ninglinLiu for the effort here! We're handling WebSocket ERROR frames centrally in the liveness monitor in #8147, so closing this in favor of that.

@davidzhao davidzhao closed this Oct 26, 2026
@ninglinLiu
ninglinLiu deleted the cursor/fix-halfopen-websocket-stt-193a branch October 26, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming STT: openai, elevenlabs and inworld never notice a silently dropped websocket, and the fixed plugins take ~45s

4 participants