(stt): reset the retry budget on any event, not only on a final transcript - #7207
Open
GregHolmes wants to merge 2 commits into
Open
(stt): reset the retry budget on any event, not only on a final transcript#7207GregHolmes wants to merge 2 commits into
GregHolmes wants to merge 2 commits into
Conversation
…cript `RecognizeStream._main_task` gives a stream `max_retry` attempts, 3 by default. `_num_retries` only ever increments, and its single reset lived in `_metrics_monitor_task` on `FINAL_TRANSCRIPT`. That made the budget a stream-lifetime counter replenished by the caller speaking, rather than a consecutive-failure counter replenished by the connection recovering. A stream that reconnects cleanly but sits through silence never earns the budget back. `max_retry` drops spread over one long call then kill it for good, with `recoverable=False` and no further reconnect, even though every one of those reconnects succeeded. The drops need not be consecutive or close together. The gaps where an agent has no final transcript are exactly the gaps where it is most exposed: the agent is talking, the caller is on hold, an IVR is playing, someone stepped away. That is also when an idle socket is most likely to be reaped by a NAT or a load balancer. The condition that spends the budget and the condition that blocks its reset are the same condition. Any event proves the connection came up and delivered, so the reset moves to the top of the event loop. A connection that has never delivered anything at all still gives up as before; the second test pins that, so this does not become an unbounded retry. Reproduced with three streams whose connections all succeed and then drop, ten drops each, differing only in what they deliver in between: no events at all 4 connections, died -> unchanged a FINAL_TRANSCRIPT each time 11 connections, lived -> unchanged RECOGNITION_USAGE only 4 connections, died -> 11, lives The third is the ordinary case: the Deepgram plugin emits a usage event every 5s while audio flows, speech or no speech.
…nsumed send_nowait only wakes the metrics consumer, it does not run it. An attempt that publishes an event and then raises in the same event-loop turn reached the terminal branch of _main_task with the budget still exhausted, so a plugin emitting its last usage event during teardown did not count. Moves the reset into the channel, which also ties it to the attempt that produced the event rather than to whenever a consumer drains it. Adds a test that enters the event-delivering attempt already at max_retry and never yields after the send. It fails on the previous commit.
longcw
requested changes
Sep 11, 2026
longcw
left a comment
Contributor
There was a problem hiding this comment.
thanks for the pr, I think the analysis is right but need some changes:
| ) | ||
|
|
||
| self._stt.emit("metrics_collected", stt_metrics) | ||
| elif ev.type == SpeechEventType.FINAL_TRANSCRIPT: |
Contributor
There was a problem hiding this comment.
I'd like to put the reset back in _metrics_monitor_task and widen it from FINAL_TRANSCRIPT to other events that verify the connection is health , instead of overriding send_nowait in _HealthySignallingChan.
Contributor
There was a problem hiding this comment.
also need to add that for MultiSpeakerAdapter
agents/livekit-agents/livekit/agents/stt/multi_speaker_adapter.py
Lines 143 to 145 in 4de6232
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RecognizeStream._main_taskgives a streammax_retryattempts (3 by default)._num_retriesonly ever increments, and its single reset lives in_metrics_monitor_task, onFINAL_TRANSCRIPT:https://github.com/livekit/agents/blob/main/livekit-agents/livekit/agents/stt/stt.py#L541-L543
That makes the budget a stream-lifetime counter replenished by the caller speaking, rather than a consecutive-failure counter replenished by the connection recovering.
A stream that reconnects cleanly but sits through silence never earns its budget back.
max_retrydrops spread over one long call then kill it permanently, withrecoverable=Falseand no further reconnect, even though every one of those reconnects succeeded. The drops need not be consecutive or close together: a blip at minute 1, 10 and 40, then a fourth, is enough.Why silence is the ordinary case, not an edge case
The gaps where a voice agent has no final transcript are exactly the gaps where it is most exposed: the agent is talking, the caller is on hold, an IVR is playing, someone stepped away. That is also when an idle socket is most likely to be reaped by a NAT or a load balancer. The condition that spends the budget and the condition that blocks its reset are the same condition.
Reproduction
Three streams whose connections all succeed and then drop. Ten drops each. The only difference is what they deliver in between.
failed to recognize speech after 3 attemptsFINAL_TRANSCRIPTeach timeRECOGNITION_USAGEonlyThe third row is the ordinary case: the Deepgram plugin emits a usage event every 5s while audio flows, speech or no speech. Same failures, opposite outcome, decided purely by whether somebody was talking.
Instrumenting
_num_retriesshows the mechanism. With transcripts it sits at a steady 1 forever, reset by the event and incremented by the failure. Without them it climbs 0, 1, 2, 3 and the stream is gone.A runnable script is in the PR below as two tests, but the shape is just:
Is this intended?
I want to flag that it might be. "A stream that has never been understood should not retry forever" is a defensible rule, and resetting on
FINAL_TRANSCRIPTimplements it. If that is the intent, then the bug is narrower: the budget should still reset once a reconnect succeeds, rather than only once recognition succeeds.Suggested fix
Reset on any event from the provider rather than only on
FINAL_TRANSCRIPT. Any event proves the connection came up and delivered. A connection that has never delivered anything at all still gives up as before, so this does not become an unbounded retry.Reported as #7208.
Happy to take it a different way if you would rather the reset hung off a successful connection explicitly, though that needs a signal out of
_run()that the base class does not have today.🤖 Generated with Claude Code