From 746d2d1aef180ebfc883767bdffbe29be33a5613 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 23 Sep 2026 15:48:00 +0900 Subject: [PATCH] =?UTF-8?q?fix(ai):=20STT=20=EC=9D=BC=EC=8B=9C=20=EC=9E=A5?= =?UTF-8?q?=EC=95=A0=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20+=20Deepgram=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 음성 답변 83건 중 7건(약 8%)이 STT 실패. ai_request_logs 상 성공 호출은 2.4~12초(p50 3.6초)인데 실패 2건은 60.8초/61.5초 — 전부 일괄 timeout=60 에 정확히 걸린 타임아웃이었다. 오디오 크기·손상·인증·모델·로컬 네트워크는 실측으로 배제했다. 실패 파일 6개를 재전송하니 5개가 1.2~4.0초에 정상 전사됐고, Deepgram anycast IP 8개·DNS·PMTU 모두 정상이었다. 상류가 구간적으로 멎는 것이 원인이고, 우리 쪽 문제는 그 딸꾹질을 영구 실패로 굳히는 데 있었다. - VoiceConsumer 가 SttError.retriable 을 읽어 지수 백오프로 재시도 (STT_MAX_ATTEMPTS 기본 3, STT_RETRY_BACKOFF_SEC 0.5). 지금까지 retriable 플래그는 생성만 되고 아무도 읽지 않았다 — questions/followup/feedback 은 classify_failure 로 전파하는데 voice 만 빠져 있었다. - 비재시도(STT_AUTH_FAILED/STT_BAD_REQUEST)와 예상 못 한 예외는 재시도 없이 즉시 실패 콜백 — 재전송해도 결과가 같거나 코드 버그다. - httpx.Timeout(read=30, connect=5) 으로 분리. 연결이 막혔을 때 read 한도만큼 사용자를 붙잡지 않는다. 정상 호출 최대가 12초라 read 30초도 여유가 크다. - 타임아웃 예외에 타입을 같이 남긴다. str() 이 빈 문자열이라 기존 로그가 "Deepgram 호출 실패: " 로 끝나 connect/read 구분이 불가능했다. - ai_request_logs 는 시도마다 한 행. error_message 앞의 [n/N] 으로 재시도율 추적. --- .env.example | 4 + ai/.env.example | 5 +- ai/CLAUDE.md | 1 + ai/src/ai_server/config/settings.py | 9 +- .../messaging/consumers/voice_consumer.py | 102 ++++++++++---- ai/src/ai_server/messaging/runner.py | 2 + ai/src/ai_server/voice/stt/deepgram.py | 16 ++- ai/src/ai_server/voice/stt/factory.py | 1 + ai/tests/test_deepgram_stt.py | 28 ++++ ai/tests/test_voice_consumer.py | 125 ++++++++++++++++++ docs/environment.md | 5 +- 11 files changed, 263 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index 6537dda6..68ccf6ab 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,10 @@ STT_PROVIDER=auto DEEPGRAM_API_KEY= DEEPGRAM_MODEL=whisper-large DEEPGRAM_LANGUAGE=ko +DEEPGRAM_TIMEOUT_SEC=30 +DEEPGRAM_CONNECT_TIMEOUT_SEC=5 +STT_MAX_ATTEMPTS=3 +STT_RETRY_BACKOFF_SEC=0.5 # RealTime server REALTIME_PORT=38020 diff --git a/ai/.env.example b/ai/.env.example index 411cff56..6a20355c 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -73,7 +73,10 @@ DEEPGRAM_API_KEY= DEEPGRAM_BASE_URL=https://api.deepgram.com/v1 DEEPGRAM_MODEL=whisper-large DEEPGRAM_LANGUAGE=ko -DEEPGRAM_TIMEOUT_SEC=60 +DEEPGRAM_TIMEOUT_SEC=30 # read 한도. 정상 호출 p50 3.6초/최대 12초 +DEEPGRAM_CONNECT_TIMEOUT_SEC=5 # connect 한도. 일괄이면 연결 막힘도 read 한도만큼 대기 +STT_MAX_ATTEMPTS=3 # 일시 장애(retriable) 재시도 횟수. 1=재시도 없음 +STT_RETRY_BACKOFF_SEC=0.5 # 지수 백오프 기준값 (0.5 → 0.5s, 1s) # TTS (질문 음성화). auto=LLM_API_KEY(gateway) > GEMINI_API_KEY 순, 없으면 mock. # gateway: Mindlogic 게이트웨이(/audio/speech)가 Gemini TTS 로 라우팅 → 직접 GEMINI_API_KEY 429 부하 분산. diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index a549dd26..92f7f955 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -199,6 +199,7 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...) - **TTS: Gemini TTS 기본(한국어)** (`voice/tts/`) — 질문(INTERVIEWER) 메시지 음성화. Deepgram/OpenAI TTS 는 한국어 미지원이라 `GeminiTtsProvider`(`gemini-2.5-flash-preview-tts`, voice=Kore)가 기본. Gemini 는 raw PCM(L16/24kHz)을 반환하므로 WAV 로 감싸 `audio/wav` 로 저장. `TtsProvider` 추상화 + `GatewayTtsProvider`(Mindlogic 게이트웨이 `/audio/speech`, LLM_API_KEY, raw PCM→WAV)/`GeminiTtsProvider`(직접 GEMINI_API_KEY)/`MockTtsProvider`, `build_tts_provider` factory(`TTS_PROVIDER=auto`면 **LLM_API_KEY(gateway) > GEMINI_API_KEY** 순 — 게이트웨이 우선으로 직접 키 429 부하 분산). gateway/gemini 는 `gemini_tts_model`/`gemini_tts_voice` 공유. `generate.tts` consumer 가 합성 → S3 PUT(확장자는 content_type 기준) → `callback.tts` 발행. 재생은 Core 오디오 프록시(`GET /api/sessions/{sid}/messages/{mid}/audio`) 경유(MinIO presigned URL 이 내부 호스트라 브라우저 직접 접근 불가). - **스트리밍 STT (실시간 음성 답변, RT3): Deepgram Live** (`voice/stt/deepgram_live.py`) — `websockets`로 Deepgram WS(`wss://api.deepgram.com/v1/listen`, nova-2)에 연결, interim/final 자막을 실시간 반환. `voice/stt/live.py`(`LiveSttProvider`/`LiveSttSession` 추상) + `voice/stt/mock_live.py`(키 없을 때 fallback) + `voice/stt/live_factory.py`(`LIVE_STT_PROVIDER=auto`면 DEEPGRAM_API_KEY 보유 시 deepgram_live). - FastAPI WS 엔드포인트 `/internal/voice/stream`(`api/voice_stream.py`): RealTime이 프록시한 오디오를 받아 부분/최종 자막을 다운 프레임(`transcript.partial`/`transcript.final`)으로 보내고, 발화 종료(`stop` 또는 UtteranceEnd) 시 메트릭 계산 후 `callback.voice` 발행 → 기존 followup 파이프라인 재사용. +- **STT 타임아웃 분리 + 재시도** (`voice/stt/deepgram.py`, `messaging/consumers/voice_consumer.py`): Deepgram 배치 호출이 간헐적으로 응답 없이 멎는다 — 운영 실패 2건이 60.8s/61.5s 로 read 한도에 정확히 걸렸고 성공 호출은 p50 3.6초/최대 12초였다. 오디오 크기·손상·인증·모델·로컬 네트워크는 실측으로 배제됐고(실패 파일 6개 중 5개가 재전송에서 즉시 전사), 상류가 구간적으로 멎는 것이 원인. 일괄 `timeout=60` 을 `httpx.Timeout(read=30, connect=5)` 으로 쪼개 연결 막힘에 1분을 쓰지 않게 하고, `VoiceConsumer` 가 `SttError.retriable` 을 **읽어** 지수 백오프로 재시도한다(`STT_MAX_ATTEMPTS` 기본 3, `STT_RETRY_BACKOFF_SEC` 0.5). 비재시도(STT_AUTH_FAILED/STT_BAD_REQUEST)와 예상 못 한 예외는 재시도 없이 즉시 실패 콜백. 타임아웃 예외는 `str()` 이 빈 문자열이라 메시지에 예외 타입을 같이 남긴다(이전 로그는 `Deepgram 호출 실패: ` 로 끝나 connect/read 구분이 불가능했다). `ai_request_logs` 는 시도마다 한 행 — `error_message` 앞의 `[n/N]` 으로 재시도율을 본다. - **STT 환각 제거** (`voice/stt/sanitize.py`): Whisper/Deepgram 이 발화 끝 무음·잡음에서 학습데이터(방송/유튜브) 정형 문구를 환각으로 덧붙이는 문제(예: "MBC 뉴스 OOO입니다", "시청해주셔서 감사합니다", "구독과 좋아요", 영어 "thanks for watching")를 보수적으로 제거. 배치(`deepgram`/`openai_whisper`)는 `transcribe` 반환 시, 라이브(`deepgram_live`)는 **최종 자막마다** + `result()` 백스톱에서 적용 → 저장 전사·메트릭·실시간 표시 모두 정화. 환각만 남은 segment 는 제거해 무음/발음 메트릭이 실제 무음 반영. "감사합니다"·"뉴스 앱" 등 정상 표현은 보존. - 추상화 계층 두기: `voice/stt/base.py` (interface), `voice/stt/whisper_api.py`, `voice/tts/base.py` + `voice/tts/{provider}.py` - 분석: diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index eb6f81bb..b8242ee1 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -59,7 +59,14 @@ class Settings(BaseSettings): deepgram_base_url: str = "https://api.deepgram.com/v1" deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2. deepgram_language: str = "ko" - deepgram_timeout_sec: float = 60.0 + # read 한도. 정상 호출은 p50 3.6초/최대 12초라 30초도 크게 여유롭다. + # 이전 60초는 Deepgram 이 멎었을 때 사용자를 1분간 붙잡아 두기만 했다. + deepgram_timeout_sec: float = 30.0 + deepgram_connect_timeout_sec: float = 5.0 + # STT 재시도. Deepgram 이 간헐적으로 응답 없이 멎는데, 같은 오디오를 재전송하면 + # 대부분 즉시 전사된다. retriable 오류에만 적용(인증·잘못된 요청은 즉시 실패). + stt_max_attempts: int = 3 + stt_retry_backoff_sec: float = 0.5 # 스트리밍 STT (실시간 음성 답변). "auto" 면 DEEPGRAM_API_KEY 보유 시 deepgram_live, 없으면 mock. live_stt_provider: Literal["auto", "mock", "deepgram_live"] = "auto" diff --git a/ai/src/ai_server/messaging/consumers/voice_consumer.py b/ai/src/ai_server/messaging/consumers/voice_consumer.py index 0b73b15c..7cfe4861 100644 --- a/ai/src/ai_server/messaging/consumers/voice_consumer.py +++ b/ai/src/ai_server/messaging/consumers/voice_consumer.py @@ -17,7 +17,7 @@ ) from ai_server.storage.base import ObjectStorage from ai_server.voice.analysis.metrics import analyze -from ai_server.voice.stt.base import SttError, SttProvider +from ai_server.voice.stt.base import SttError, SttProvider, TranscriptionResult log = structlog.get_logger(__name__) @@ -43,6 +43,8 @@ def __init__( callback_routing_key: str, filler_pattern: str, core_client: CoreClient | None = None, + stt_max_attempts: int = 3, + stt_retry_backoff_sec: float = 0.5, ) -> None: self._stt = stt self._storage = storage @@ -51,6 +53,8 @@ def __init__( self._callback_routing_key = callback_routing_key self._filler_pattern = filler_pattern self._core_client = core_client + self._stt_max_attempts = max(1, stt_max_attempts) + self._stt_retry_backoff_sec = stt_retry_backoff_sec async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -91,45 +95,21 @@ async def handle(self, message: AbstractIncomingMessage) -> None: await self._publish_failed(envelope, req, code="AUDIO_FETCH_FAILED") return - started = None try: - started = time.perf_counter() - result = await self._stt.transcribe( - audio_bytes=audio_bytes, - content_type=req.content_type, - hint=req.previous_question_text, - ) - self._record_stt_log( - req, - envelope, - latency_ms=_elapsed_ms(started), - status="SUCCESS", - error_message=None, + result = await self._transcribe_with_retry( + req, envelope, audio_bytes ) except SttError as exc: - self._record_stt_log( - req, - envelope, - latency_ms=_elapsed_ms(started), - status="FAILED", - error_message=exc.message, - ) log.error( "voice.stt.failed", error=str(exc), code=exc.code, session_id=req.session_id, + attempts=getattr(exc, "attempts", 1), ) await self._publish_failed(envelope, req, code=exc.code) return except Exception as exc: - self._record_stt_log( - req, - envelope, - latency_ms=_elapsed_ms(started), - status="FAILED", - error_message=str(exc), - ) log.error( "voice.stt.unexpected", error=str(exc), @@ -162,6 +142,72 @@ async def handle(self, message: AbstractIncomingMessage) -> None: trace_id=envelope.trace_id, ) + async def _transcribe_with_retry( + self, req: AnalyzeVoiceRequest, envelope, audio_bytes: bytes + ) -> TranscriptionResult: + """일시 장애(retriable)면 지수 백오프로 재시도. + + Deepgram 호출은 간헐적으로 응답 없이 멎는다 — 운영 실패 2건이 60.8s/61.5s 로 + read 한도에 정확히 걸렸고, 성공 호출은 p50 3.6초였다. 멎은 뒤 같은 오디오를 + 재전송하면 대부분 즉시 전사되므로(실패 파일 6개 중 5개가 재전송에서 성공), + 한 번의 딸꾹질이 사용자에게 영구 실패로 보이지 않도록 여기서 흡수한다. + + 비재시도(STT_AUTH_FAILED, STT_BAD_REQUEST)는 재전송해도 같은 결과라 즉시 올린다. + 예상 못 한 예외는 코드 버그일 가능성이 높아 재시도로 지연만 늘리므로 그대로 올린다. + `ai_request_logs` 에는 시도마다 한 행씩 남는다 — 재시도율을 보려면 error_message + 앞의 `[n/N]` 을 본다. + """ + for attempt in range(1, self._stt_max_attempts + 1): + started = time.perf_counter() + try: + result = await self._stt.transcribe( + audio_bytes=audio_bytes, + content_type=req.content_type, + hint=req.previous_question_text, + ) + except SttError as exc: + self._record_stt_log( + req, + envelope, + latency_ms=_elapsed_ms(started), + status="FAILED", + error_message=f"[{attempt}/{self._stt_max_attempts}] {exc.message}", + ) + if not exc.retriable or attempt == self._stt_max_attempts: + exc.attempts = attempt # 최종 실패 로그가 실제 시도 횟수를 싣도록 + raise + backoff = self._stt_retry_backoff_sec * (2 ** (attempt - 1)) + log.warn( + "voice.stt.retry", + code=exc.code, + attempt=attempt, + max_attempts=self._stt_max_attempts, + backoff_sec=backoff, + session_id=req.session_id, + ) + await asyncio.sleep(backoff) + continue + except Exception as exc: + self._record_stt_log( + req, + envelope, + latency_ms=_elapsed_ms(started), + status="FAILED", + error_message=str(exc), + ) + raise + + self._record_stt_log( + req, + envelope, + latency_ms=_elapsed_ms(started), + status="SUCCESS", + error_message=None, + ) + return result + + raise AssertionError("도달 불가 — 마지막 시도는 항상 raise 한다") + async def _publish_callback(self, envelope, payload: VoiceCallbackPayload) -> None: await self._publisher.publish( routing_key=self._callback_routing_key, diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 2ada889a..3e86a749 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -293,6 +293,8 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_voice, filler_pattern=settings.voice_filler_pattern, core_client=core_client, + stt_max_attempts=settings.stt_max_attempts, + stt_retry_backoff_sec=settings.stt_retry_backoff_sec, ) # 질문 TTS (Part A) — tts 인스턴스는 위에서 이미 생성됨. diff --git a/ai/src/ai_server/voice/stt/deepgram.py b/ai/src/ai_server/voice/stt/deepgram.py index e7984121..a0665f58 100644 --- a/ai/src/ai_server/voice/stt/deepgram.py +++ b/ai/src/ai_server/voice/stt/deepgram.py @@ -30,7 +30,8 @@ def __init__( base_url: str = "https://api.deepgram.com/v1", model: str = "whisper-large", language: str | None = "ko", - timeout_sec: float = 60.0, + timeout_sec: float = 30.0, + connect_timeout_sec: float = 5.0, client: httpx.AsyncClient | None = None, ) -> None: if not api_key: @@ -39,7 +40,9 @@ def __init__( self._base_url = base_url.rstrip("/") self._model = model self._language = language - self._timeout_sec = timeout_sec + # connect 를 따로 둔다 — 일괄 timeout 이면 연결이 막혔을 때도 read 한도만큼(운영 60초) + # 붙잡고 있다가 실패했다. 정상 호출은 p50 3.6초라 read 30초로도 여유가 크다. + self._timeout = httpx.Timeout(timeout_sec, connect=connect_timeout_sec) self._client = client @property @@ -78,14 +81,19 @@ async def transcribe( url, params=params, headers=headers, content=audio_bytes ) else: - async with httpx.AsyncClient(timeout=self._timeout_sec) as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: resp = await client.post( url, params=params, headers=headers, content=audio_bytes ) except httpx.HTTPError as exc: + # 타임아웃 예외는 str() 이 빈 문자열이라 타입을 같이 남겨야 connect/read 를 구분한다. + detail = str(exc) raise SttError( code="STT_UNAVAILABLE", - message=f"Deepgram 호출 실패: {exc}", + message=( + f"Deepgram 호출 실패: {type(exc).__name__}" + + (f": {detail}" if detail else "") + ), retriable=True, ) from exc diff --git a/ai/src/ai_server/voice/stt/factory.py b/ai/src/ai_server/voice/stt/factory.py index 082125ad..58ba64ae 100644 --- a/ai/src/ai_server/voice/stt/factory.py +++ b/ai/src/ai_server/voice/stt/factory.py @@ -34,6 +34,7 @@ def build_stt_provider(settings: Settings) -> SttProvider: model=settings.deepgram_model, language=settings.deepgram_language or None, timeout_sec=settings.deepgram_timeout_sec, + connect_timeout_sec=settings.deepgram_connect_timeout_sec, ) if provider == "mock": diff --git a/ai/tests/test_deepgram_stt.py b/ai/tests/test_deepgram_stt.py index d9ee9d2e..e7bfd2e2 100644 --- a/ai/tests/test_deepgram_stt.py +++ b/ai/tests/test_deepgram_stt.py @@ -105,3 +105,31 @@ async def test_deepgram_5xx_is_retriable(): await provider.transcribe(audio_bytes=b"x", content_type="audio/webm") assert exc_info.value.code == "STT_UNAVAILABLE" assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_deepgram_timeout_keeps_exception_type_in_message(): + """타임아웃 예외는 str() 이 빈 문자열이다 — 타입을 안 남기면 로그가 + 'Deepgram 호출 실패: ' 로 끝나 connect/read 를 구분할 수 없었다.""" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectTimeout("", request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = DeepgramSttProvider(api_key="k", client=client) + with pytest.raises(SttError) as exc_info: + await provider.transcribe(audio_bytes=b"x", content_type="audio/webm") + + assert exc_info.value.code == "STT_UNAVAILABLE" + assert exc_info.value.retriable is True + assert "ConnectTimeout" in exc_info.value.message + + +def test_deepgram_connect_timeout_is_separate_from_read_timeout(): + """일괄 timeout 이면 연결이 막혔을 때도 read 한도만큼 붙잡고 있다가 실패한다.""" + provider = DeepgramSttProvider( + api_key="k", timeout_sec=30.0, connect_timeout_sec=5.0 + ) + timeout = provider._timeout # noqa: SLF001 — 구성값 회귀 고정 + assert timeout.connect == 5.0 + assert timeout.read == 30.0 diff --git a/ai/tests/test_voice_consumer.py b/ai/tests/test_voice_consumer.py index bafdb969..63d415b7 100644 --- a/ai/tests/test_voice_consumer.py +++ b/ai/tests/test_voice_consumer.py @@ -250,3 +250,128 @@ async def test_consumer_unmarks_when_publish_fails(): await consumer.handle(_StubMessage(_envelope())) assert store.is_seen_then_mark("voice-1") is False + + +@pytest.mark.asyncio +async def test_consumer_retries_retriable_stt_error_and_succeeds(): + """Deepgram 이 한 번 멎어도 재전송하면 대부분 전사된다 — 사용자에게 실패를 보이지 않는다.""" + ok = _stt_ok() + stt = MagicMock() + stt.transcribe = AsyncMock( + side_effect=[ + SttError( + code="STT_UNAVAILABLE", + message="Deepgram 호출 실패: ReadTimeout", + retriable=True, + ), + await ok.transcribe(audio_bytes=b"", content_type="audio/webm"), + ] + ) + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = VoiceConsumer( + stt=stt, + storage=_storage_ok(), + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.voice", + filler_pattern=r"(?:음+|어+|그+|아+)", + stt_max_attempts=3, + stt_retry_backoff_sec=0.0, + ) + await consumer.handle(_StubMessage(_envelope())) + + assert stt.transcribe.await_count == 2 + payload: VoiceCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.error_code is None + assert payload.transcript.startswith("ACID") + + +@pytest.mark.asyncio +async def test_consumer_does_not_retry_non_retriable_stt_error(): + """인증·잘못된 요청은 재전송해도 같은 결과라 즉시 실패시킨다.""" + stt = MagicMock() + stt.transcribe = AsyncMock( + side_effect=SttError(code="STT_AUTH_FAILED", message="bad key", retriable=False) + ) + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = VoiceConsumer( + stt=stt, + storage=_storage_ok(), + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.voice", + filler_pattern=r"(?:음+|어+|그+|아+)", + stt_max_attempts=3, + stt_retry_backoff_sec=0.0, + ) + await consumer.handle(_StubMessage(_envelope())) + + assert stt.transcribe.await_count == 1 + payload: VoiceCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.error_code == "STT_AUTH_FAILED" + + +@pytest.mark.asyncio +async def test_consumer_publishes_failure_after_exhausting_retries(): + stt = MagicMock() + stt.transcribe = AsyncMock( + side_effect=SttError( + code="STT_UNAVAILABLE", + message="Deepgram 호출 실패: ReadTimeout", + retriable=True, + ) + ) + publisher = MagicMock() + publisher.publish = AsyncMock() + core_client = MagicMock() + core_client.record_ai_log = AsyncMock() + + consumer = VoiceConsumer( + stt=stt, + storage=_storage_ok(), + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.voice", + filler_pattern=r"(?:음+|어+|그+|아+)", + core_client=core_client, + stt_max_attempts=3, + stt_retry_backoff_sec=0.0, + ) + await consumer.handle(_StubMessage(_envelope())) + await asyncio.sleep(0) + + assert stt.transcribe.await_count == 3 + payload: VoiceCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.error_code == "STT_UNAVAILABLE" + # 시도마다 로그 한 행 — 재시도율을 error_message 의 [n/N] 으로 추적한다. + assert core_client.record_ai_log.await_count == 3 + assert "[3/3]" in core_client.record_ai_log.await_args.kwargs["error_message"] + + +@pytest.mark.asyncio +async def test_consumer_does_not_retry_unexpected_exception(): + """예상 못 한 예외는 코드 버그일 가능성이 높아 재시도로 지연만 늘린다.""" + stt = MagicMock() + stt.transcribe = AsyncMock(side_effect=RuntimeError("boom")) + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = VoiceConsumer( + stt=stt, + storage=_storage_ok(), + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.voice", + filler_pattern=r"(?:음+|어+|그+|아+)", + stt_max_attempts=3, + stt_retry_backoff_sec=0.0, + ) + await consumer.handle(_StubMessage(_envelope())) + + assert stt.transcribe.await_count == 1 + payload: VoiceCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.error_code == "TRANSCRIPTION_FAILED" diff --git a/docs/environment.md b/docs/environment.md index 579d99ad..671fd464 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -164,7 +164,10 @@ STT_PROVIDER=auto # auto | mock | deepgram (auto=DEEPGRAM_API_KE DEEPGRAM_BASE_URL=https://api.deepgram.com/v1 DEEPGRAM_MODEL=whisper-large # 한국어 정확도 우선; 저비용 우선 시 nova-2 DEEPGRAM_LANGUAGE=ko -DEEPGRAM_TIMEOUT_SEC=60 +DEEPGRAM_TIMEOUT_SEC=30 # read 한도 (정상 호출 p50 3.6초 / 최대 12초) +DEEPGRAM_CONNECT_TIMEOUT_SEC=5 # connect 한도 — 일괄 timeout 이면 연결 막힘도 read 한도만큼 붙잡는다 +STT_MAX_ATTEMPTS=3 # 일시 장애(retriable) 재시도 횟수. 1 이면 재시도 없음 +STT_RETRY_BACKOFF_SEC=0.5 # 지수 백오프 기준값 (0.5 → 0.5s, 1s) TTS_PROVIDER=auto # auto | mock | gemini | gateway (auto=LLM_API_KEY(gateway) > GEMINI_API_KEY, 없으면 mock) GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts GEMINI_TTS_VOICE=Kore