diff --git a/packages/divbase-api/src/divbase_api/exception_handlers.py b/packages/divbase-api/src/divbase_api/exception_handlers.py index efe60f7c..0d7f2101 100644 --- a/packages/divbase-api/src/divbase_api/exception_handlers.py +++ b/packages/divbase-api/src/divbase_api/exception_handlers.py @@ -9,6 +9,8 @@ 3. Less work/duplication in the routes themselves, just raise the exception and the handler makes it pretty. """ +import logging + import structlog from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError @@ -17,41 +19,31 @@ from divbase_api.db import get_db from divbase_api.deps import _authenticate_frontend_user_from_tokens -from divbase_api.exceptions import ( - AuthenticationError, - AuthorizationError, - DimensionsUpdateAlreadyInProcessError, - DownloadedFileChecksumMismatchError, - ObjectDoesNotExistError, - PATDuplicateNameError, - PATLimitExceededError, - ProjectCreationError, - ProjectMemberAlreadyExistsError, - ProjectMemberNotFoundError, - ProjectNotFoundError, - ProjectVersionAlreadyExistsError, - ProjectVersionCreationError, - ProjectVersionNotFoundError, - ProjectVersionSoftDeletedError, - QueueClosedError, - TaskNotFoundInBackendError, - TooManyObjectsInRequestError, - UserNotFoundError, - UserRegistrationError, - VCFDimensionsEntryMissingError, -) +from divbase_api.exceptions import DivBaseAPIException, UserRegistrationError from divbase_api.frontend_routes.core import templates from divbase_api.models.users import UserDB logger = structlog.get_logger(__name__) -def is_api_request(request: Request) -> bool: +def _is_api_request(request: Request) -> bool: """Helper function to check if request comes from frontend or API.""" return request.url.path.startswith("/api/") -async def get_current_user_from_request_object(request: Request) -> UserDB | None: +def _log_exception( + request: Request, exc: Exception, log_level: int, prefix: str, include_exc_info: bool = True +) -> None: + """Helper function to log exception with request details""" + event = f"{prefix} Details: {request.method} {request.url.path}: {exc}" + logger.log( + log_level, + event, + exc_info=include_exc_info, + ) + + +async def _get_current_user_from_request_object(request: Request) -> UserDB | None: """Helper function to get current user from request object""" access_token = request.cookies.get("access_token") refresh_token = request.cookies.get("refresh_token") @@ -65,7 +57,7 @@ async def get_current_user_from_request_object(request: Request) -> UserDB | Non return user -def add_request_id_header(request: Request, response: Response) -> Response: +def _add_request_id_header(request: Request, response: Response) -> Response: """ helper fn to add X-Request-ID to an existing response Needed in the global/generic exception handler as the middleware approach of appending the header will not work as won't be reached. @@ -76,16 +68,17 @@ def add_request_id_header(request: Request, response: Response) -> Response: return response -async def render_error_page( +async def _render_error_page( request: Request, message: str, + error_template: str = "error.html", status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, ): - """Helper function to render the generic error page for frontend requests.""" - current_user = await get_current_user_from_request_object(request) + """Helper function to render an error page for frontend requests.""" + current_user = await _get_current_user_from_request_object(request) response = templates.TemplateResponse( request=request, - name="error.html", + name=error_template, context={ "request": request, "error_message": message, @@ -93,92 +86,75 @@ async def render_error_page( }, status_code=status_code, ) - return add_request_id_header(request, response) + return _add_request_id_header(request, response) -async def global_exception_handler(request: Request, exc: Exception): - """ - Handle unexpected exceptions globally. - in the ideal world this is never be triggered +async def divbase_api_exception_handler(request: Request, exc: DivBaseAPIException) -> Response: """ - logger.error(f"Unexpected Error occurred for: {request.method} {request.url.path}: {exc}", exc_info=True) - - if is_api_request(request): - return add_request_id_header( - request, - JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "detail": "An unexpected error occurred. Please try again later.", - "type": "server_error", - }, - ), - ) - else: - return await render_error_page( - request, - "An unexpected error occurred. Please try again later.", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - + The default exception handler for any DivBaseAPIException subclass. + Each DivBaseAPIException child class declares how it should be handled using class attributes. -async def authentication_error_handler(request: Request, exc: AuthenticationError): - logger.info(f"Authentication failed for {request.method} {request.url.path}: {exc.message}", exc_info=False) + Certain exceptions are handled individually as they have special requirements (e.g. UserRegistrationError). + """ + _log_exception( + request=request, exc=exc, log_level=exc.log_level, prefix=exc.error_type, include_exc_info=exc.include_exc_info + ) - if is_api_request(request): + if _is_api_request(request): return JSONResponse( status_code=exc.status_code, - content={"detail": exc.message, "type": "authentication_error"}, + content={"detail": exc.message, "type": exc.error_type}, headers=exc.headers, ) - else: - return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) + if exc.frontend_redirect_url: + return RedirectResponse(url=exc.frontend_redirect_url, status_code=status.HTTP_302_FOUND) -async def authorization_error_handler(request: Request, exc: AuthorizationError): - logger.info(f"Authorization failed for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "authorization_error"}, - headers=exc.headers, - ) - else: - return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) - + return await _render_error_page( + request=request, + message=exc.frontend_message or exc.message, + status_code=exc.status_code, + error_template="404.html" if exc.status_code == status.HTTP_404_NOT_FOUND else "error.html", + ) -async def pat_limit_exceeded_error_handler(request: Request, exc: PATLimitExceededError): - logger.info(f"PAT limit exceeded for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "pat_limit_exceeded_error"}, - headers=exc.headers, - ) - else: - # The expected place for this error to occur is covered in the route itself, this is a fallback - return await render_error_page(request, exc.message, status_code=exc.status_code) +async def unexpected_exception_handler(request: Request, exc: Exception): + """ + Handle unexpected exceptions. In the ideal world this is never triggered. + """ + prefix = f"Unexpected error '{type(exc).__name__}' occurred." + _log_exception(request=request, exc=exc, log_level=logging.ERROR, prefix=prefix, include_exc_info=True) -async def pat_duplicate_name_error_handler(request: Request, exc: PATDuplicateNameError): - logger.info(f"PAT duplicate name for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "pat_duplicate_name_error"}, - headers=exc.headers, + user_message = "An unexpected error occurred. Please try again later." + if _is_api_request(request): + return _add_request_id_header( + request, + JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": user_message, "type": "InternalServerError"}, + ), ) else: - # The expected place for this error to occur is covered in the route itself, this is a fallback - return await render_error_page(request, exc.message, status_code=exc.status_code) + return await _render_error_page( + request=request, message=user_message, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) async def user_registration_error_handler(request: Request, exc: UserRegistrationError): - logger.warning(f"User registration failed for {request.method} {request.url.path}: {exc.message}", exc_info=True) + """ + UserRegistrationError has its own dedicated handler. As it needs to + (1) show different user-facing message vs internal logging message and + (2) can render 2 diff templates, depending on whether the error happened in the admin panel or public registration page. + """ + prefix = f"{exc.error_type} occurred." + _log_exception( + request=request, exc=exc, log_level=exc.log_level, prefix=prefix, include_exc_info=exc.include_exc_info + ) - if is_api_request(request): + if _is_api_request(request): return JSONResponse( status_code=exc.status_code, - content={"detail": exc.user_message, "type": "user_registration_error"}, + content={"detail": exc.user_message, "type": exc.error_type}, ) if request.url.path.startswith("/admin/"): @@ -205,167 +181,31 @@ async def user_registration_error_handler(request: Request, exc: UserRegistratio ) -async def project_not_found_error_handler(request: Request, exc: ProjectNotFoundError): - logger.debug(f"Project not found for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_not_found_error"}, - headers=exc.headers, - ) - else: - return await render_error_page( - request, message="Project not found or you don't have access.", status_code=exc.status_code - ) - - -async def project_member_not_found_error_handler(request: Request, exc: ProjectMemberNotFoundError): - logger.warning(f"Project member not found for {request.method} {request.url.path}: {exc.message}", exc_info=True) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_member_not_found_error"}, - headers=exc.headers, - ) - else: - return RedirectResponse(url="/projects", status_code=status.HTTP_302_FOUND) - - -async def project_creation_error_handler(request: Request, exc: ProjectCreationError): - logger.warning(f"Project creation failed for {request.method} {request.url.path}: {exc.message}", exc_info=True) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_creation_error"}, - headers=exc.headers, - ) - else: - return RedirectResponse(url="/projects", status_code=status.HTTP_302_FOUND) - - -async def project_member_already_exists_error_handler(request: Request, exc: ProjectMemberAlreadyExistsError): - logger.debug( - f"Project member already exists error for {request.method} {request.url.path}: {exc.message}", exc_info=False - ) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_member_already_exists_error"}, - headers=exc.headers, - ) - else: - # The expected place for this error to occur is covered in the route itself, this is a fallback - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def user_not_found_error_handler(request: Request, exc: UserNotFoundError): - logger.debug(f"User not found error for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "user_not_found_error"}, - headers=exc.headers, - ) - else: - # The expected place for this error to occur is covered in the route itself, this is a fallback - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def too_many_objects_in_request_error_handler(request: Request, exc: TooManyObjectsInRequestError): - logger.warning(f"Too many objects in request for {request.method} {request.url.path}: {exc.message}", exc_info=True) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "too_many_objects_in_request_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def project_version_creation_error_handler(request: Request, exc: ProjectVersionCreationError): - logger.warning( - f"Project version creation error for {request.method} {request.url.path}: {exc.message}", exc_info=True - ) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_version_creation_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def project_version_already_exists_error_handler(request: Request, exc: ProjectVersionAlreadyExistsError): - logger.info( - f"Project version already exists error for {request.method} {request.url.path}: {exc.message}", exc_info=True - ) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_version_already_exists_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def project_version_not_found_error_handler(request: Request, exc: ProjectVersionNotFoundError): - logger.info( - f"Project version not found error for {request.method} {request.url.path}: {exc.message}", exc_info=True - ) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_version_not_found_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def project_version_soft_deleted_error_handler(request: Request, exc: ProjectVersionSoftDeletedError): - logger.info( - f"Project version soft deleted error for {request.method} {request.url.path}: {exc.message}", exc_info=True - ) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "project_version_soft_deleted_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - async def generic_http_exception_handler(request: Request, exc: HTTPException): """ - Generic handler for HTTP exceptions not caught by the specific handlers above. - - 401 and 403s are handled by custom exceptions above. + Generic handler for HTTP exceptions. 401 and 403s are handled by custom exceptions which inherit from DivBaseAPIException. - Note that we have to import starlette.exceptions.HTTPException here not FastAPI's HTTPException. + NOTE: that we have to import starlette.exceptions.HTTPException here not FastAPI's HTTPException. """ - if is_api_request(request): - if exc.status_code != 404: - logger.error( - f"HTTP {exc.status_code} error for {request.method} {request.url.path}: {exc.detail}", exc_info=True - ) + error_type = type(exc).__name__ + _log_exception( + request=request, + exc=exc, + log_level=logging.ERROR if exc.status_code != 404 else logging.INFO, + prefix=error_type, + include_exc_info=exc.status_code != 404, + ) + + if _is_api_request(request): return JSONResponse( status_code=exc.status_code, - content={"detail": exc.detail, "type": "http_error"}, + content={"detail": exc.detail, "type": error_type}, headers=exc.headers, ) # (Frontend request) if exc.status_code == 404: - current_user = await get_current_user_from_request_object(request) + current_user = await _get_current_user_from_request_object(request) return templates.TemplateResponse( request=request, name="404.html", @@ -373,118 +213,37 @@ async def generic_http_exception_handler(request: Request, exc: HTTPException): status_code=status.HTTP_404_NOT_FOUND, ) else: - logger.error( - f"HTTP {exc.status_code} error for {request.method} {request.url.path}: {exc.detail}", exc_info=True - ) - return await render_error_page( - request, "An unexpected error occurred. Please try again later.", exc.status_code + return await _render_error_page( + request=request, + message="An unexpected error occurred. Please try again later.", + status_code=exc.status_code, ) async def request_validation_error_handler(request: Request, exc: RequestValidationError): - """When a request contains invalid data, FastAPI internally raises a RequestValidationError""" - logger.error(f"Request validation error for {request.method} {request.url.path}: {exc.errors()}", exc_info=True) + """ + When a request contains invalid data, FastAPI internally raises a RequestValidationError - # pydantic return a list of validation errors, so extract all the messages to give to user + NOTE: Pydantic returns a list of validation errors, so this handler works differently than others. + """ + error_type = "RequestValidationError" + logger.info(f"{error_type} for {request.method} {request.url.path}: {exc.errors()}", exc_info=False) + + # gives the user back multiple error messages if there is more than 1 validation error errors = [error["msg"] for error in exc.errors() if "msg" in error] - if is_api_request(request): + if _is_api_request(request): return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - content={"detail": errors, "type": "request_validation_error"}, + content={"detail": errors, "type": error_type}, ) else: - return await render_error_page( + return await _render_error_page( request=request, message=f"Badly formatted request. Please check your input and try again. Details: {errors}", status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, ) -async def vcf_dimensions_entry_missing_error_handler(request: Request, exc: VCFDimensionsEntryMissingError): - logger.info(f"VCF dimensions entry missing for {request.method} {request.url.path}: {exc.message}", exc_info=False) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "vcf_dimensions_entry_missing_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def dimensions_update_already_in_process_error_handler( - request: Request, exc: DimensionsUpdateAlreadyInProcessError -): - logger.info( - f"Dimensions update already in process error for {request.method} {request.url.path}: {exc.message}", - exc_info=False, - ) - - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "dimensions_update_task_already_in_process_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def task_not_found_in_backend_error_handler(request: Request, exc: TaskNotFoundInBackendError): - logger.warning( - f"Task ID not found in results backend for {request.method} {request.url.path}: {exc.message}", exc_info=True - ) - if is_api_request(request): - return JSONResponse( - status_code=status.HTTP_410_GONE, - content={"detail": exc.message, "type": "task_not_found_in_backend_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=status.HTTP_410_GONE) - - -async def downloaded_file_checksum_mismatch_error_handler(request: Request, exc: DownloadedFileChecksumMismatchError): - logger.warning( - f"Downloaded file checksum mismatch error for {request.method} {request.url.path}: {exc.message}", exc_info=True - ) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "downloaded_file_checksum_mismatch_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - -async def object_does_not_exist_error_handler(request: Request, exc: ObjectDoesNotExistError): - logger.debug(f"Object does not exist error for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "object_does_not_exist_error"}, - headers=exc.headers, - ) - else: - return await render_error_page( - request, "The requested file does not exist or you don't have access.", status_code=exc.status_code - ) - - -async def queue_closed_error_handler(request: Request, exc: QueueClosedError): - logger.debug(f"Queue closed error for {request.method} {request.url.path}: {exc.message}", exc_info=False) - if is_api_request(request): - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.message, "type": "queue_closed_error"}, - headers=exc.headers, - ) - else: - return await render_error_page(request, exc.message, status_code=exc.status_code) - - def register_exception_handlers(app: FastAPI) -> None: """ Register all exception handlers with FastAPI app. @@ -492,33 +251,13 @@ def register_exception_handlers(app: FastAPI) -> None: Type errors ignored (https://github.com/fastapi/fastapi/discussions/11741) NOTE: error handlers need to be defined above this function, otherwise they will not work. + NOTE: UserRegistrationError has its own dedicated handler (custom templates/messages) rather than + going through divbase_api_exception_handler. (FastAPI chooses whichever registered exception handler is most specific.) """ - app.add_exception_handler(AuthenticationError, authentication_error_handler) # type: ignore - app.add_exception_handler(AuthorizationError, authorization_error_handler) # type: ignore - app.add_exception_handler(PATLimitExceededError, pat_limit_exceeded_error_handler) # type: ignore - app.add_exception_handler(PATDuplicateNameError, pat_duplicate_name_error_handler) # type: ignore + app.add_exception_handler(DivBaseAPIException, divbase_api_exception_handler) # type: ignore app.add_exception_handler(UserRegistrationError, user_registration_error_handler) # type: ignore - app.add_exception_handler(ProjectNotFoundError, project_not_found_error_handler) # type: ignore - app.add_exception_handler(ProjectMemberNotFoundError, project_member_not_found_error_handler) # type: ignore - app.add_exception_handler(ProjectCreationError, project_creation_error_handler) # type: ignore - app.add_exception_handler(ProjectMemberAlreadyExistsError, project_member_already_exists_error_handler) # type: ignore - app.add_exception_handler(UserNotFoundError, user_not_found_error_handler) # type: ignore - app.add_exception_handler(ProjectVersionAlreadyExistsError, project_version_already_exists_error_handler) # type: ignore - app.add_exception_handler(TooManyObjectsInRequestError, too_many_objects_in_request_error_handler) # type: ignore - app.add_exception_handler(ProjectVersionCreationError, project_version_creation_error_handler) # type: ignore - app.add_exception_handler(ProjectVersionNotFoundError, project_version_not_found_error_handler) # type: ignore - app.add_exception_handler(ProjectVersionSoftDeletedError, project_version_soft_deleted_error_handler) # type: ignore app.add_exception_handler(RequestValidationError, request_validation_error_handler) # type: ignore - app.add_exception_handler(VCFDimensionsEntryMissingError, vcf_dimensions_entry_missing_error_handler) # type: ignore - app.add_exception_handler( - DimensionsUpdateAlreadyInProcessError, - dimensions_update_already_in_process_error_handler, # type: ignore - ) - app.add_exception_handler(TaskNotFoundInBackendError, task_not_found_in_backend_error_handler) # type: ignore - app.add_exception_handler(DownloadedFileChecksumMismatchError, downloaded_file_checksum_mismatch_error_handler) # type: ignore - app.add_exception_handler(ObjectDoesNotExistError, object_does_not_exist_error_handler) # type: ignore - app.add_exception_handler(QueueClosedError, queue_closed_error_handler) # type: ignore # These cover more generic/unexpected HTTP errors - the exceptions above take precedence app.add_exception_handler(HTTPException, generic_http_exception_handler) # type: ignore - app.add_exception_handler(Exception, global_exception_handler) # type: ignore + app.add_exception_handler(Exception, unexpected_exception_handler) # type: ignore diff --git a/packages/divbase-api/src/divbase_api/exceptions.py b/packages/divbase-api/src/divbase_api/exceptions.py index 2b7c6eb9..796251d7 100644 --- a/packages/divbase-api/src/divbase_api/exceptions.py +++ b/packages/divbase-api/src/divbase_api/exceptions.py @@ -1,16 +1,32 @@ """ DivBase API custom exceptions. -All exceptions in this module should inherit from DivBaseAPIException, so we can catch for that externally. +All exceptions in this module should inherit from DivBaseAPIException. """ +import logging from pathlib import Path from fastapi import status class DivBaseAPIException(Exception): - """Base exception for all DivBase errors.""" + """ + Base exception for all DivBaseAPI errors + + Each subclass declares how `exception_handlers.divbase_api_exception_handler` should treat it: + - log_level: What log level to use + - include_exc_info: whether to include the exception traceback in the logs. + - frontend_redirect_url: if set, frontend (non-API) requests are redirected here instead of shown the + generic error page. + - frontend_message: if set, frontend requests see this instead of `message` (API responses always see + `message`) - used when the raw message is too internal/technical for a human-facing page. + """ + + log_level: int = logging.INFO + include_exc_info: bool = False + frontend_redirect_url: str | None = None + frontend_message: str | None = None def __init__(self, message: str, status_code: int, headers: dict[str, str] | None = None): self.message = message @@ -18,14 +34,27 @@ def __init__(self, message: str, status_code: int, headers: dict[str, str] | Non self.headers = headers or {} super().__init__(self.message) + @property + def error_type(self) -> str: + """Returned in API responses as the (error) type.""" + return type(self).__name__ + class AuthenticationError(DivBaseAPIException): + log_level = logging.INFO + include_exc_info = False + frontend_redirect_url = "/login" + def __init__(self, message: str = "Authentication required"): default_headers = {"WWW-Authenticate": "Bearer"} super().__init__(message=message, status_code=status.HTTP_401_UNAUTHORIZED, headers=default_headers) class AuthorizationError(DivBaseAPIException): + log_level = logging.INFO + include_exc_info = False + frontend_redirect_url = "/login" + def __init__(self, message: str = "Authorization required"): default_headers = {"WWW-Authenticate": "Bearer"} super().__init__(message=message, status_code=status.HTTP_403_FORBIDDEN, headers=default_headers) @@ -34,6 +63,9 @@ def __init__(self, message: str = "Authorization required"): class UserRegistrationError(DivBaseAPIException): """Exception for failed registration of new user or update of existing user.""" + log_level = logging.INFO + include_exc_info = False + def __init__( self, internal_logging_message: str, # for logs, can be more detailed @@ -44,40 +76,53 @@ def __init__( class ProjectNotFoundError(DivBaseAPIException): + log_level = logging.DEBUG + include_exc_info = False + frontend_message = "Project not found or you don't have access." + def __init__(self, message: str = "Project not found or you don't have access"): super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND) class ProjectMemberNotFoundError(DivBaseAPIException): + log_level = logging.INFO + include_exc_info = False + frontend_redirect_url = "/projects" + def __init__(self, message: str = "Project member not found"): super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND) class UserNotFoundError(DivBaseAPIException): + log_level = logging.DEBUG + include_exc_info = False + def __init__(self, message: str = "User not found"): super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND) class ProjectMemberAlreadyExistsError(DivBaseAPIException): + log_level = logging.DEBUG + include_exc_info = False + def __init__(self, message: str = "User is already a member of this project"): super().__init__(message=message, status_code=status.HTTP_409_CONFLICT) class ProjectCreationError(DivBaseAPIException): - def __init__(self, message: str = "Project creation failed"): - super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) - + log_level = logging.WARNING + include_exc_info = True -class TooManyObjectsInRequestError(DivBaseAPIException): - """Raise when e.g. too many files are requested to be downloaded in a single request.""" - - def __init__(self, message: str = "Too many objects to work on in a single request"): + def __init__(self, message: str = "Project creation failed"): super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) class ProjectVersionCreationError(DivBaseAPIException): """Raised when there is an error creating a new project version.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, message: str = "Failed to create a new project version"): super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) @@ -85,8 +130,11 @@ def __init__(self, message: str = "Failed to create a new project version"): class ProjectVersionAlreadyExistsError(DivBaseAPIException): """Raised when attempting to create a project version that already exists.""" + log_level = logging.INFO + include_exc_info = False + def __init__( - self, message: str = "A project version with the specified name already exists, please choose a different name." + self, message: str = "A project version with this name already exists, please choose a different name." ): super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) @@ -94,6 +142,9 @@ def __init__( class ProjectVersionNotFoundError(DivBaseAPIException): """Raised when a project version is not found.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, message: str = "Could not find the specified project version"): super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND) @@ -101,6 +152,9 @@ def __init__(self, message: str = "Could not find the specified project version" class ProjectVersionSoftDeletedError(DivBaseAPIException): """Raised when a user tries to modify a project version that is soft-deleted.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, message: str): super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) @@ -108,6 +162,9 @@ def __init__(self, message: str): class VCFDimensionsEntryMissingError(DivBaseAPIException): """Raised when there are no entries in the VCF dimensions db table.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, project_name: str | None = None): if project_name: message = ( @@ -127,6 +184,9 @@ def __init__(self, project_name: str | None = None): class DimensionsUpdateAlreadyInProcessError(DivBaseAPIException): """Raised when a user tries to queue a new VCF dimensions update task for a project that already has a queued or running update task.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, project_name: str, ongoing_task_id: int): message = ( f"A VCF dimensions update task (with id: {ongoing_task_id}) is already in process for the project '{project_name}'. \n" @@ -136,19 +196,12 @@ def __init__(self, project_name: str, ongoing_task_id: int): super().__init__(message=message, status_code=status.HTTP_409_CONFLICT) -class TaskNotFoundInBackendError(DivBaseAPIException): - """Raised when a task ID exists in the task_history table in the database but not in the results backend.""" - - def __init__( - self, - message: str = "Task ID not found in results backend. It may have been purged during cleanup of old task records.", - ): - super().__init__(message=message, status_code=status.HTTP_410_GONE) - - class DownloadedFileChecksumMismatchError(DivBaseAPIException): """Raised when a worker downloads a file but the calculated checksum does not match the checksum provided by s3.""" + log_level = logging.ERROR + include_exc_info = True + def __init__(self, file_path: Path, calculated_checksum: str, expected_checksum: str): message = ( f"Downloaded file checksum mismatch for file '{file_path.name}'. " @@ -158,17 +211,33 @@ def __init__(self, file_path: Path, calculated_checksum: str, expected_checksum: super().__init__(message=message, status_code=status.HTTP_409_CONFLICT) +class TooManyObjectsInRequestError(DivBaseAPIException): + """Raise when e.g. too many files are requested to be downloaded in a single request.""" + + log_level = logging.WARNING + include_exc_info = False + + def __init__(self, message: str = "Too many objects to work on in a single request"): + super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) + + class ObjectDoesNotExistError(DivBaseAPIException): """Raised when an S3 object/key does not exist in the project's bucket.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, key: str, bucket_name: str): - message = f"The file/object '{key}' does not exist in the project '{bucket_name}'. " + message = f"The file/object '{key}' does not exist in the project's store: '{bucket_name}'. " super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND) class TSVFileNotFoundInProjectError(DivBaseAPIException): """Raised when a TSV doesn't exist in project storage.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, filename: str | None = None, project_name: str | None = None): self.filename = filename self.project_name = project_name @@ -189,6 +258,9 @@ def __init__(self, filename: str | None = None, project_name: str | None = None) class QueueClosedError(DivBaseAPIException): """Raised when the queue is closed for new tasks (e.g. before a maintenance window).""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, message: str): super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST) @@ -196,6 +268,9 @@ def __init__(self, message: str): class PATLimitExceededError(DivBaseAPIException): """Raised when a user has reached the maximum number of active personal access tokens.""" + log_level = logging.INFO + include_exc_info = False + def __init__( self, message: str = "You have reached the maximum of 5 active tokens. Please revoke one before creating a new one.", @@ -206,5 +281,8 @@ def __init__( class PATDuplicateNameError(DivBaseAPIException): """Raised when a user already has an active (not soft-deleted) personal access token with the same name.""" + log_level = logging.INFO + include_exc_info = False + def __init__(self, message: str = "You already have an active token with this name."): super().__init__(message=message, status_code=status.HTTP_409_CONFLICT) diff --git a/packages/divbase-api/src/divbase_api/middleware.py b/packages/divbase-api/src/divbase_api/middleware.py index 0d315596..b69c6f3e 100644 --- a/packages/divbase-api/src/divbase_api/middleware.py +++ b/packages/divbase-api/src/divbase_api/middleware.py @@ -13,7 +13,7 @@ from urllib.parse import urlparse import structlog -from fastapi import FastAPI +from fastapi import FastAPI, status from fastapi.middleware.gzip import GZipMiddleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.middleware.trustedhost import TrustedHostMiddleware @@ -94,8 +94,8 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - "If you're not sure how to do that, you can find instructions on how to upgrade here: " f"{api_settings.general.mkdocs_site_url}/user-guides/installation" ) - body = {"detail": message, "type": "cli_version_outdated_error"} - return JSONResponse(content=body, status_code=400) + body = {"detail": message, "type": "CLIVersionOutdatedError"} + return JSONResponse(content=body, status_code=status.HTTP_400_BAD_REQUEST) response = await call_next(request) return response diff --git a/packages/divbase-api/src/divbase_api/templates/404.html b/packages/divbase-api/src/divbase_api/templates/404.html index 47f4ae7d..8a530d2c 100644 --- a/packages/divbase-api/src/divbase_api/templates/404.html +++ b/packages/divbase-api/src/divbase_api/templates/404.html @@ -15,7 +15,7 @@
- Sorry, the page you are looking for doesn't exist or has been moved. + {{ error_message | default("Sorry, the page you are looking for doesn't exist or has been moved.") }}