Repository navigation
Streaming STT: openai, elevenlabs and inworld never notice a silently dropped websocket, and the fixed plugins take ~45s #7900
Description
Bug Description
If the STT websocket goes half-open (the provider or a NAT in between disappears without a FIN/RST), the OpenAI, ElevenLabs and Inworld STT streams wait on ws.receive() forever. Nothing raises, so the stream never reconnects and stt.FallbackAdapter never switches to the next provider. On a call, the agent just stops hearing the user.
Deepgram, Soniox, Telnyx, xAI and Simplismart got per-plugin fixes for this (#7206, #7357, #7359, #7364, #7369). Those work, but with heartbeat=30 it still takes about 45s to notice (a ping every 30s, then 15s waiting for the pong). That's 45s of dead air before failover.
I measured it with a local server that accepts the websocket upgrade, then never sends anything and never answers pings (script in the reproduction steps). The probe streams silence into each plugin with max_retry=0:
| plugin | time until the stream notices |
|---|---|
| openai STT (realtime) | no error after 75s, still waiting |
| elevenlabs STT | no error after 75s, still waiting |
| inworld STT | no error after 75s, still waiting |
| deepgram STT (fixed in #7206) | APIConnectionError after 45.4s (No PONG received after 15.0 seconds) |
TTS doesn't have this problem. ElevenLabs and Cartesia TTS both fail after 5s against the same server, because the TTS side already bounds the wait. STT can't use a plain read timeout, since a quiet user is the normal state (as pointed out in #5717). That leaves the ping/pong heartbeat as the right signal, so it's worth having it everywhere and with a shorter window.
Expected Behavior
A streaming STT whose websocket goes silent and stops answering pings should raise a retryable APIConnectionError within a bounded, reasonably short time. The stream would then reconnect, or FallbackAdapter would move to the next provider, the same way it already does for a socket that closes cleanly.
Reproduction Steps
# 1. pip install "livekit-agents[openai,elevenlabs,inworld,deepgram]"
# 2. save the script below as halfopen_probe.py
# 3. python halfopen_probe.py openai elevenlabs inworld deepgram
# No API keys or network access needed; everything talks to 127.0.0.1.
"""Half-open websocket probe for streaming STT plugins (halfopen_probe.py).
A local server accepts the websocket upgrade, then never sends anything and never
answers pings, like a provider (or a NAT in between) that vanished without FIN/RST.
The probe streams silence into the plugin and reports how long it takes to notice.
python halfopen_probe.py openai elevenlabs inworld deepgram
No API keys or network access needed.
"""
import asyncio
import sys
import time
import aiohttp
from aiohttp import web
from livekit import rtc
from livekit.agents import APIConnectOptions
from livekit.agents.utils import http_context
WATCH = 75.0
PORT = 8765
URL = f"http://127.0.0.1:{PORT}"
async def silent_ws(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(autoping=False) # never answer pings
await ws.prepare(request)
await asyncio.sleep(3600) # never send anything
return ws
def make_stt(name: str, http: aiohttp.ClientSession):
if name == "openai":
from livekit.plugins import openai
return openai.STT(api_key="x", base_url=URL + "/v1", use_realtime=True)
if name == "elevenlabs":
from livekit.plugins import elevenlabs
return elevenlabs.STT(api_key="x", base_url=URL + "/v1", http_session=http)
if name == "inworld":
from livekit.plugins import inworld
return inworld.STT(api_key="x", base_url=URL + "/", http_session=http)
if name == "deepgram":
from livekit.plugins import deepgram
return deepgram.STT(api_key="x", base_url=URL + "/v1/listen", http_session=http)
raise ValueError(name)
async def probe(name: str) -> str:
async with aiohttp.ClientSession() as http:
stt = make_stt(name, http)
stream = stt.stream(conn_options=APIConnectOptions(max_retry=0))
frame = rtc.AudioFrame(
data=b"\x00\x00" * 160, sample_rate=16000, num_channels=1, samples_per_channel=160
)
async def feed() -> None:
while True:
stream.push_frame(frame)
await asyncio.sleep(0.01)
async def consume() -> None:
async for _ in stream:
pass
feeder = asyncio.create_task(feed())
start = time.perf_counter()
try:
await asyncio.wait_for(consume(), WATCH)
result = f"stream ended after {time.perf_counter() - start:.1f}s"
except asyncio.TimeoutError:
result = f"no error after {WATCH:.0f}s, still waiting"
except Exception as e:
result = f"{type(e).__name__} after {time.perf_counter() - start:.1f}s"
feeder.cancel()
try:
await asyncio.wait_for(stream.aclose(), 5)
except Exception:
pass
return result
async def main(names: list[str]) -> None:
http_context._new_session_ctx()
app = web.Application()
app.router.add_route("GET", "/{tail:.*}", silent_ws)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "127.0.0.1", PORT).start()
try:
for name in names:
print(f"{name:12s} {await probe(name)}", flush=True)
finally:
await runner.cleanup()
await http_context._close_http_ctx()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1:] or ["openai", "elevenlabs", "inworld", "deepgram"]))Operating System
Windows 11 (the behavior is in the plugin code, not OS specific)
Models Used
openai STT (realtime), ElevenLabs STT, Inworld STT, Deepgram STT
Package Versions
livekit==1.1.20
livekit-agents==1.8.8 (main @ ceb3d59)
livekit-plugins-openai==1.8.8
livekit-plugins-elevenlabs==1.8.8
livekit-plugins-inworld==1.8.8
livekit-plugins-deepgram==1.8.8
aiohttp==3.14.3Session/Room/Call IDs
No response
Proposed Solution
Additional Context
Proposed solution
Every fix so far repeats the same two pieces in each plugin:
- passing
heartbeat=30.0tows_connect; - turning
WSMsgType.ERRORinto anAPIConnectionError, because that's where aiohttp reports the missed pong.
I'd like to put that in one small shared helper in livekit.agents.utils, so a new plugin gets it by default and the interval is decided in one place. For example:
- a default heartbeat for STT websocket connections;
- one function that maps
CLOSE/CLOSED/CLOSING/ERRORto a retryableAPIConnectionError, with the reason fromws.exception().
Then I'd fix the plugins one at a time on top of it, each with a test that runs against the same silent server:
- the helper plus a reusable test fixture, and the OpenAI STT fix;
- ElevenLabs STT;
- Inworld STT.
I'd leave Gladia (#7404), AssemblyAI (#7547), Gradium (#7871) and Nabrah (#7703) alone, since those already have open PRs.
Two questions before I start:
- Would you take the shared helper, or do you prefer keeping the heartbeat inline in each plugin like the existing fixes? I'm happy to do either.
- Is 30s the interval you want? Something like 10s would cut the dead air to about 15s. The pings are tiny control frames, but I don't know whether any provider objects to them.
cc @tinalenguyen since you merged the earlier fixes in this area.
Screenshots and Recordings
No response
Activity
Rehansanjay commentedon Oct 18, 2026
Picked the three-plugin part of this up in #7913 —
heartbeat=30.0on the openai, elevenlabs and inworld connects, matching what the five already-fixed plugins use, with a test each following the soniox pattern.@DEVJHAWAR11 if you were already on it, say so and I'll close mine. The measurement and the half-open probe here are yours and did all the work of finding it; I only wrote the three lines it points at.
I left the shorter-window question out deliberately. You're right that 45s is a long time to be deaf before failover, but changing it means touching the five plugins that already shipped
30.0as well as these three, and there's no single existing convention to follow — microsoft-ai uses10.0and meta uses a module constant. That reads like a maintainer's call on a number rather than a bug fix.fix(stt): make a heartbeat failure raise instead of ending the stream…
Rehansanjay commentedon Oct 20, 2026
@DEVJHAWAR11 I extended your probe to all nineteen streaming STT plugins and put it somewhere re-runnable: https://github.com/Rehansanjay/livekit-stt-halfopen
Your four numbers hold. Deepgram comes back at 47.0s against your 45.4s on my machine, which was the check I used to trust the harness at all.
The other fifteen, against
d4cc4a941:Two things worth your time in there.
A third category you could not see with four plugins. soniox detects the dead socket at ~46s, logs it, requests a reconnect, is connected again 3 ms later, and repeats indefinitely — so a FallbackAdapter is never told and the caller stays deaf for the whole call. simplismart logs
connection lostand likewise never raises. Arguably worse than never noticing, because it looks healthy.The three plugins that look bounded and are not. baseten, fireworksai and gradium wrap
receive()inasyncio.wait_for(..., timeout=5), whose handler then justcontinues. I had written those three off as safe on the strength of thewait_for; your probe is what proved otherwise. All three sat at 75s, and with a heartbeat they come back at 47-48s (#7943).Your point about 45s being a long time to be deaf also stands up better with nineteen data points than four: every plugin that has a heartbeat has
30.0, so the ~46s is nobody's considered choice — it is just the default nobody revisited. That still reads like a maintainers' call rather than something to change in a PR.regalmoix commentedon Oct 21, 2026
Opened #8023 covering the Inference STT case from this issue — same two-change pattern as the merged plugin fixes (#7206 etc). CI green.