Skip to content

fix(ai): STT 일시 장애 재시도 + Deepgram 타임아웃 분리 - #244

Merged
i3months merged 1 commit into
devfrom
fix/stt-retry-and-timeout
Sep 23, 2026
Merged

i3months merged 1 commit into
devfrom
fix/stt-retry-and-timeout

Conversation

@i3months

Copy link
Copy Markdown
Member

작업 내용

  • VoiceConsumer 가 SttError.retriable 을 읽어 지수 백오프로 재시도 (STT_MAX_ATTEMPTS 기본 3, STT_RETRY_BACKOFF_SEC 0.5)
  • 비재시도(STT_AUTH_FAILED/STT_BAD_REQUEST)와 예상 못 한 예외는 재시도 없이 즉시 실패 콜백
  • 일괄 timeout=60 → httpx.Timeout(read=30, connect=5) 으로 분리
  • 타임아웃 예외 메시지에 예외 타입 포함 (ConnectTimeout / ReadTimeout 구분 가능)
  • ai_request_logs 는 시도마다 한 행, error_message 앞에 [n/N]

변경 이유 / 배경

운영 음성 답변 83건 중 7건(약 8%) 이 STT 실패해 "음성 인식에 실패했습니다. 텍스트로 다시 답변해 주세요." 로 확정됐습니다.

ai_request_logs 의 stt.transcribe 분포:

건수 소요 시간
SUCCESS 9 2.4~12.1초 (p50 3.6초)
FAILED 2 60.8초 / 61.5초

실패는 "느린 성공"이 아니라 deepgram_timeout_sec=60 에 정확히 걸린 타임아웃입니다. HTTP 상태도 응답 본문도 없습니다.

배제한 원인 (운영 서버 실측)

  • 오디오 크기 아님 — 실패 파일 160KB~851KB, 전체 중앙값 445KB, 1.7MB 파일은 성공
  • 오디오 손상 아님 — 실패 파일 6개 재전송 시 5개가 1.2~4.0초에 정상 전사
  • 인증·모델·설정 아님 — 키 유효, 두 모델 모두 응답 정상
  • 로컬 네트워크 아님 — 컨테이너 DNS 55~117ms, Deepgram anycast IP 8개 전부 도달, PMTU 1472 정상
  • 컨테이너 egress 아님 — 운영과 동일 경로(컨테이너 내부 httpx, 호출마다 새 AsyncClient) 15회 전부 성공, 1.18~2.29초

상류가 구간적으로 멎습니다(테스트 중 3개 모델 30/30 전부 행 → 몇 분 뒤 13/13 정상 회복). 우리가 통제할 수 있는 건 그 딸꾹질을 어떻게 받아내느냐뿐이고, 지금은 1회 실패가 곧 사용자에게 영구 실패였습니다.

retriable 이 죽어 있던 문제

SttError(retriable=True) 는 생성만 되고 아무도 읽지 않았습니다. questions/followup/feedback 컨슈머는 classify_failure 로 retriable 을 전파하는데 voice 만 빠져 있었고, VoiceCallbackPayload 에는 필드 자체가 없습니다. 실패 파일 5/6 이 재전송에서 성공하므로 재시도만으로 대부분 흡수됩니다.

테스트

  • 단위 테스트 추가 — 재시도 후 성공 / 비재시도 미재시도 / 재시도 소진 후 실패 콜백 / 예상 못 한 예외 미재시도 / 타임아웃 예외 타입 보존 / connect·read 분리 (6건)
  • uv run pytest 405 passed
  • black --check, flake8 clean
  • 운영 서버에서 원인 실측 (위 배제 목록)

영향 범위

  • DB 마이그레이션: 없음
  • API contract 변경: 없음 (콜백 스키마 무변경)
  • 환경변수 추가/변경: 있음 — DEEPGRAM_CONNECT_TIMEOUT_SEC, STT_MAX_ATTEMPTS, STT_RETRY_BACKOFF_SEC 신규 / DEEPGRAM_TIMEOUT_SEC 기본값 60→30. .env.example(루트·ai) + docs/environment.md 동기화 완료. 운영 .env 에 해당 키가 없어 기본값이 그대로 적용됩니다.

리뷰어 체크포인트

  • ai_request_logs 에 시도마다 한 행이 남습니다. Deepgram 호출 자체가 과금 단위라 이게 사실에 가깝다고 판단했는데, 기존에 이 테이블로 실패율을 보고 계셨다면 분모가 달라집니다 (error_message 의 [n/N] 으로 구분 가능). 한 행만 남기는 쪽이 낫다면 말씀해 주세요.
  • 예상 못 한 예외를 재시도 대상에서 뺐습니다. ai/CLAUDE.md §5 의 "코드 버그 → DLQ" 분류를 따랐는데, failure_signal.classify_failure 는 unknown 을 retriable=true 로 봅니다. voice 는 직접-발행 모델이라 다르게 잡았습니다.
  • read 30초가 너무 타이트한지 — 관측된 최대는 12초(52.9초 분량 오디오)입니다.

남은 작업 (별도 PR)

실패 시 오디오는 S3 에 남지만 재전사 API 가 없어 사용자가 답변을 통째로 다시 입력해야 합니다. 스키마·UI 변경이 필요해 분리했습니다.

🤖 Generated with Claude Code

운영 음성 답변 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] 으로 재시도율 추적.
@i3months
i3months merged commit 70f4423 into dev Sep 23, 2026
5 checks passed
i3months added a commit to i3months/stackup that referenced this pull request Oct 6, 2026
STT 가 실패하면 답변이 "음성 인식에 실패했습니다" 로 확정되고 끝이었다. 오디오는
S3 에 그대로 남아 있는데 꺼낼 경로가 없어서, 사용자는 방금 말한 답변을 통째로 다시
입력해야 했다.

실패의 대부분은 Deepgram 이 간헐적으로 멎는 것이고 같은 파일을 재전송하면 대체로
전사된다(PR Team-StackUp#244 의 자동 재시도가 1차 방어선). 이건 그마저 소진됐을 때 사용자가
직접 당기는 2차 방어선이다.

- POST /api/sessions/{sid}/messages/voice/{mid}/retranscribe
  (VoiceRetranscribeService) — placeholder 상태로 되돌리고 업로드와 같은
  VoiceAnswerUploadedEvent + AFTER_COMMIT 경로로 analyze.voice 재발행
- 실패 메시지가 세션의 마지막일 때만 허용. 이미 다시 답변했다면 뒤늦은 전사가
  지나간 턴을 덮어쓴다(VOICE_RETRANSCRIBE_NOT_ALLOWED)
- content_type 은 저장하지 않으므로 S3 key 확장자에서 역산(buildKey 의 역함수)
- 프론트: 스테이지 인라인 배너 + 대화 기록 드로어 양쪽에 '다시 인식하기'.
  스테이지에는 실패 안내가 아예 없어서, 사용자는 왜 답변이 사라졌는지 모른 채
  컴포저만 다시 열리는 걸 봐야 했다
- 녹음이 남아 있을 때는 문구도 "다시 답변해 주세요" 대신 "녹음은 남아 있어요" 로
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.

1 participant