Skip to content

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

Description

@DEVJHAWAR11

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.3

Session/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.0 to ws_connect;
  • turning WSMsgType.ERROR into an APIConnectionError, 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 / ERROR to a retryable APIConnectionError, with the reason from ws.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:

  1. the helper plus a reusable test fixture, and the OpenAI STT fix;
  2. ElevenLabs STT;
  3. 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

  1. Rehansanjay commented on Oct 18, 2026

    @Rehansanjay
    Contributor

    Picked the three-plugin part of this up in #7913 — heartbeat=30.0 on 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.0 as well as these three, and there's no single existing convention to follow — microsoft-ai uses 10.0 and meta uses a module constant. That reads like a maintainer's call on a number rather than a bug fix.

  2. Rehansanjay commented on Oct 20, 2026

    @Rehansanjay
    Contributor

    @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:

    fails over smallestai 46.4s, telnyx 46.9s, deepgram 47.0s
    detects it and never says so soniox (~46s), simplismart (~50.6s)
    never notices assemblyai, baseten, elevenlabs, fireworksai, gladia, gradium, inworld, openai
    could not be measured meta, microsoft-ai, nabrah, sarvam, slng, xai

    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 lost and 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() in asyncio.wait_for(..., timeout=5), whose handler then just continues. I had written those three off as safe on the strength of the wait_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.

  3. regalmoix commented on Oct 21, 2026

    @regalmoix

    Opened #8023 covering the Inference STT case from this issue — same two-change pattern as the merged plugin fixes (#7206 etc). CI green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions