Skip to content

fix(stt): guard connect_to_deepgram against False start() (#6302)#6303

Open
beastoin wants to merge 9 commits intomainfrom
fix/dg-start-guard-6302
Open

fix(stt): guard connect_to_deepgram against False start() (#6302)#6303
beastoin wants to merge 9 commits intomainfrom
fix/dg-start-guard-6302

Conversation

@beastoin
Copy link
Copy Markdown
Collaborator

@beastoin beastoin commented Apr 4, 2026

Fixes #6302.

Guards connect_to_deepgram() against start() returning False — returns None instead of passing a dead connection to callers. Also fixes connect_to_deepgram_with_backoff() to retry when connect_to_deepgram returns None (previously treated as success, bypassing all retries).

Changes

  1. streaming.py:connect_to_deepgram — returns None when start() returns False instead of returning a dead socket
  2. streaming.py:connect_to_deepgram_with_backoff — retries on None return (start failure), returns None after exhaustion (preserves existing caller contract)
  3. test_dg_start_guard.py — dedicated tests for the guard (None on False, connection on True) + import-order-safe deepgram stubs
  4. test_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 safety
  5. test_desktop_transcribe.py — removed skip-prone start guard tests (replaced by dedicated file)
  6. test.sh — added test_dg_start_guard.py

Testing

  • pytest tests/unit/test_dg_start_guard.py -v — 2/2 pass
  • pytest tests/unit/test_streaming_deepgram_backoff.py -v — 40/40 pass
  • pytest tests/unit/test_desktop_transcribe.py -v — 43/43 pass
  • Both combined test orderings pass (42/42 each) — import-order isolation verified
  • L1: all 85 affected tests pass, full test.sh passes (3 pre-existing failures in unrelated file)
  • L2: backend started with branch code, authenticated WS connection to /v2/voice-message/transcribe-stream, DG lifecycle verified in logs

Risks

  • connect_to_deepgram_with_backoff now retries on None (new behavior), but returns None after exhaustion — all existing callers already handle None
  • Pre-existing: _process_stt in transcribe.py doesn't fail-close on None STT init (tracked separately)

by AI for @beastoin

beastoin and others added 4 commits April 4, 2026 04:09
)

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 4, 2026

Greptile Summary

This PR adds a correctness guard to connect_to_deepgram() in backend/utils/stt/streaming.py: if dg_connection.start() returns False, the function now logs an error and returns None rather than silently returning a dead connection. It also wires two new test files (test_dg_start_guard.py and test_desktop_transcribe.py) into the CI runner (test.sh).

  • Bug fix (streaming.py lines 362–366): if not result: return None prevents a dead dg_connection from being wrapped in SafeDeepgramSocket and handed back to callers, which was the root cause of zero transcripts on /v2/voice-message/transcribe-stream when DEEPGRAM_SELF_HOSTED_ENABLED=true.
  • Retry bypass (streaming.py): Returning None instead of raising an exception means the for attempt in range(retries) loop in connect_to_deepgram_with_backoff exits immediately — all three backoff retries are silently skipped. This is intentional for the permanent self-hosted case described in the PR, but could silently suppress retries for transient start() failures.
  • Tests (test_dg_start_guard.py): Cover the False/True paths for connect_to_deepgram; no test covers the backoff-propagation contract.
  • CI registration (test.sh): Both new test files are correctly added at the end of the unit-test suite.

Confidence Score: 3/5

Safe to merge as-is — the guard eliminates a real silent failure — but the retry bypass is a latent correctness risk for transient start() failures.

The core fix is correct and well-tested for its intended scenario. The risk is that returning None short-circuits the backoff retry loop, which could silently fail on transient DG server hiccups rather than recovering. The process_audio_dg caller handles None gracefully so there is no crash, but callers lose the retry benefit that was previously available for other error types.

backend/utils/stt/streaming.py — specifically the interaction between connect_to_deepgram returning None and the retry loop in connect_to_deepgram_with_backoff.

Important Files Changed

Filename Overview
backend/utils/stt/streaming.py Adds a if not result: return None guard after dg_connection.start() in connect_to_deepgram(); fixes the silent dead-connection bug but bypasses the backoff retry loop when start() returns False.
backend/tests/unit/test_dg_start_guard.py New unit test verifying connect_to_deepgram returns None on start() returning False and returns the connection on True. Coverage is correct for the guarded code path.
backend/tests/unit/test_desktop_transcribe.py New test file for desktop PTT transcription (PR #6286); added to test.sh CI runner alongside test_dg_start_guard.py in this PR.
backend/test.sh Registers both new test files (test_desktop_transcribe.py and test_dg_start_guard.py) in the CI test runner.

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
Loading

Reviews (1): Last reviewed commit: "ci: add test_dg_start_guard.py to test.s..." | Re-trigger Greptile

Comment on lines 362 to +366
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
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.

P1 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).

Comment on lines +48 to +63
@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
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.

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

beastoin and others added 5 commits April 4, 2026 22:58
…#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>
)

Only set LiveTranscriptionEvents when this file owns the deepgram mock,
preventing identity mismatch when another test file imported streaming.py first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@beastoin
Copy link
Copy Markdown
Collaborator Author

beastoin commented Apr 4, 2026

CP9 Changed-Path Coverage Checklist

Path ID Sequence ID(s) Changed path Happy-path test Non-happy-path test L1 result + evidence L2 result + evidence
P1 N/A streaming.py:connect_to_deepgram guard (return None when start()==False) test_returns_connection_when_start_succeeds — start()==True returns connection test_returns_none_when_start_fails — start()==False returns None PASS — pytest test_dg_start_guard.py -v 2/2 pass PASS — WS connect to local backend, logs show Deepgram connection started: True, full DG lifecycle (metadata + close)
P2 N/A streaming.py:connect_to_deepgram_with_backoff None-retry test_retries_on_none_then_succeeds — None first, then succeeds on retry 3 test_returns_none_after_all_none_retries_exhausted — all 3 retries return None → returns None PASS — pytest test_streaming_deepgram_backoff.py -v -k none 7/7 pass PASS — Backend started with our branch, connect_to_deepgram_with_backoff logged, start()==True path verified

L1 Synthesis

P1 (guard) proven by test_returns_none_when_start_fails (non-happy) and test_returns_connection_when_start_succeeds (happy). P2 (retry) proven by test_retries_on_none_then_succeeds (happy) and test_returns_none_after_all_none_retries_exhausted (non-happy). Mock isolation verified in both test orderings (42/42 pass). No paths UNTESTED.

L2 Synthesis

Backend started on VPS port 10201 with branch code. Code verification: inspect.getsource() confirmed both P1 guard ("Deepgram connection start() returned False") and P2 retry ("if result is not None") are loaded. Authenticated WS connection to /v2/voice-message/transcribe-stream succeeded — backend logs show connect_to_deepgram_with_backoff called → Deepgram connection started: True → full DG lifecycle (metadata response + clean close). The start()==False non-happy-path cannot be triggered in L2 without a misconfigured self-hosted Deepgram instance; unit tests cover this scenario comprehensively. No paths UNTESTED.


by AI for @beastoin

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.

Deploy Monitor: PR #6287 — PTT migration to Python backend

1 participant