hermes(stt-client): retry a brief STT outage, never dump a traceback

A transcription that landed while the private Whisper service was
restarting (an image roll) crashed hermes_stt_client.py with a raw
urllib ConnectionRefused traceback that got dumped into the
conversation. The client now retries the request with backoff (up to 5
attempts, ~10s - long enough to ride an STT pod restart) and, on a
persistent outage, exits with one concise line ('speech transcription
unavailable...') instead of a stack trace. Delivered via the coordinator
ConfigMap; the next reconcile picks it up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-24 20:16:57 -03:00
parent c54eeada1b
commit 546e960185
2 changed files with 77 additions and 3 deletions

View File

@ -8,9 +8,17 @@ import json
import mimetypes import mimetypes
import os import os
import secrets import secrets
import sys
import time
from pathlib import Path from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
# The private Whisper service briefly restarts on image rolls; a transcription
# request that lands in that window must retry rather than crash the turn.
_MAX_ATTEMPTS = 5
_RETRY_BACKOFF_SECONDS = (0.5, 1.5, 3.0, 5.0)
LANGUAGE_SUFFIX = ".language" LANGUAGE_SUFFIX = ".language"
@ -70,6 +78,29 @@ def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
return b"".join(chunks), boundary return b"".join(chunks), boundary
def _transcribe_with_retry(request: Request) -> dict:
"""POST to the Whisper service, retrying a brief outage (a service roll).
A connection error means the private STT service is momentarily down
(e.g. rolling to a new image); retry with backoff instead of surfacing a
Python traceback into the conversation. On exhaustion, raise a plain
RuntimeError so ``main`` can print one short line, not a stack trace.
"""
last_error: Exception | None = None
for attempt in range(_MAX_ATTEMPTS):
try:
with urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except (URLError, ConnectionError, TimeoutError, OSError) as error:
last_error = error
if attempt < _MAX_ATTEMPTS - 1:
delay = _RETRY_BACKOFF_SECONDS[min(attempt, len(_RETRY_BACKOFF_SECONDS) - 1)]
time.sleep(delay)
raise RuntimeError(
"the speech service is temporarily unavailable; please try again"
) from last_error
def main() -> None: def main() -> None:
"""Transcribe one file and emit the .txt contract Hermes expects.""" """Transcribe one file and emit the .txt contract Hermes expects."""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -92,8 +123,7 @@ def main() -> None:
}, },
method="POST", method="POST",
) )
with urlopen(request, timeout=120) as response: result = _transcribe_with_retry(request)
result = json.loads(response.read().decode("utf-8"))
transcript = str(result.get("text") or "").strip() transcript = str(result.get("text") or "").strip()
_write_result( _write_result(
args.output_dir, args.output_dir,
@ -104,4 +134,9 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
except RuntimeError as error:
# A concise message, never a traceback, reaches the conversation.
print(f"speech transcription unavailable: {error}", file=sys.stderr)
sys.exit(1)

View File

@ -365,6 +365,45 @@ def test_stt_language_absent_when_whisper_omits_it(monkeypatch):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_stt_client_retries_a_brief_outage_then_fails_cleanly(monkeypatch, tmp_path):
"""A momentary connection refusal (service roll) retries; a persistent
outage raises a plain RuntimeError, never a urllib traceback."""
module = _load_stt_client()
monkeypatch.setattr(module.time, "sleep", lambda *_a, **_k: None)
calls = {"n": 0}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return b'{"text": "recovered", "language": "en"}'
def flaky(_request, timeout=None):
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionRefusedError(111, "Connection refused")
return _Resp()
monkeypatch.setattr(module, "urlopen", flaky)
req = module.Request("http://stt.invalid/", data=b"", method="POST")
assert module._transcribe_with_retry(req)["text"] == "recovered"
assert calls["n"] == 3
def always_refused(_request, timeout=None):
raise ConnectionRefusedError(111, "Connection refused")
monkeypatch.setattr(module, "urlopen", always_refused)
import pytest as _pytest
with _pytest.raises(RuntimeError, match="temporarily unavailable"):
module._transcribe_with_retry(req)
def test_stt_client_writes_language_sidecar_beside_the_txt_contract(tmp_path): def test_stt_client_writes_language_sidecar_beside_the_txt_contract(tmp_path):
module = _load_stt_client() module = _load_stt_client()
module._write_result(tmp_path, "voice-input", "Как дела?", "ru") module._write_result(tmp_path, "voice-input", "Как дела?", "ru")