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
+48
View File
@@ -0,0 +1,48 @@
"""Centralized API exception handlers."""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from transcription.errors import AppError, ErrorCategory, build_error_envelope, classify_unexpected_error
logger = logging.getLogger(__name__)
_STATUS_BY_CATEGORY: dict[ErrorCategory, int] = {
ErrorCategory.VALIDATION: 400,
ErrorCategory.USER_INPUT: 400,
ErrorCategory.NOT_FOUND: 404,
ErrorCategory.CONFLICT: 409,
ErrorCategory.EXTERNAL_PROVIDER: 503,
ErrorCategory.INFRA_TRANSIENT: 503,
ErrorCategory.INFRA_PERSISTENT: 500,
ErrorCategory.INTERNAL_UNEXPECTED: 500,
}
def _status_for(error: AppError) -> int:
return _STATUS_BY_CATEGORY.get(error.category, 500)
def register_error_handlers(app: FastAPI) -> None:
"""Register API exception handlers on the app."""
@app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
envelope = build_error_envelope(exc)
return JSONResponse(status_code=_status_for(exc), content=envelope.__dict__)
@app.exception_handler(Exception)
async def fallback_error_handler(_request: Request, exc: Exception) -> JSONResponse:
normalized = classify_unexpected_error(exc, operation="api.request")
logger.exception(
"Unhandled API exception error_id=%s category=%s",
normalized.error_id,
normalized.category.value,
)
envelope = build_error_envelope(normalized)
return JSONResponse(status_code=_status_for(normalized), content=envelope.__dict__)
+2
View File
@@ -7,6 +7,7 @@ from threading import Event, Thread
from fastapi import FastAPI
from transcription.api.errors import register_error_handlers
from transcription.api.health import router as health_router
from transcription.config import get_settings, setup_logging
from transcription.db import create_all
@@ -55,6 +56,7 @@ async def _lifespan(app: FastAPI):
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan)
register_error_handlers(app)
register_pages(app)
app.include_router(health_router)
return app
+86
View File
@@ -0,0 +1,86 @@
"""Shared error taxonomy and helpers for runtime boundaries."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
from uuid import uuid4
class ErrorCategory(StrEnum):
"""Stable error categories defined by docs/error_handling.md."""
VALIDATION = "validation_error"
USER_INPUT = "user_input_error"
NOT_FOUND = "not_found_error"
CONFLICT = "conflict_error"
EXTERNAL_PROVIDER = "external_provider_error"
INFRA_TRANSIENT = "infrastructure_transient_error"
INFRA_PERSISTENT = "infrastructure_persistent_error"
INTERNAL_UNEXPECTED = "internal_unexpected_error"
def new_error_id() -> str:
"""Return a short, user-shareable error reference id."""
return uuid4().hex[:8]
class AppError(RuntimeError):
"""Base application error carrying user-safe handling metadata."""
def __init__(
self,
message: str,
*,
category: ErrorCategory = ErrorCategory.INTERNAL_UNEXPECTED,
suggestion: str = "Retry once. If it persists, review logs and report the error reference id.",
retriable: bool = False,
error_id: str | None = None,
) -> None:
super().__init__(message)
self.message = message
self.category = category
self.suggestion = suggestion
self.retriable = retriable
self.error_id = error_id or new_error_id()
@dataclass(frozen=True)
class ErrorEnvelope:
"""Serializable API/UI error payload."""
error_id: str
category: str
message: str
suggestion: str
timestamp: str
def build_error_envelope(error: AppError) -> ErrorEnvelope:
"""Build an API-safe response envelope from an AppError."""
return ErrorEnvelope(
error_id=error.error_id,
category=error.category.value,
message=error.message,
suggestion=error.suggestion,
timestamp=datetime.now(timezone.utc).isoformat(),
)
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize unknown exceptions into internal_unexpected_error."""
return AppError(
f"Unexpected error during {operation}: {exc}",
category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False,
)
def format_error_detail(error: AppError) -> str:
"""Return a compact persisted failure string for transcript.error_detail."""
return (
f"[{error.category.value}] {error.message} | "
f"suggestion={error.suggestion} | error_id={error.error_id}"
)
+55 -9
View File
@@ -7,7 +7,15 @@ import mimetypes
from pathlib import Path
from transcription.config import Settings, get_settings
from transcription.providers import ProviderError, TranscriptionProvider, TranscriptionResult, get_transcription_provider
from transcription.errors import AppError, ErrorCategory
from transcription.providers import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
TranscriptionProvider,
TranscriptionResult,
get_transcription_provider,
)
logger = logging.getLogger(__name__)
@@ -15,11 +23,11 @@ DEFAULT_PROMPT_FILE = "transcribe_document.md"
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class PromptLoadError(RuntimeError):
class PromptLoadError(AppError):
"""Raised when prompt artifacts cannot be loaded safely."""
class TranscriptionError(RuntimeError):
class TranscriptionError(AppError):
"""Raised when transcription execution fails."""
@@ -29,11 +37,19 @@ def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settin
prompt_path = runtime_settings.prompt_dir / prompt_name
if not prompt_path.exists() or not prompt_path.is_file():
raise PromptLoadError(f"Prompt file not found: {prompt_path}")
raise PromptLoadError(
f"Prompt file not found: {prompt_path}",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
)
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
if not prompt_text:
raise PromptLoadError(f"Prompt file is empty: {prompt_path}")
raise PromptLoadError(
f"Prompt file is empty: {prompt_path}",
category=ErrorCategory.VALIDATION,
suggestion="Populate the prompt file with valid instructions and retry.",
)
logger.info("Loaded prompt artifact: %s", prompt_path)
return prompt_text
@@ -44,17 +60,29 @@ def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
path = Path(image_path)
if not path.exists() or not path.is_file():
raise TranscriptionError(f"Image file not found: {path}")
raise TranscriptionError(
f"Image file not found: {path}",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the uploaded file exists and retry from the jobs page.",
)
suffix = path.suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise TranscriptionError(f"Unsupported file type: {suffix}")
raise TranscriptionError(
f"Unsupported file type: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Use JPG, JPEG, PNG, TIFF, or PDF files.",
)
mime_type, _ = mimetypes.guess_type(path.name)
if suffix in {".tif", ".tiff"}:
mime_type = "image/tiff"
if not mime_type:
raise TranscriptionError(f"Unable to determine MIME type for: {path}")
raise TranscriptionError(
f"Unable to determine MIME type for: {path}",
category=ErrorCategory.VALIDATION,
suggestion="Re-save the file in a supported format and retry.",
)
return path.read_bytes(), mime_type
@@ -80,8 +108,26 @@ def transcribe_document_image(
image_bytes=image_bytes,
mime_type=mime_type,
)
except ProviderAuthError as exc:
raise TranscriptionError(
"Provider authentication failed",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Verify provider API credentials and retry.",
) from exc
except ProviderResponseError as exc:
raise TranscriptionError(
"Provider returned an invalid response",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
retriable=True,
) from exc
except ProviderError as exc:
raise TranscriptionError("Provider transcription failed") from exc
raise TranscriptionError(
"Provider transcription failed",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
retriable=True,
) from exc
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
return result
+28 -6
View File
@@ -11,6 +11,7 @@ from sqlmodel import Session
from transcription.config import Settings, get_settings
from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory
from transcription.models import Document, Job, JobStatus
logger = logging.getLogger(__name__)
@@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class UploadError(RuntimeError):
class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely."""
@@ -52,7 +53,11 @@ def create_upload_job(
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(f"Failed to persist upload file: {stored_path}") from exc
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
try:
if session is not None:
@@ -66,7 +71,12 @@ def create_upload_job(
)
except Exception as exc: # noqa: BLE001
_best_effort_delete(stored_path)
raise UploadError("Failed to create upload database records") from exc
raise UploadError(
"Failed to create upload database records",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry upload. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
return UploadJobResult(
@@ -79,15 +89,27 @@ def create_upload_job(
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
if not file_bytes:
raise UploadError("Upload payload is empty")
raise UploadError(
"Upload payload is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty file and try again.",
)
safe_name = Path(filename).name
if not safe_name:
raise UploadError("Upload filename is required")
raise UploadError(
"Upload filename is required",
category=ErrorCategory.VALIDATION,
suggestion="Choose a file with a valid filename and retry.",
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
raise UploadError(f"Unsupported upload extension: {suffix}")
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
)
def _build_stored_filename(filename: str) -> str:
+40
View File
@@ -0,0 +1,40 @@
"""Shared UI error rendering helpers."""
from __future__ import annotations
from nicegui import ui
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error
def to_app_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize any exception for consistent UI display."""
if isinstance(exc, AppError):
return exc
return classify_unexpected_error(exc, operation=operation)
def show_error(exc: Exception, *, title: str, operation: str) -> None:
"""Display a visible, actionable UI error with trace id."""
error = to_app_error(exc, operation=operation)
ui.notify(
f"{title}: {error.message} (ref: {error.error_id})",
type="negative",
timeout=0,
close_button="Dismiss",
)
with ui.card().classes("bg-red-1 text-red-10 q-mt-md q-pa-md"):
ui.label(title).classes("text-subtitle1")
ui.label(error.message)
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
ui.label(f"Error reference: {error.error_id}").classes("text-caption")
ui.label(f"Category: {error.category.value}").classes("text-caption")
def summarize_error(exc: Exception, *, operation: str) -> str:
"""Return short one-line summary for status labels."""
error = to_app_error(exc, operation=operation)
if error.category == ErrorCategory.INTERNAL_UNEXPECTED:
return f"Unexpected error (ref: {error.error_id})"
return f"{error.message} (ref: {error.error_id})"
+3 -1
View File
@@ -10,6 +10,7 @@ from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document, Job, Transcript
from transcription.ui.error_presenter import show_error, summarize_error
@dataclass(frozen=True)
@@ -92,7 +93,8 @@ def register_page() -> None:
render_table()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {exc}"
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
ui.button("Refresh", on_click=refresh)
render_table()
+7 -2
View File
@@ -8,6 +8,7 @@ from nicegui import ui
from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
from transcription.ui.error_presenter import show_error, summarize_error
@dataclass
@@ -46,9 +47,13 @@ def register_page() -> None:
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = str(exc)
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
ui.notify(f"Upload failed: {state.message}", type="negative")
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
+21 -4
View File
@@ -10,6 +10,7 @@ from threading import Event
from sqlmodel import Session, select
from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, format_error_detail
from transcription.models import Document, Job, JobStatus, Transcript
from transcription.services.transcription import transcribe_document_image
@@ -46,12 +47,22 @@ def _process_next_queued_job(*, session: Session) -> bool:
document = session.get(Document, job.document_id)
if document is None:
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail="Document not found")
error = AppError(
"Document not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Re-upload the source document and retry processing.",
)
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.status = JobStatus.FAILED
job.updated_at = datetime.now(timezone.utc)
session.add(job)
session.commit()
logger.error("Job failed because document was missing job_id=%s", job.id)
logger.error(
"Job failed because document was missing job_id=%s error_id=%s category=%s",
job.id,
error.error_id,
error.category.value,
)
return True
try:
@@ -60,9 +71,15 @@ def _process_next_queued_job(*, session: Session) -> bool:
job.status = JobStatus.TRANSCRIBED
logger.info("Job transcribed job_id=%s provider=%s", job.id, result.provider)
except Exception as exc: # noqa: BLE001
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=str(exc))
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.status = JobStatus.FAILED
logger.exception("Job failed job_id=%s", job.id)
logger.exception(
"Job failed job_id=%s error_id=%s category=%s",
job.id,
error.error_id,
error.category.value,
)
job.updated_at = datetime.now(timezone.utc)
session.add(job)