generated from john/python-template
Error handling added to MVP according to error_handling.md guideline
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
## Step 7 Results: Error Handling Standardization and Operational Visibility
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Step 7 was implemented across the MVP runtime boundaries with a shared error taxonomy, actionable UI error surfacing, worker failure normalization, and API error envelope handling.
|
||||||
|
|
||||||
|
All required validation gates in `docs/step7.md` were executed and passed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Delivered
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- Shared application error contract and taxonomy
|
||||||
|
- Service-layer error normalization (upload + transcription)
|
||||||
|
- UI error presentation helpers with suggested actions and error references
|
||||||
|
- Worker failure persistence format with category/suggestion/error_id markers
|
||||||
|
- API exception handlers for structured error responses
|
||||||
|
- Targeted tests for new error contract behavior
|
||||||
|
|
||||||
|
### Not implemented in this step
|
||||||
|
- External lane execution (`-m external`) was not required for Step 7 completion and was not run in this pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
- `src/transcription/errors.py`
|
||||||
|
- `src/transcription/api/errors.py`
|
||||||
|
- `src/transcription/ui/error_presenter.py`
|
||||||
|
- `tests/test_errors.py`
|
||||||
|
- `tests/api/test_error_responses.py`
|
||||||
|
- `docs/step7.md`
|
||||||
|
|
||||||
|
## Files Updated
|
||||||
|
|
||||||
|
- `src/transcription/app.py`
|
||||||
|
- `src/transcription/services/upload.py`
|
||||||
|
- `src/transcription/services/transcription.py`
|
||||||
|
- `src/transcription/ui/upload_page.py`
|
||||||
|
- `src/transcription/ui/jobs_page.py`
|
||||||
|
- `src/transcription/worker.py`
|
||||||
|
- `tests/services/test_upload.py`
|
||||||
|
- `tests/services/test_transcription.py`
|
||||||
|
- `tests/services/test_worker.py`
|
||||||
|
- `tests/integration/test_pipeline_flow.py`
|
||||||
|
- `uv.lock`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes by Phase
|
||||||
|
|
||||||
|
### Phase A/B (Foundation)
|
||||||
|
- Added `ErrorCategory` enum and `AppError` base type in `src/transcription/errors.py`.
|
||||||
|
- Added helper utilities:
|
||||||
|
- `new_error_id()`
|
||||||
|
- `build_error_envelope(...)`
|
||||||
|
- `classify_unexpected_error(...)`
|
||||||
|
- `format_error_detail(...)`
|
||||||
|
|
||||||
|
### Phase C (Service/Provider normalization)
|
||||||
|
- `UploadError` now extends `AppError` and includes category/suggestion/retriable metadata.
|
||||||
|
- `PromptLoadError` and `TranscriptionError` now extend `AppError`.
|
||||||
|
- Provider failures are mapped with deterministic category semantics (auth/payload/provider-failure cases).
|
||||||
|
|
||||||
|
### Phase D (UI visibility)
|
||||||
|
- Added `src/transcription/ui/error_presenter.py`.
|
||||||
|
- Upload and jobs pages now use centralized UI error rendering and summary helpers.
|
||||||
|
- UI error paths now include more visible/actionable guidance and reference IDs.
|
||||||
|
|
||||||
|
### Phase E (Worker failure handling)
|
||||||
|
- Worker now normalizes exception handling into structured persisted `error_detail` strings with:
|
||||||
|
- category marker
|
||||||
|
- suggestion marker
|
||||||
|
- error_id marker
|
||||||
|
- Logging now includes category/error_id context in failure paths.
|
||||||
|
|
||||||
|
### Phase F (API envelope)
|
||||||
|
- Added `src/transcription/api/errors.py` and registered handlers in app factory.
|
||||||
|
- AppError and unexpected exceptions now serialize to stable API envelopes with mapped status codes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Commands and Outcomes
|
||||||
|
|
||||||
|
All commands were executed with `uv run python -m pytest ...` and completed successfully.
|
||||||
|
|
||||||
|
1. `uv run python -m pytest tests/test_errors.py -q` ✅
|
||||||
|
2. `uv run python -m pytest tests/services/test_upload.py -q` ✅
|
||||||
|
3. `uv run python -m pytest tests/services/test_transcription.py -q` ✅
|
||||||
|
4. `uv run python -m pytest tests/providers/test_openrouter.py -q` ✅
|
||||||
|
5. `uv run python -m pytest tests/services/test_worker.py -q` ✅
|
||||||
|
6. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q` ✅
|
||||||
|
7. `uv run python -m pytest tests/api/test_error_responses.py -q` ✅
|
||||||
|
8. `uv run python -m pytest tests/ui/test_upload_page.py -q` ✅
|
||||||
|
9. `uv run python -m pytest tests/ui/test_jobs_page.py -q` ✅
|
||||||
|
10. `uv run python -m pytest -m "not external" -q` ✅
|
||||||
|
11. `uv run python -m pytest --collect-only -q` ✅
|
||||||
|
12. `uv run python -m pytest -m unit -q` ✅
|
||||||
|
13. `uv run python -m pytest -m integration -q` ✅
|
||||||
|
14. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q` ✅
|
||||||
|
15. `uv run python -m pytest tests/ui/test_upload_page.py -q` ✅
|
||||||
|
16. `uv run python -m pytest tests/ui/test_jobs_page.py -q` ✅
|
||||||
|
17. `uv run python -m pytest -q` ✅
|
||||||
|
|
||||||
|
Observed warning (non-blocking): Starlette/FastAPI TestClient deprecation warning related to `httpx` package naming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Policy Alignment Check (`docs/error_handling.md`)
|
||||||
|
|
||||||
|
Aligned items:
|
||||||
|
- Stable taxonomy categories are implemented.
|
||||||
|
- Unexpected errors are normalized.
|
||||||
|
- User-facing UI paths include actionable guidance and references.
|
||||||
|
- Worker persistence includes trace-friendly failure detail.
|
||||||
|
- API error responses are structured and category-aware.
|
||||||
|
|
||||||
|
Follow-up candidates:
|
||||||
|
- Add richer UI tests that validate rendered suggested-action content end-to-end (current tests focus helper/service contracts).
|
||||||
|
- Consider typed storage fields for error metadata instead of packed `error_detail` strings in a future schema revision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7 Definition of Done Status
|
||||||
|
|
||||||
|
- [x] Shared error taxonomy implemented across MVP layers
|
||||||
|
- [x] GUI error paths upgraded for visibility/actionability
|
||||||
|
- [x] Worker failure persistence and log context standardized
|
||||||
|
- [x] API error envelope handling added and tested
|
||||||
|
- [x] Phase-level and full-suite validation gates passed
|
||||||
|
- [x] Results documented in this report
|
||||||
|
|
||||||
|
Step 7 is complete.
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
## Step 7: Error Handling Standardization and Operational Visibility
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Apply the canonical error policy from `docs/error_handling.md` to the MVP implementation so failures are:
|
||||||
|
|
||||||
|
- consistently classified
|
||||||
|
- visibly surfaced in the GUI
|
||||||
|
- paired with suggested corrective actions
|
||||||
|
- traceable through logs via error reference IDs
|
||||||
|
- validated through deterministic tests after each phase
|
||||||
|
|
||||||
|
This step extends MVP hardening by converting current ad hoc exception behavior into a stable cross-layer contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- Introduce a shared application error contract and taxonomy implementation
|
||||||
|
- Normalize service/provider exceptions into taxonomy categories
|
||||||
|
- Improve GUI error visibility and suggested-action UX
|
||||||
|
- Standardize worker failure persistence and logging context
|
||||||
|
- Add API error-envelope policy hooks for current/future endpoints
|
||||||
|
- Add targeted tests and phase-level/full-suite validation gates
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
- Major architecture rewrites (distributed queue, multi-service decomposition)
|
||||||
|
- Post-MVP feature expansion unrelated to error handling
|
||||||
|
- Full observability platform rollout (tracing backends, APM)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Policy Source of Truth
|
||||||
|
|
||||||
|
- Canonical policy document: `docs/error_handling.md`
|
||||||
|
- If implementation and policy diverge, policy is authoritative and code/tests must be updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned Deliverables
|
||||||
|
|
||||||
|
### Runtime code
|
||||||
|
- `src/transcription/errors.py` *(new shared contract module)*
|
||||||
|
- `src/transcription/ui/error_presenter.py` *(new UI error rendering helper)*
|
||||||
|
- Updates to:
|
||||||
|
- `src/transcription/services/upload.py`
|
||||||
|
- `src/transcription/services/transcription.py`
|
||||||
|
- `src/transcription/providers/openrouter.py`
|
||||||
|
- `src/transcription/worker.py`
|
||||||
|
- `src/transcription/ui/upload_page.py`
|
||||||
|
- `src/transcription/ui/jobs_page.py`
|
||||||
|
- `src/transcription/api/*` *(as needed for envelope/handlers)*
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/test_errors.py` *(new shared error contract tests)*
|
||||||
|
- updates/additions in:
|
||||||
|
- `tests/services/test_upload.py`
|
||||||
|
- `tests/services/test_transcription.py` *(add if missing)*
|
||||||
|
- `tests/providers/test_openrouter.py`
|
||||||
|
- `tests/services/test_worker.py`
|
||||||
|
- `tests/ui/test_upload_page.py`
|
||||||
|
- `tests/ui/test_jobs_page.py`
|
||||||
|
- `tests/api/test_error_responses.py` *(new, if API handlers added)*
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update `docs/error_handling.md` only if implementation reveals policy gaps
|
||||||
|
- Capture validation evidence in a Step 7 results artifact (`docs/step7-results.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design and Policy Decisions
|
||||||
|
|
||||||
|
1. **Stable taxonomy contract**
|
||||||
|
- Use policy categories as stable identifiers (`validation_error`, `user_input_error`, etc.).
|
||||||
|
|
||||||
|
2. **Actionable UX is mandatory**
|
||||||
|
- User-visible errors must include a suggested course of action.
|
||||||
|
|
||||||
|
3. **Traceability by default**
|
||||||
|
- Non-trivial errors include an `error_id` in both logs and user-facing output.
|
||||||
|
|
||||||
|
4. **Safe surface / rich logs**
|
||||||
|
- UI/API show safe summaries; logs retain diagnostic detail and traceback.
|
||||||
|
|
||||||
|
5. **Deterministic verification cadence**
|
||||||
|
- Targeted tests after each change batch, then phase-level regression gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan + Checklist
|
||||||
|
|
||||||
|
## Phase A — Baseline Validation and Gap Confirmation
|
||||||
|
|
||||||
|
- [ ] Run baseline tests before changes
|
||||||
|
- [ ] Record baseline outputs and any known flaky behavior
|
||||||
|
- [ ] Confirm current behavior against `docs/error_handling.md` requirements
|
||||||
|
|
||||||
|
### Validation gate
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
- [ ] `uv run pytest -q`
|
||||||
|
|
||||||
|
## Phase B — Shared Error Contract Foundation
|
||||||
|
|
||||||
|
- [ ] Add `src/transcription/errors.py` with:
|
||||||
|
- [ ] stable category enum
|
||||||
|
- [ ] base `AppError` (category/message/suggestion/error_id/retriable)
|
||||||
|
- [ ] helpers for error-id generation and fallback classification
|
||||||
|
- [ ] Keep category names aligned with `docs/error_handling.md`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- [ ] Add `tests/test_errors.py`
|
||||||
|
- [ ] category stability assertions
|
||||||
|
- [ ] error_id creation behavior
|
||||||
|
- [ ] fallback classification for unexpected exceptions
|
||||||
|
|
||||||
|
### Validation gate
|
||||||
|
- [ ] `uv run pytest tests/test_errors.py -q`
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
|
||||||
|
## Phase C — Service and Provider Normalization
|
||||||
|
|
||||||
|
- [ ] Refactor upload service exceptions to shared taxonomy
|
||||||
|
- [ ] Refactor transcription service exceptions to shared taxonomy
|
||||||
|
- [ ] Normalize provider adapter failures into deterministic categories
|
||||||
|
- [ ] Preserve causal chaining (`raise ... from exc`)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- [ ] Extend `tests/services/test_upload.py`:
|
||||||
|
- [ ] empty payload category/suggestion
|
||||||
|
- [ ] unsupported extension category/suggestion
|
||||||
|
- [ ] persistence failure category mapping
|
||||||
|
- [ ] Add/extend `tests/services/test_transcription.py`:
|
||||||
|
- [ ] missing/empty prompt behavior
|
||||||
|
- [ ] unsupported file type behavior
|
||||||
|
- [ ] provider failure mapping behavior
|
||||||
|
- [ ] Extend `tests/providers/test_openrouter.py`:
|
||||||
|
- [ ] auth error mapping
|
||||||
|
- [ ] malformed response mapping
|
||||||
|
|
||||||
|
### Validation gate
|
||||||
|
- [ ] `uv run pytest tests/services/test_upload.py -q`
|
||||||
|
- [ ] `uv run pytest tests/services/test_transcription.py -q`
|
||||||
|
- [ ] `uv run pytest tests/providers/test_openrouter.py -q`
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
|
||||||
|
## Phase D — GUI Visibility and Suggested Actions
|
||||||
|
|
||||||
|
- [ ] Add `src/transcription/ui/error_presenter.py`
|
||||||
|
- [ ] Update upload/jobs pages to use centralized error presentation
|
||||||
|
- [ ] Ensure GUI surfaces:
|
||||||
|
- [ ] user-safe message
|
||||||
|
- [ ] suggested action
|
||||||
|
- [ ] error reference ID
|
||||||
|
- [ ] optional technical details panel
|
||||||
|
- [ ] Replace raw `str(exc)` UX where policy requires safer messaging
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- [ ] Extend `tests/ui/test_upload_page.py` for actionable error UX paths
|
||||||
|
- [ ] Extend `tests/ui/test_jobs_page.py` for refresh/detail error guidance
|
||||||
|
- [ ] Add `tests/ui/test_error_presenter.py` *(optional but recommended)*
|
||||||
|
|
||||||
|
### Validation gate
|
||||||
|
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
||||||
|
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
|
||||||
|
## Phase E — Worker Failure Persistence and Logging Context
|
||||||
|
|
||||||
|
- [ ] Update worker failure handling to classify errors before persistence
|
||||||
|
- [ ] Ensure failed jobs persist actionable, structured error detail
|
||||||
|
- [ ] Add log context fields where available (`error_id`, `category`, `operation`, `job_id`)
|
||||||
|
- [ ] Ensure retry semantics are explicit and bounded (or clearly documented as deferred)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- [ ] Extend `tests/services/test_worker.py`:
|
||||||
|
- [ ] missing document failure contract
|
||||||
|
- [ ] provider/transcription failure contract
|
||||||
|
- [ ] persisted error detail includes category/suggestion/error_id markers
|
||||||
|
- [ ] Validate integration failure flow in `tests/integration/test_pipeline_flow.py`
|
||||||
|
|
||||||
|
### Validation gate
|
||||||
|
- [ ] `uv run pytest tests/services/test_worker.py -q`
|
||||||
|
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
|
||||||
|
## Phase F — API Error Envelope Alignment (Current + Future Routes)
|
||||||
|
|
||||||
|
- [ ] Add shared API error serialization utilities/handlers (as needed)
|
||||||
|
- [ ] Ensure API responses can include:
|
||||||
|
- [ ] `error_id`
|
||||||
|
- [ ] `category`
|
||||||
|
- [ ] `message`
|
||||||
|
- [ ] `suggestion`
|
||||||
|
- [ ] `timestamp`
|
||||||
|
- [ ] Map categories to HTTP status guidance from `docs/error_handling.md`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- [ ] Add `tests/api/test_error_responses.py` *(if handlers added)*
|
||||||
|
- [ ] Keep `tests/api/test_health.py` passing
|
||||||
|
|
||||||
|
### Validation gate
|
||||||
|
- [ ] `uv run pytest tests/api/test_error_responses.py -q` *(if added)*
|
||||||
|
- [ ] `uv run pytest tests/api/test_health.py -q`
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
|
||||||
|
## Phase G — Final Regression and Documentation Closure
|
||||||
|
|
||||||
|
- [ ] Reconcile implementation details with `docs/error_handling.md`
|
||||||
|
- [ ] Update policy doc only where required by confirmed implementation learning
|
||||||
|
- [ ] Capture execution evidence in `docs/step7-results.md`
|
||||||
|
|
||||||
|
### Final validation sequence (strict)
|
||||||
|
- [ ] `uv run pytest --collect-only -q`
|
||||||
|
- [ ] `uv run pytest -m unit -q`
|
||||||
|
- [ ] `uv run pytest -m integration -q`
|
||||||
|
- [ ] `uv run pytest -m "not external" -q`
|
||||||
|
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
|
||||||
|
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
||||||
|
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
||||||
|
- [ ] `uv run pytest -q`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- [ ] `uv run pytest -m external -q`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not weaken user-facing clarity to expose raw internals.
|
||||||
|
- Do not introduce silent exception swallowing.
|
||||||
|
- Do not break category-name stability without policy update.
|
||||||
|
- Do not merge phase changes without passing that phase validation gate.
|
||||||
|
- Keep targeted tests fast and deterministic; isolate external-provider tests under `external`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done (Step 7)
|
||||||
|
|
||||||
|
- [ ] Shared error taxonomy is implemented and used across MVP layers
|
||||||
|
- [ ] GUI error experiences are visible, actionable, and traceable
|
||||||
|
- [ ] Worker persists and logs failure context consistently
|
||||||
|
- [ ] API error contract path is aligned for current/future endpoints
|
||||||
|
- [ ] Phase-by-phase test gates pass
|
||||||
|
- [ ] Full suite remains green (`uv run pytest -q`)
|
||||||
|
- [ ] Step 7 results are documented with evidence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR Checklist (Step 7)
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
- [ ] Added shared error contract module
|
||||||
|
- [ ] Updated service/provider/worker/UI error handling paths
|
||||||
|
- [ ] Added actionable GUI guidance for user-visible failures
|
||||||
|
- [ ] Added error reference IDs for traceability
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Added/updated tests per phase scope
|
||||||
|
- [ ] Ran targeted phase tests after each change batch
|
||||||
|
- [ ] Ran `not external` regression at each phase boundary
|
||||||
|
- [ ] Ran full suite before closeout
|
||||||
|
|
||||||
|
### Documentation and Evidence
|
||||||
|
- [ ] `docs/error_handling.md` reviewed for alignment
|
||||||
|
- [ ] `docs/step7-results.md` includes executed command outputs
|
||||||
|
- [ ] Residual risks and deferred items explicitly recorded
|
||||||
@@ -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 fastapi import FastAPI
|
||||||
|
|
||||||
|
from transcription.api.errors import register_error_handlers
|
||||||
from transcription.api.health import router as health_router
|
from transcription.api.health import router as health_router
|
||||||
from transcription.config import get_settings, setup_logging
|
from transcription.config import get_settings, setup_logging
|
||||||
from transcription.db import create_all
|
from transcription.db import create_all
|
||||||
@@ -55,6 +56,7 @@ async def _lifespan(app: FastAPI):
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||||
|
register_error_handlers(app)
|
||||||
register_pages(app)
|
register_pages(app)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
return app
|
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 pathlib import Path
|
||||||
|
|
||||||
from transcription.config import Settings, get_settings
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -15,11 +23,11 @@ DEFAULT_PROMPT_FILE = "transcribe_document.md"
|
|||||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
||||||
|
|
||||||
|
|
||||||
class PromptLoadError(RuntimeError):
|
class PromptLoadError(AppError):
|
||||||
"""Raised when prompt artifacts cannot be loaded safely."""
|
"""Raised when prompt artifacts cannot be loaded safely."""
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionError(RuntimeError):
|
class TranscriptionError(AppError):
|
||||||
"""Raised when transcription execution fails."""
|
"""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
|
prompt_path = runtime_settings.prompt_dir / prompt_name
|
||||||
|
|
||||||
if not prompt_path.exists() or not prompt_path.is_file():
|
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()
|
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
||||||
if not prompt_text:
|
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)
|
logger.info("Loaded prompt artifact: %s", prompt_path)
|
||||||
return prompt_text
|
return prompt_text
|
||||||
@@ -44,17 +60,29 @@ def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
|
|||||||
path = Path(image_path)
|
path = Path(image_path)
|
||||||
|
|
||||||
if not path.exists() or not path.is_file():
|
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()
|
suffix = path.suffix.lower()
|
||||||
if suffix not in SUPPORTED_EXTENSIONS:
|
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)
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
if suffix in {".tif", ".tiff"}:
|
if suffix in {".tif", ".tiff"}:
|
||||||
mime_type = "image/tiff"
|
mime_type = "image/tiff"
|
||||||
if not mime_type:
|
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
|
return path.read_bytes(), mime_type
|
||||||
|
|
||||||
@@ -80,8 +108,26 @@ def transcribe_document_image(
|
|||||||
image_bytes=image_bytes,
|
image_bytes=image_bytes,
|
||||||
mime_type=mime_type,
|
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:
|
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)
|
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlmodel import Session
|
|||||||
|
|
||||||
from transcription.config import Settings, get_settings
|
from transcription.config import Settings, get_settings
|
||||||
from transcription.db import get_session
|
from transcription.db import get_session
|
||||||
|
from transcription.errors import AppError, ErrorCategory
|
||||||
from transcription.models import Document, Job, JobStatus
|
from transcription.models import Document, Job, JobStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
||||||
|
|
||||||
|
|
||||||
class UploadError(RuntimeError):
|
class UploadError(AppError):
|
||||||
"""Raised when uploaded content cannot be persisted safely."""
|
"""Raised when uploaded content cannot be persisted safely."""
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +53,11 @@ def create_upload_job(
|
|||||||
try:
|
try:
|
||||||
stored_path.write_bytes(file_bytes)
|
stored_path.write_bytes(file_bytes)
|
||||||
except OSError as exc:
|
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:
|
try:
|
||||||
if session is not None:
|
if session is not None:
|
||||||
@@ -66,7 +71,12 @@ def create_upload_job(
|
|||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
_best_effort_delete(stored_path)
|
_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)
|
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
|
||||||
return UploadJobResult(
|
return UploadJobResult(
|
||||||
@@ -79,15 +89,27 @@ def create_upload_job(
|
|||||||
|
|
||||||
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||||
if not file_bytes:
|
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
|
safe_name = Path(filename).name
|
||||||
if not safe_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()
|
suffix = Path(safe_name).suffix.lower()
|
||||||
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
|
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:
|
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.db import get_session
|
||||||
from transcription.models import Document, Job, Transcript
|
from transcription.models import Document, Job, Transcript
|
||||||
|
from transcription.ui.error_presenter import show_error, summarize_error
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -92,7 +93,8 @@ def register_page() -> None:
|
|||||||
render_table()
|
render_table()
|
||||||
status.text = "Refreshed"
|
status.text = "Refreshed"
|
||||||
except Exception as exc: # noqa: BLE001
|
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)
|
ui.button("Refresh", on_click=refresh)
|
||||||
render_table()
|
render_table()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from nicegui import ui
|
|||||||
from nicegui.events import UploadEventArguments
|
from nicegui.events import UploadEventArguments
|
||||||
|
|
||||||
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
|
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
|
||||||
|
from transcription.ui.error_presenter import show_error, summarize_error
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -46,9 +47,13 @@ def register_page() -> None:
|
|||||||
status_label.text = state.message
|
status_label.text = state.message
|
||||||
ui.notify(state.message, type="positive")
|
ui.notify(state.message, type="positive")
|
||||||
except UploadError as exc:
|
except UploadError as exc:
|
||||||
state.message = str(exc)
|
state.message = summarize_error(exc, operation="upload.submit")
|
||||||
status_label.text = f"Upload failed: {state.message}"
|
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:
|
finally:
|
||||||
state.loading = False
|
state.loading = False
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from threading import Event
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from transcription.db import get_session
|
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.models import Document, Job, JobStatus, Transcript
|
||||||
from transcription.services.transcription import transcribe_document_image
|
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)
|
document = session.get(Document, job.document_id)
|
||||||
if document is None:
|
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.status = JobStatus.FAILED
|
||||||
job.updated_at = datetime.now(timezone.utc)
|
job.updated_at = datetime.now(timezone.utc)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
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
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -60,9 +71,15 @@ def _process_next_queued_job(*, session: Session) -> bool:
|
|||||||
job.status = JobStatus.TRANSCRIBED
|
job.status = JobStatus.TRANSCRIBED
|
||||||
logger.info("Job transcribed job_id=%s provider=%s", job.id, result.provider)
|
logger.info("Job transcribed job_id=%s provider=%s", job.id, result.provider)
|
||||||
except Exception as exc: # noqa: BLE001
|
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
|
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)
|
job.updated_at = datetime.now(timezone.utc)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -72,3 +72,5 @@ class TestPipelineFailureFlow:
|
|||||||
assert transcript is not None
|
assert transcript is not None
|
||||||
assert transcript.text is None
|
assert transcript.text is None
|
||||||
assert "pipeline provider failure" in transcript.error_detail
|
assert "pipeline provider failure" in transcript.error_detail
|
||||||
|
assert "[internal_unexpected_error]" in transcript.error_detail
|
||||||
|
assert "error_id=" in transcript.error_detail
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ class TestPromptLoading:
|
|||||||
prompt_dir.mkdir()
|
prompt_dir.mkdir()
|
||||||
|
|
||||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
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)
|
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
|
@pytest.mark.unit
|
||||||
class TestImageLoading:
|
class TestImageLoading:
|
||||||
@@ -83,9 +86,12 @@ class TestImageLoading:
|
|||||||
"""Image loader raises TranscriptionError when image file does not exist."""
|
"""Image loader raises TranscriptionError when image file does not exist."""
|
||||||
missing = tmp_path / "missing.png"
|
missing = tmp_path / "missing.png"
|
||||||
|
|
||||||
with pytest.raises(TranscriptionError):
|
with pytest.raises(TranscriptionError) as exc_info:
|
||||||
load_image_payload(missing)
|
load_image_payload(missing)
|
||||||
|
|
||||||
|
assert exc_info.value.category.value == "not_found_error"
|
||||||
|
assert "verify" in exc_info.value.suggestion.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestTranscriptionService:
|
class TestTranscriptionService:
|
||||||
@@ -123,5 +129,9 @@ class TestTranscriptionService:
|
|||||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
||||||
provider = _FakeProvider(error=ProviderError("upstream failure"))
|
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)
|
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()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class TestUploadValidation:
|
|||||||
def test_rejects_empty_bytes(self, session, tmp_path: Path):
|
def test_rejects_empty_bytes(self, session, tmp_path: Path):
|
||||||
"""create_upload_job rejects an empty upload payload."""
|
"""create_upload_job rejects an empty upload payload."""
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
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(
|
create_upload_job(
|
||||||
filename="letter.jpg",
|
filename="letter.jpg",
|
||||||
file_bytes=b"",
|
file_bytes=b"",
|
||||||
@@ -24,10 +24,13 @@ class TestUploadValidation:
|
|||||||
settings=settings,
|
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):
|
def test_rejects_unsupported_extension(self, session, tmp_path: Path):
|
||||||
"""create_upload_job rejects unsupported filename extensions."""
|
"""create_upload_job rejects unsupported filename extensions."""
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
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(
|
create_upload_job(
|
||||||
filename="notes.txt",
|
filename="notes.txt",
|
||||||
file_bytes=b"content",
|
file_bytes=b"content",
|
||||||
@@ -35,6 +38,9 @@ class TestUploadValidation:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.category.value == "user_input_error"
|
||||||
|
assert "jpg" in exc_info.value.suggestion.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestUploadPersistence:
|
class TestUploadPersistence:
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class TestWorkerFailurePath:
|
|||||||
assert transcript is not None
|
assert transcript is not None
|
||||||
assert transcript.text is None
|
assert transcript.text is None
|
||||||
assert "provider failure" in transcript.error_detail
|
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):
|
def test_updates_existing_transcript_if_present(self, session, monkeypatch):
|
||||||
"""process_next_queued_job updates existing transcript instead of duplicating."""
|
"""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].id == existing.id
|
||||||
assert transcripts[0].text is None
|
assert transcripts[0].text is None
|
||||||
assert "provider failure" in transcripts[0].error_detail
|
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
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1356,7 +1356,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.138.0" },
|
{ name = "fastapi", specifier = ">=0.138.0" },
|
||||||
{ name = "nicegui", specifier = ">=3.13.0" },
|
{ name = "nicegui", specifier = "==3.13.0" },
|
||||||
{ name = "openrouter", specifier = ">=0.7.0" },
|
{ name = "openrouter", specifier = ">=0.7.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.9.1" },
|
{ name = "pydantic-settings", specifier = ">=2.9.1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user