generated from john/python-template
Error handling added to MVP according to error_handling.md guideline
This commit is contained in:
@@ -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__)
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user