Skip to content

stt: reset the retry budget once an attempt outlived the connect timeout - #7275

Open
u9g wants to merge 1 commit into
mainfrom
jason/stt-retry-reset-on-reconnect
Open

u9g wants to merge 1 commit into
mainfrom
jason/stt-retry-reset-on-reconnect

Conversation

@u9g

@u9g u9g commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #7208. Supersedes #7207.

Problem

RecognizeStream._main_task gives a stream max_retry (default 3) reconnect attempts, and the only thing that resets _num_retries is a FINAL_TRANSCRIPT. A caller who never speaks never produces one, so a provider that recycles an idle socket on a fixed interval spends the whole budget on ordinary, successful reconnects. Cartesia closes an idle socket with 1001 Idle timeout every ~3 minutes; after four of them the stream raises failed to recognize speech after 3 attempts and emits recoverable=False, even though every reconnect in between succeeded.

#7207 proposes resetting on any provider event. That covers Deepgram, which emits usage events every 5 s while audio flows, but not a provider that stays quiet during silence: Cartesia delivers nothing at all until someone speaks, so under #7207 the silent-caller case still dies. Its own description notes this.

Since #6418 the session tolerates unrecoverable STT errors and _STTPipeline recreates the stream, so on main this costs a socket rather than the call. It still burns one of max_unrecoverable_errors per ~12 quiet minutes, and that count only resets on a user transcript, so a long silent call dies at ~36 minutes.

Reproduced against real Cartesia with cue-cli driving a dispatchable agent started with RoomOptions(audio_input=False) on main:

12:48:20  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 0.1s
12:51:20  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 2.0s   ← counter never reset
12:54:22  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 2.0s
12:57:25  REPRO stt error recoverable=False
          APIConnectionError: failed to recognize speech after 3 attempts

Fix

An attempt that ran longer than conn_options.timeout had connected, so its failure is not consecutive with the previous one: reset _num_retries before handling the error. max_retry now means "consecutive failures in a row" regardless of whether anyone was talking. No new option; reuses the existing connect timeout. The FINAL_TRANSCRIPT reset is left in place.

Verification

Same cue-cli scenario on this branch:

13:21:54  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 0.1s
13:24:55  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 0.1s
13:27:55  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 0.1s
13:30:56  closed unexpectedly (close_code=1001, extra='Idle timeout')  retrying in 0.1s

Every retry is attempt 0 again and every error is recoverable=True; the stream never gave up and the session's unrecoverable-error count was never touched.

Unit tests in tests/test_stt_base.py: a flapping stream whose connections stay up for 180 s survives 9 drops with max_retry=3, and one whose connections drop after 1 s still gives up after max_retry + 1 attempts. mypy -p livekit.agents clean.

JS counterpart: livekit/agents-js#2494 (which also ports #6418).

RecognizeStream counts retries over the lifetime of the stream and only
resets on a FINAL_TRANSCRIPT. A caller who never speaks never produces
one, so providers that close an idle socket on a fixed interval
(Cartesia sends 1001 Idle timeout every ~3 minutes) exhaust max_retry
after a few clean reconnects and the stream gives up for good.

An attempt that ran longer than the connect timeout had connected, so
its failure is not consecutive with the previous one: reset the count
before handling the error. The budget now means consecutive failures
in a row, independent of whether anyone was talking.

Fixes #7208.
@u9g
u9g requested a review from a team as a code owner September 14, 2026 17:32

@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 found 1 potential issue.

Devin Review

Comment on lines +480 to +481
if time.time() - last_start_time > self._conn_options.timeout:
self._num_retries = 0

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.

🔴 Connection timeouts retry forever

A handshake timeout after conn_options.timeout resets _num_retries despite never connecting. Repeated handshake timeouts never exhaust max_retry, so fallback never starts.

Learn more

Elapsed attempt time cannot distinguish a healthy connection from a handshake that consumed its entire timeout. Several streams enforce the same option around connection establishment, such as SpeechStream._connect_ws. Such a timeout returns after approximately conn_options.timeout, which satisfies this reset and turns the next failure into attempt zero again. The public options also permit timeout=0, making any nonzero-duration retryable failure reset the budget immediately.

Example: With max_retry=3 and timeout=10, xAI repeatedly fails its WebSocket handshake at 10 seconds. Every failure resets _num_retries to zero, so the stream retries indefinitely instead of failing after four attempts.

Recommended fix: Reset the budget only from explicit evidence that the attempt established a usable provider connection. Add a connection-acquired signal or timestamp to RecognizeStream, and have each streaming plugin set it after its handshake succeeds. Do not infer connection success from total _run() duration.

Devin Review

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

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.

I think this comment is valid

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.

maybe use elapsed > 2 * self._conn_options.timeout?

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.

STT retry budget resets on FINAL_TRANSCRIPT, so a silent caller's stream dies after max_retry drops despite every reconnect succeeding

3 participants