fix(stt): guard connect_to_deepgram against False start() (#6302)#6303
fix(stt): guard connect_to_deepgram against False start() (#6302)#6303
Conversation
) When Deepgram WS start() returns False (e.g. self-hosted DG doesn't support streaming), the function previously returned the dead connection. Callers only checked for None, so a dead socket was wrapped and used, causing zero transcripts on the transcribe-stream endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verifies connect_to_deepgram returns None when start() returns False and returns the connection when start() succeeds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…scribe Tests moved to dedicated test_dg_start_guard.py where streaming module can be imported without MagicMock interference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds a correctness guard to
Confidence Score: 3/5Safe to merge as-is — the guard eliminates a real silent failure — but the retry bypass is a latent correctness risk for transient The core fix is correct and well-tested for its intended scenario. The risk is that returning backend/utils/stt/streaming.py — specifically the interaction between Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[process_audio_dg] --> B[connect_to_deepgram_with_backoff]
B --> C{is_active check}
C -->|False| D[return None]
C -->|True| E[asyncio.to_thread: connect_to_deepgram]
E --> F[dg_connection.start options]
F --> G{result is falsy?}
G -->|Yes - NEW GUARD| H[log error, return None]
G -->|No| I[return dg_connection]
H --> J[backoff fn returns None immediately - no retry]
I --> K[backoff fn returns connection]
J --> L[process_audio_dg returns None]
K --> M[wrap with SafeDeepgramSocket]
E --> N{Exception raised?}
N -->|Yes, last attempt| O[re-raise Exception]
N -->|Yes, earlier attempt| P[calculate backoff + sleep, retry]
P --> C
Reviews (1): Last reviewed commit: "ci: add test_dg_start_guard.py to test.s..." | Re-trigger Greptile |
| result = dg_connection.start(options) | ||
| logger.info(f'Deepgram connection started: {result}') | ||
| if not result: | ||
| logger.error('Deepgram connection start() returned False — connection not established') | ||
| return None |
There was a problem hiding this comment.
False start bypasses retry loop — no backoff attempted
connect_to_deepgram now returns None when start() returns False. Because connect_to_deepgram_with_backoff calls it via return await asyncio.to_thread(...), a None return exits the for attempt in range(retries) loop immediately — the except Exception block is never triggered, so none of the three retry attempts fire.
For the permanent case described in the PR (self-hosted DG doesn't support WebSocket streaming) this is fine. However, start() can also return False for transient reasons (e.g. a brief server-side hiccup), and those callers silently get None with zero retries and no backoff.
Consider raising a dedicated exception instead of returning None, so the existing retry machinery handles transient failures:
result = dg_connection.start(options)
logger.info(f'Deepgram connection started: {result}')
if not result:
raise Exception('Deepgram connection start() returned False — connection not established')
return dg_connection…and catch/re-raise as None only after all retries are exhausted (or add a dedicated StartFailedError type so callers can distinguish it from a WebSocket error).
| @patch('utils.stt.streaming.deepgram') | ||
| def test_returns_none_when_start_fails(self, mock_dg): | ||
| """If dg_connection.start() returns False, must return None (#6302).""" | ||
| mock_dg_conn = MagicMock() | ||
| mock_dg_conn.start.return_value = False | ||
| mock_dg.listen.websocket.v.return_value = mock_dg_conn | ||
|
|
||
| result = connect_to_deepgram( | ||
| on_message=MagicMock(), | ||
| on_error=MagicMock(), | ||
| language='en', | ||
| sample_rate=16000, | ||
| channels=1, | ||
| model='nova-3', | ||
| ) | ||
| assert result is None |
There was a problem hiding this comment.
No test for backoff propagation of
None return
The two tests verify that connect_to_deepgram itself returns None / the connection correctly, but there is no test for the higher-level connect_to_deepgram_with_backoff (or process_audio_dg) to confirm that a None return from connect_to_deepgram is propagated without triggering retries or raising an unhandled exception.
Given that connect_to_deepgram_with_backoff has its own retry / raise logic, a test that patches connect_to_deepgram to return None and asserts the backoff function also returns None (with zero retries) would make the contract explicit and catch regressions if the backoff loop is later refactored.
…#6302) When connect_to_deepgram returns None (start() returned False), the backoff function now retries instead of treating it as success. Returns None after all retries exhaust, preserving the existing None-based contract for callers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests that connect_to_deepgram_with_backoff retries when connect_to_deepgram returns None, and returns None after all retries exhaust. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add deepgram module stubs matching test_streaming_deepgram_backoff.py pattern to prevent import-order pollution when pytest collects both files together. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t_guard (#6302) Don't set deepgram mock attributes in test_dg_start_guard.py — MagicMock auto-generates them and explicit overwriting pollutes shared pytest state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CP9 Changed-Path Coverage Checklist
L1 SynthesisP1 (guard) proven by L2 SynthesisBackend started on VPS port 10201 with branch code. Code verification: by AI for @beastoin |
Fixes #6302.
Guards
connect_to_deepgram()againststart()returningFalse— returnsNoneinstead of passing a dead connection to callers. Also fixesconnect_to_deepgram_with_backoff()to retry whenconnect_to_deepgramreturnsNone(previously treated as success, bypassing all retries).Changes
streaming.py:connect_to_deepgram— returnsNonewhenstart()returnsFalseinstead of returning a dead socketstreaming.py:connect_to_deepgram_with_backoff— retries onNonereturn (start failure), returnsNoneafter exhaustion (preserves existing caller contract)test_dg_start_guard.py— dedicated tests for the guard (None on False, connection on True) + import-order-safe deepgram stubstest_streaming_deepgram_backoff.py— two new tests: retry-on-None-then-succeed, None-after-all-retries-exhausted + guarded deepgram mock attribute setting for import-order safetytest_desktop_transcribe.py— removed skip-prone start guard tests (replaced by dedicated file)test.sh— addedtest_dg_start_guard.pyTesting
pytest tests/unit/test_dg_start_guard.py -v— 2/2 passpytest tests/unit/test_streaming_deepgram_backoff.py -v— 40/40 passpytest tests/unit/test_desktop_transcribe.py -v— 43/43 pass/v2/voice-message/transcribe-stream, DG lifecycle verified in logsRisks
connect_to_deepgram_with_backoffnow retries onNone(new behavior), but returnsNoneafter exhaustion — all existing callers already handleNone_process_sttintranscribe.pydoesn't fail-close onNoneSTT init (tracked separately)by AI for @beastoin