Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ class UpstreamServiceError(Exception):
"""


def upstream_or_validation_error(status_code: int, message: str) -> Exception:
"""Their outage is not our client's bad request.

A revoked key or an exhausted quota (401, 403) is not silence any more than a rate
limit is: both mean ElevenLabs refused the request, not that the room said nothing.
"""
if status_code in (401, 403, 429) or status_code >= 500:
return UpstreamServiceError(message)
return ValidationError(message)


class UnreadableReply(Exception):
"""A model answered, and the answer could not be read — not a provider that is down.

Expand Down
14 changes: 4 additions & 10 deletions app/services/platform/stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import httpx

from app.core.config import Settings, get_settings
from app.core.exceptions import UpstreamServiceError, ValidationError
from app.core.exceptions import UpstreamServiceError, ValidationError, upstream_or_validation_error
from app.services.platform.voices import language_hint

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -83,7 +83,9 @@ async def transcribe_speech(
logger.warning(
"ElevenLabs STT failed: status=%s body=%s", response.status_code, response.text[:500]
)
raise _upstream_or_validation_error(response.status_code)
raise upstream_or_validation_error(
response.status_code, f"Transcription request failed with status {response.status_code}"
)

text = str(response.json().get("text") or "").strip()
logger.info(
Expand All @@ -96,14 +98,6 @@ async def transcribe_speech(
return text


def _upstream_or_validation_error(status_code: int) -> Exception:
"""Their outage is not our client's bad request — same split as the TTS service."""
message = f"Transcription request failed with status {status_code}"
if status_code == 429 or status_code >= 500:
return UpstreamServiceError(message)
return ValidationError(message)


def _make_client() -> httpx.AsyncClient:
global _DEFAULT_CLIENT
if _DEFAULT_CLIENT is None:
Expand Down
19 changes: 4 additions & 15 deletions app/services/platform/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import httpx

from app.core.config import Settings, get_settings
from app.core.exceptions import UpstreamServiceError, ValidationError
from app.core.exceptions import UpstreamServiceError, ValidationError, upstream_or_validation_error
from app.services.platform.voices import language_hint, resolve_voice

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -358,24 +358,13 @@ async def _synthesize(
response.status_code,
response.text[:500],
)
raise _upstream_or_validation_error(response.status_code)
raise upstream_or_validation_error(
response.status_code, f"TTS request failed with status {response.status_code}"
)

return bytes(response.content)


def _upstream_or_validation_error(status_code: int) -> Exception:
"""Their outage is not our client's bad request.

429 and 5xx mean ElevenLabs is rate limiting or down: that is an upstream failure (502),
and dressing it as a 400 means the right alert never fires. Other 4xx really are a
malformed request we sent, so they stay a business error.
"""
message = f"TTS request failed with status {status_code}"
if status_code == 429 or status_code >= 500:
return UpstreamServiceError(message)
return ValidationError(message)


def etag_of(audio: bytes) -> str:
return hashlib.sha256(audio).hexdigest()[:32]

Expand Down
17 changes: 3 additions & 14 deletions app/services/project_health/voice/elevenlabs_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import httpx

from app.core.config import Settings, get_settings
from app.core.exceptions import UpstreamServiceError, ValidationError
from app.core.exceptions import UpstreamServiceError, ValidationError, upstream_or_validation_error
from app.services.project_health.voice.cache import CachedAudio, audio_cache
from app.services.project_health.voice.voice_map import (
MULTILINGUAL_VOICE_ID,
Expand All @@ -31,17 +31,6 @@ def _require_api_key(cfg: Settings) -> str:
return cfg.ph_elevenlabs_api_key


def _upstream_or_validation_error(status_code: int, message: str) -> Exception:
"""Their outage is not our client's bad request.

A revoked key or an exhausted quota (401, 403) is not silence any more than a rate
limit is — same split as translation_helper/transcribe_audio.py.
"""
if status_code in (401, 403, 429) or status_code >= 500:
return UpstreamServiceError(message)
return ValidationError(message)


async def synthesize_speech(
text: str,
*,
Expand Down Expand Up @@ -96,7 +85,7 @@ async def synthesize_speech(
response.status_code,
response.text[:500],
)
raise _upstream_or_validation_error(
raise upstream_or_validation_error(
response.status_code, f"TTS request failed with status {response.status_code}"
)

Expand Down Expand Up @@ -197,7 +186,7 @@ async def transcribe_audio(
response.status_code,
response.text[:500],
)
raise _upstream_or_validation_error(
raise upstream_or_validation_error(
response.status_code,
f"Transcription request failed with status {response.status_code}",
)
Expand Down
28 changes: 11 additions & 17 deletions app/services/translation_helper/synthesize_speech.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import httpx

from app.core.config import Settings, get_settings
from app.core.exceptions import UpstreamServiceError, ValidationError
from app.core.exceptions import UpstreamServiceError, ValidationError, upstream_or_validation_error
from app.services.platform.tts import SpeechStore
from app.services.translation_helper.audio_cache import CachedAudio, audio_cache
from app.services.translation_helper.detect_language import detect_language_code
Expand Down Expand Up @@ -367,16 +367,22 @@ async def synthesize_speech(
}

http = client or _make_client()
response = await http.post(
url, json=body, params={"output_format": cfg.elevenlabs_output_format}, headers=headers
)
try:
response = await http.post(
url, json=body, params={"output_format": cfg.elevenlabs_output_format}, headers=headers
)
except httpx.HTTPError as error:
logger.warning("ElevenLabs TTS unreachable: %s", error)
raise UpstreamServiceError(f"Speech request could not reach ElevenLabs: {error}") from error
if response.status_code >= 400:
logger.warning(
"ElevenLabs TTS failed: status=%s body=%s",
response.status_code,
response.text[:500],
)
raise _upstream_or_validation_error(response.status_code)
raise upstream_or_validation_error(
response.status_code, f"TTS request failed with status {response.status_code}"
)

payload = response.json()
audio_b64 = payload.get("audio_base64") or ""
Expand All @@ -393,15 +399,3 @@ async def synthesize_speech(
if speech_store is not None:
await _write_durable(speech_store, cache_key, audio_bytes, timepoints)
return entry, False


def _upstream_or_validation_error(status_code: int) -> Exception:
"""Their outage is not our client's bad request.

A revoked key or an exhausted quota (401, 403) is not silence any more than a rate
limit is — same split as translation_helper/transcribe_audio.py.
"""
message = f"TTS request failed with status {status_code}"
if status_code in (401, 403, 429) or status_code >= 500:
return UpstreamServiceError(message)
return ValidationError(message)
18 changes: 4 additions & 14 deletions app/services/translation_helper/transcribe_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import httpx

from app.core.config import Settings, get_settings
from app.core.exceptions import UpstreamServiceError, ValidationError
from app.core.exceptions import UpstreamServiceError, ValidationError, upstream_or_validation_error

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -47,18 +47,6 @@ def _guess_mime_type(filename: str | None, fallback: str | None) -> str:
return "audio/mpeg"


def _upstream_or_validation_error(status_code: int) -> Exception:
"""Their outage is not our client's bad request.

A revoked key or an exhausted quota (401, 403) is not silence any more than a rate
limit is: both mean ElevenLabs refused the request, not that the room said nothing.
"""
message = f"Transcription request failed with status {status_code}"
if status_code in (401, 403, 429) or status_code >= 500:
return UpstreamServiceError(message)
return ValidationError(message)


def _filename_for_upload(filename: str | None, mime_type: str) -> str:
if filename:
return filename
Expand Down Expand Up @@ -167,7 +155,9 @@ async def transcribe_audio_detailed(
response.status_code,
response.text[:500],
)
raise _upstream_or_validation_error(response.status_code)
raise upstream_or_validation_error(
response.status_code, f"Transcription request failed with status {response.status_code}"
)

payload = response.json()
text = (payload.get("text") or "").strip()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_platform/test_stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async def test_without_an_api_key_it_is_a_configuration_error() -> None:
)


@pytest.mark.parametrize("status", [429, 500, 503])
@pytest.mark.parametrize("status", [401, 403, 429, 500, 503])
async def test_elevenlabs_unavailability_is_an_upstream_failure_not_a_client_error(
status: int,
) -> None:
Expand Down
5 changes: 3 additions & 2 deletions tests/test_platform/test_tts_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,12 @@ async def test_empty_text_is_an_error() -> None:
)


@pytest.mark.parametrize("status", [429, 500, 503])
@pytest.mark.parametrize("status", [401, 403, 429, 500, 503])
async def test_elevenlabs_unavailability_is_an_upstream_failure_not_a_client_error(
status: int,
) -> None:
# Their 429/5xx is not a bad request from the SPA: as a 400, the right alert never fires.
# A revoked key, a spent quota, or 429/5xx is not a bad request from the SPA: as a 400,
# the right alert never fires.
store = MemoryStore()

with pytest.raises(UpstreamServiceError):
Expand Down
12 changes: 12 additions & 0 deletions tests/test_th_audio_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,18 @@ async def test_synthesize_speech_raises_when_api_error(status: int) -> None:
await synthesize_speech("hello", client=client, settings=_settings())


@pytest.mark.parametrize(
"failure", [httpx.ConnectError("boom"), httpx.ReadTimeout("boom")], ids=["connect", "timeout"]
)
async def test_synthesize_speech_treats_a_dropped_connection_as_upstream_too(
failure: Exception,
) -> None:
audio_cache.clear()
client = SimpleNamespace(post=AsyncMock(side_effect=failure))
with pytest.raises(UpstreamServiceError):
await synthesize_speech("hello", client=client, settings=_settings())


async def test_synthesize_speech_requires_api_key() -> None:
audio_cache.clear()
s = Settings(database_url="sqlite+aiosqlite:///./test.db", elevenlabs_api_key="")
Expand Down
Loading