From dc0531142ce2719e60328feddcfa6c83e81845c9 Mon Sep 17 00:00:00 2001 From: adavyas Date: Wed, 8 Apr 2026 23:26:23 -0400 Subject: [PATCH] Harden audio transcription error handling --- src/utils/files.py | 18 +++++++++---- tests/utils/test_audio_processing.py | 40 ++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/utils/files.py b/src/utils/files.py index 334b25bb..d905ce30 100644 --- a/src/utils/files.py +++ b/src/utils/files.py @@ -10,6 +10,7 @@ from typing import Any, Protocol from fastapi import UploadFile from nanoid import generate as generate_nanoid +from openai import APIError from sqlalchemy import Integer, select from sqlalchemy.ext.asyncio import AsyncSession @@ -34,6 +35,7 @@ AUDIO_EXTENSION_CONTENT_TYPES = { ".wav": "audio/wav", } UPLOAD_VALIDATION_CHUNK_BYTES = 1024 * 1024 +AUDIO_PROBE_TIMEOUT_SECONDS = 10 GENERIC_CONTENT_TYPES = { "", "application/octet-stream", @@ -156,11 +158,14 @@ class AudioProcessor: content, suffix, ) - text = await transcribe_audio( - content, - filename=normalized_filename, - content_type=normalized_content_type, - ) + try: + text = await transcribe_audio( + content, + filename=normalized_filename, + content_type=normalized_content_type, + ) + except APIError as exc: + raise FileProcessingError("Audio transcription failed") from exc return ExtractedFileText( text=text, metadata={ @@ -218,12 +223,15 @@ class AudioProcessor: capture_output=True, text=True, check=True, + timeout=AUDIO_PROBE_TIMEOUT_SECONDS, ) return max(float(result.stdout.strip()), 0.0) except FileNotFoundError as exc: raise ValidationException( "Audio uploads require ffmpeg and ffprobe to be installed on the server" ) from exc + except subprocess.TimeoutExpired as exc: + raise ValidationException("Audio validation timed out") from exc except (subprocess.CalledProcessError, ValueError) as exc: raise ValidationException("Uploaded audio is invalid or unreadable") from exc diff --git a/tests/utils/test_audio_processing.py b/tests/utils/test_audio_processing.py index d1bcfacb..9ee46ce6 100644 --- a/tests/utils/test_audio_processing.py +++ b/tests/utils/test_audio_processing.py @@ -1,16 +1,19 @@ import asyncio import io +import subprocess +from pathlib import Path from types import TracebackType from unittest.mock import AsyncMock, patch +import httpx import pytest from fastapi import UploadFile -from openai import AsyncOpenAI +from openai import APIError, AsyncOpenAI from starlette.datastructures import Headers import src.utils.files as file_utils from src.config import settings -from src.exceptions import ValidationException +from src.exceptions import FileProcessingError, ValidationException from src.utils.clients import CLIENTS, transcribe_audio from src.utils.files import AudioProcessor, FileProcessingService @@ -60,6 +63,19 @@ def test_probe_audio_duration_cleans_up_temp_file_on_write_failure(): mock_unlink.assert_called_once_with(missing_ok=True) +def test_probe_audio_duration_timeout_raises_validation_exception(): + processor = AudioProcessor() + + with ( + patch( + "src.utils.files.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="ffprobe", timeout=10), + ), + pytest.raises(ValidationException, match="Audio validation timed out"), + ): + processor.probe_audio_duration_seconds_from_path(Path("/tmp/audio.mp3")) + + @pytest.mark.asyncio async def test_audio_upload_requires_openai_client_before_processing(): file = UploadFile( @@ -181,6 +197,26 @@ async def test_audio_processor_extract_text_transcribes_directly(): assert "transcription_fallback_used" not in extracted.metadata +@pytest.mark.asyncio +async def test_audio_processor_extract_text_wraps_provider_errors(): + processor = AudioProcessor() + request = httpx.Request("POST", "https://api.openai.com/v1/audio/transcriptions") + + with ( + patch.object(processor, "_probe_audio_duration_seconds", return_value=1.0), + patch( + "src.utils.files.transcribe_audio", + side_effect=APIError("provider failed", request=request, body=None), + ), + pytest.raises(FileProcessingError, match="Audio transcription failed"), + ): + await processor.extract_text( + b"audio-bytes", + filename="seg-0.mp3", + content_type="audio/mpeg", + ) + + @pytest.mark.asyncio async def test_audio_processor_extract_text_probes_in_background_thread(): processor = AudioProcessor()