Error handling added to MVP according to error_handling.md guideline

This commit is contained in:
Jim Lancaster
2026-06-25 12:56:35 -05:00
parent 0cc6b0e1eb
commit e291ffc907
18 changed files with 819 additions and 28 deletions
+56
View File
@@ -0,0 +1,56 @@
"""Tests for API error response envelope handlers."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers
from transcription.errors import AppError, ErrorCategory
@pytest.mark.integration
class TestApiErrorResponses:
"""Verify API-level error serialization and status mapping."""
def test_app_error_returns_structured_envelope(self):
"""AppError maps to policy envelope fields and status code."""
app = FastAPI()
register_error_handlers(app)
@app.get("/boom")
def boom() -> dict[str, str]:
raise AppError(
"Bad upload payload",
category=ErrorCategory.VALIDATION,
suggestion="Upload a non-empty file",
error_id="abc12345",
)
client = TestClient(app)
response = client.get("/boom")
assert response.status_code == 400
payload = response.json()
assert payload["error_id"] == "abc12345"
assert payload["category"] == "validation_error"
assert payload["message"] == "Bad upload payload"
assert payload["suggestion"] == "Upload a non-empty file"
assert "timestamp" in payload
def test_unexpected_error_returns_internal_unexpected_envelope(self):
"""Unexpected exceptions map to internal_unexpected_error with 500."""
app = FastAPI()
register_error_handlers(app)
@app.get("/explode")
def explode() -> dict[str, str]:
raise RuntimeError("unexpected failure")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/explode")
assert response.status_code == 500
payload = response.json()
assert payload["category"] == "internal_unexpected_error"
assert "error_id" in payload
assert payload["suggestion"]
+2
View File
@@ -72,3 +72,5 @@ class TestPipelineFailureFlow:
assert transcript is not None
assert transcript.text is None
assert "pipeline provider failure" in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail
+13 -3
View File
@@ -60,9 +60,12 @@ class TestPromptLoading:
prompt_dir.mkdir()
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
with pytest.raises(PromptLoadError):
with pytest.raises(PromptLoadError) as exc_info:
load_prompt_text(settings=settings)
assert exc_info.value.category.value == "infrastructure_persistent_error"
assert "verify prompt_dir" in exc_info.value.suggestion.lower()
@pytest.mark.unit
class TestImageLoading:
@@ -83,9 +86,12 @@ class TestImageLoading:
"""Image loader raises TranscriptionError when image file does not exist."""
missing = tmp_path / "missing.png"
with pytest.raises(TranscriptionError):
with pytest.raises(TranscriptionError) as exc_info:
load_image_payload(missing)
assert exc_info.value.category.value == "not_found_error"
assert "verify" in exc_info.value.suggestion.lower()
@pytest.mark.unit
class TestTranscriptionService:
@@ -123,5 +129,9 @@ class TestTranscriptionService:
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
provider = _FakeProvider(error=ProviderError("upstream failure"))
with pytest.raises(TranscriptionError):
with pytest.raises(TranscriptionError) as exc_info:
transcribe_document_image(image_path, settings=settings, provider=provider)
assert exc_info.value.category.value == "external_provider_error"
assert exc_info.value.retriable is True
assert "retry" in exc_info.value.suggestion.lower()
+8 -2
View File
@@ -16,7 +16,7 @@ class TestUploadValidation:
def test_rejects_empty_bytes(self, session, tmp_path: Path):
"""create_upload_job rejects an empty upload payload."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError):
with pytest.raises(UploadError) as exc_info:
create_upload_job(
filename="letter.jpg",
file_bytes=b"",
@@ -24,10 +24,13 @@ class TestUploadValidation:
settings=settings,
)
assert exc_info.value.category.value == "validation_error"
assert "non-empty" in exc_info.value.suggestion.lower()
def test_rejects_unsupported_extension(self, session, tmp_path: Path):
"""create_upload_job rejects unsupported filename extensions."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError):
with pytest.raises(UploadError) as exc_info:
create_upload_job(
filename="notes.txt",
file_bytes=b"content",
@@ -35,6 +38,9 @@ class TestUploadValidation:
settings=settings,
)
assert exc_info.value.category.value == "user_input_error"
assert "jpg" in exc_info.value.suggestion.lower()
@pytest.mark.integration
class TestUploadPersistence:
+5
View File
@@ -95,6 +95,9 @@ class TestWorkerFailurePath:
assert transcript is not None
assert transcript.text is None
assert "provider failure" in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail
assert "suggestion=" in transcript.error_detail
def test_updates_existing_transcript_if_present(self, session, monkeypatch):
"""process_next_queued_job updates existing transcript instead of duplicating."""
@@ -118,6 +121,8 @@ class TestWorkerFailurePath:
assert transcripts[0].id == existing.id
assert transcripts[0].text is None
assert "provider failure" in transcripts[0].error_detail
assert "[internal_unexpected_error]" in transcripts[0].error_detail
assert "error_id=" in transcripts[0].error_detail
@pytest.mark.unit
+43
View File
@@ -0,0 +1,43 @@
"""Tests for shared error taxonomy and helpers."""
import pytest
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id
@pytest.mark.unit
class TestErrorCategoryContract:
"""Verify stable category identifiers."""
def test_category_values_match_policy_contract(self):
"""Error category values match docs/error_handling.md identifiers."""
assert ErrorCategory.VALIDATION.value == "validation_error"
assert ErrorCategory.USER_INPUT.value == "user_input_error"
assert ErrorCategory.NOT_FOUND.value == "not_found_error"
assert ErrorCategory.CONFLICT.value == "conflict_error"
assert ErrorCategory.EXTERNAL_PROVIDER.value == "external_provider_error"
assert ErrorCategory.INFRA_TRANSIENT.value == "infrastructure_transient_error"
assert ErrorCategory.INFRA_PERSISTENT.value == "infrastructure_persistent_error"
assert ErrorCategory.INTERNAL_UNEXPECTED.value == "internal_unexpected_error"
@pytest.mark.unit
class TestAppErrorHelpers:
"""Verify helper behavior for IDs and normalization."""
def test_new_error_id_returns_short_identifier(self):
"""new_error_id returns a short non-empty identifier."""
value = new_error_id()
assert isinstance(value, str)
assert len(value) == 8
def test_classify_unexpected_error_returns_internal_unexpected(self):
"""Unexpected exceptions are normalized to internal_unexpected_error."""
err = classify_unexpected_error(RuntimeError("boom"), operation="unit.test")
assert isinstance(err, AppError)
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
assert "unit.test" in err.message
assert "boom" in err.message
assert err.suggestion
assert err.error_id