Files
mcma-backend/app/api/errors.py
T
Цвылев Александр Вадимович a263272935 feat(api): finish Group A stubbed endpoints
Implements previously-stubbed /api/v1 endpoints (hexagonal: ports -> repos
-> services -> routers wired in deps):

- playlists: GET /{id}/cover (serves stored cover, 404 when absent)
- settings: GET/PATCH /settings + GET/PUT /settings/scrobbling — lazy
  per-user row, write-only Fernet-encrypted scrobble session key; adds
  user_settings table + migration (chains off dc126696f5a6)
- storage: GET /duplicates, /broken, /missing-metadata + admin POST
  /cleanup (arq cleanup_storage worker; reconciles local refs only,
  guarded against a storage-outage mass delete)
- admin: GET /services, /sources, /settings + POST /reindex; PATCH
  /settings and /sources/{source} return 501 (config is env-managed)

Adds NotSupportedError (-> HTTP 501). Integration tests for each surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:33:12 +03:00

93 lines
3.4 KiB
Python

"""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,
NotSupportedError,
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,
NotSupportedError: status.HTTP_501_NOT_IMPLEMENTED,
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."),
)