"""Maps domain exceptions to HTTP responses. The only place that knows both. Two surfaces share this mapping: the native ``/api/v1`` API answers with a JSON error body and an HTTP status code, while the Subsonic ``/rest`` layer answers with its own envelope and **always HTTP 200** (the status lives in the body). A request is routed to the Subsonic renderer by path prefix. """ from fastapi import FastAPI, Request, Response, status from fastapi.responses import JSONResponse from app.api.rest.envelope import subsonic_error from app.core.logging import get_logger from app.domain.errors import ( AlreadyExistsError, AuthenticationError, ConflictError, DependencyUnavailableError, DomainError, NotFoundError, PermissionDeniedError, RangeNotSatisfiableError, StorageError, ValidationError, ) log = get_logger(__name__) _STATUS_BY_ERROR: dict[type[DomainError], int] = { NotFoundError: status.HTTP_404_NOT_FOUND, AlreadyExistsError: status.HTTP_409_CONFLICT, ConflictError: status.HTTP_409_CONFLICT, ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT, AuthenticationError: status.HTTP_401_UNAUTHORIZED, PermissionDeniedError: status.HTTP_403_FORBIDDEN, DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE, StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR, } # Subsonic error codes (subsonic.org/restapi): 10 missing param, 40 wrong # credentials, 50 not authorized, 70 not found, 0 generic. _SUBSONIC_CODE_BY_ERROR: dict[type[DomainError], int] = { ValidationError: 10, AuthenticationError: 40, PermissionDeniedError: 50, NotFoundError: 70, } _SUBSONIC_PREFIX = "/rest" def _is_subsonic(request: Request) -> bool: return request.url.path.startswith(_SUBSONIC_PREFIX) def _error_body(code: str, message: str) -> dict[str, dict[str, str]]: return {"error": {"code": code, "message": message}} def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(RangeNotSatisfiableError) async def _handle_range_error(_request: Request, exc: RangeNotSatisfiableError) -> JSONResponse: return JSONResponse( status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, content=_error_body(exc.code, exc.message), headers={"Content-Range": f"bytes */{exc.total_size}"}, ) @app.exception_handler(DomainError) async def _handle_domain_error(request: Request, exc: DomainError) -> Response: if _is_subsonic(request): code = _SUBSONIC_CODE_BY_ERROR.get(type(exc), 0) return subsonic_error(code, exc.message, fmt=request.query_params.get("f")) http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST) return JSONResponse( status_code=http_status, content=_error_body(exc.code, exc.message), ) @app.exception_handler(Exception) async def _handle_unexpected(request: Request, exc: Exception) -> Response: log.error("unhandled_exception", exc_info=exc) if _is_subsonic(request): return subsonic_error( 0, "An unexpected error occurred.", fmt=request.query_params.get("f") ) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=_error_body("internal_error", "An unexpected error occurred."), )