diff --git a/services/api-server/src/simcore_service_api_server/api/errors/__init__.py b/services/api-server/src/simcore_service_api_server/api/errors/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py b/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py deleted file mode 100644 index 6b0a4213541..00000000000 --- a/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging - -from fastapi import Request, status -from starlette.responses import JSONResponse - -_logger = logging.getLogger(__name__) - - -class CustomBaseError(Exception): - pass - - -class InsufficientCredits(CustomBaseError): - pass - - -class MissingWallet(CustomBaseError): - pass - - -class ApplicationSetupError(CustomBaseError): - pass - - -async def custom_error_handler(_: Request, exc: CustomBaseError): - if isinstance(exc, InsufficientCredits): - return JSONResponse( - status_code=status.HTTP_402_PAYMENT_REQUIRED, content=f"{exc}" - ) - if isinstance(exc, MissingWallet): - return JSONResponse( - status_code=status.HTTP_424_FAILED_DEPENDENCY, content=f"{exc}" - ) diff --git a/services/api-server/src/simcore_service_api_server/api/errors/http_error.py b/services/api-server/src/simcore_service_api_server/api/errors/http_error.py deleted file mode 100644 index 977b0e161a4..00000000000 --- a/services/api-server/src/simcore_service_api_server/api/errors/http_error.py +++ /dev/null @@ -1,47 +0,0 @@ -from collections.abc import Callable - -from fastapi import HTTPException -from fastapi.encoders import jsonable_encoder -from servicelib.error_codes import create_error_code -from starlette.requests import Request -from starlette.responses import JSONResponse - -from ...models.schemas.errors import ErrorGet - - -def create_error_json_response(*errors, status_code: int) -> JSONResponse: - # NOTE: do not forget to add in the decorator `responses={ ???: {"model": ErrorGet} }` - # SEE https://fastapi.tiangolo.com/advanced/additional-responses/#additional-response-with-model - error_model = ErrorGet(errors=list(errors)) - return JSONResponse(content=jsonable_encoder(error_model), status_code=status_code) - - -async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse: - return create_error_json_response(exc.detail, status_code=exc.status_code) - - -def make_http_error_handler_for_exception( - exception_cls: type[BaseException], - status_code: int, - *, - detail_message: str, - add_exception_to_message: bool = False, - add_oec_to_message: bool = False, -) -> Callable: - """ - Produces a handler for BaseException-type exceptions which converts them - into an error JSON response with a given status code - - SEE https://docs.python.org/3/library/exceptions.html#concrete-exceptions - """ - - async def _http_error_handler(_: Request, error: BaseException) -> JSONResponse: - assert isinstance(error, exception_cls) # nosec - details = detail_message - if add_exception_to_message: - details += f"\n{error}" - if add_oec_to_message: - details += f"\n[OEC: {create_error_code(error)}]" - return create_error_json_response(details, status_code=status_code) - - return _http_error_handler diff --git a/services/api-server/src/simcore_service_api_server/api/routes/files.py b/services/api-server/src/simcore_service_api_server/api/routes/files.py index c0b3a851995..eccdda0cba9 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/files.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/files.py @@ -30,6 +30,7 @@ from starlette.responses import RedirectResponse from ..._meta import API_VTAG +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.pagination import Page, PaginationParams from ...models.schemas.errors import ErrorGet from ...models.schemas.files import ( @@ -39,7 +40,6 @@ FileUploadData, UploadLinks, ) -from ...services.service_exception_handling import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...services.storage import StorageApi, StorageFileMetaData, to_file_api_model from ..dependencies.authentication import get_current_user_id from ..dependencies.services import get_api_client diff --git a/services/api-server/src/simcore_service_api_server/api/routes/health.py b/services/api-server/src/simcore_service_api_server/api/routes/health.py index cc0fa6407d7..1537d1a5d65 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/health.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/health.py @@ -3,9 +3,10 @@ from collections.abc import Callable from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import PlainTextResponse from models_library.app_diagnostics import AppStatusCheck +from servicelib.aiohttp import status from ..._meta import API_VERSION, PROJECT_NAME from ...core.health_checker import ApiServerHealthChecker, get_health_checker @@ -19,16 +20,15 @@ router = APIRouter() -class HealtchCheckException(RuntimeError): - """Failed a health check""" - - @router.get("/", include_in_schema=False, response_class=PlainTextResponse) async def check_service_health( health_checker: Annotated[ApiServerHealthChecker, Depends(get_health_checker)] ): if not health_checker.healthy: - raise HealtchCheckException() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="unhealthy" + ) + return f"{__name__}@{datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}" diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py index 575f4ff7fc7..219184352ca 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py @@ -9,12 +9,12 @@ from pydantic import ValidationError from pydantic.errors import PydanticValueError +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.basic_types import VersionStr from ...models.pagination import OnePage, Page, PaginationParams from ...models.schemas.errors import ErrorGet from ...models.schemas.solvers import Solver, SolverKeyId, SolverPort from ...services.catalog import CatalogApi -from ...services.service_exception_handling import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ..dependencies.application import get_reverse_url_mapper from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.services import get_api_client diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py index d97974099cc..b5e5b81faf8 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py @@ -5,11 +5,11 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, Request, status -from fastapi.exceptions import HTTPException from models_library.api_schemas_webserver.projects import ProjectCreateNew, ProjectGet from models_library.clusters import ClusterID from pydantic.types import PositiveInt +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.basic_types import VersionStr from ...models.schemas.errors import ErrorGet from ...models.schemas.jobs import ( @@ -23,7 +23,6 @@ from ...models.schemas.solvers import Solver, SolverKeyId from ...services.catalog import CatalogApi from ...services.director_v2 import DirectorV2Api -from ...services.service_exception_handling import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...services.solver_job_models_converters import ( create_job_from_project, create_jobstatus_from_task, @@ -33,7 +32,6 @@ from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.services import get_api_client from ..dependencies.webserver import AuthSession, get_webserver_session -from ..errors.http_error import create_error_json_response from ._common import API_SERVER_DEV_FEATURES_ENABLED from ._jobs import start_project, stop_project @@ -247,24 +245,16 @@ async def replace_job_custom_metadata( job_name = _compose_job_resource_name(solver_key, version, job_id) _logger.debug("Custom metadata for '%s'", job_name) - try: - project_metadata = await webserver_api.update_project_metadata( - project_id=job_id, metadata=update.metadata - ) - return JobMetadata( + project_metadata = await webserver_api.update_project_metadata( + project_id=job_id, metadata=update.metadata + ) + return JobMetadata( + job_id=job_id, + metadata=project_metadata.custom, + url=url_for( + "replace_job_custom_metadata", + solver_key=solver_key, + version=version, job_id=job_id, - metadata=project_metadata.custom, - url=url_for( - "replace_job_custom_metadata", - solver_key=solver_key, - version=version, - job_id=job_id, - ), - ) - - except HTTPException as err: - if err.status_code == status.HTTP_404_NOT_FOUND: - return create_error_json_response( - f"Cannot find job={job_name} ", - status_code=status.HTTP_404_NOT_FOUND, - ) + ), + ) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py index de201e2d0da..54a1673476d 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py @@ -1,5 +1,4 @@ # pylint: disable=too-many-arguments -# pylint: disable=W0613 import logging from collections import deque @@ -16,12 +15,15 @@ from models_library.api_schemas_webserver.wallets import WalletGetWithAvailableCredits from models_library.projects_nodes_io import BaseFileLink from models_library.users import UserID +from models_library.wallets import ZERO_CREDITS from pydantic import NonNegativeInt from pydantic.types import PositiveInt from servicelib.fastapi.requests_decorators import cancel_on_disconnect from servicelib.logging_utils import log_context from starlette.background import BackgroundTask +from ...exceptions.custom_errors import InsufficientCreditsError, MissingWalletError +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.basic_types import LogStreamingResponse, VersionStr from ...models.pagination import Page, PaginationParams from ...models.schemas.errors import ErrorGet @@ -31,7 +33,6 @@ from ...services.catalog import CatalogApi from ...services.director_v2 import DirectorV2Api, DownloadLink, NodeName from ...services.log_streaming import LogDistributor, LogStreamer -from ...services.service_exception_handling import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...services.solver_job_models_converters import create_job_from_project from ...services.solver_job_outputs import ResultsTypes, get_solver_output_results from ...services.storage import StorageApi, to_file_api_model @@ -41,8 +42,6 @@ from ..dependencies.rabbitmq import get_log_check_timeout, get_log_distributor from ..dependencies.services import get_api_client from ..dependencies.webserver import AuthSession, get_webserver_session -from ..errors.custom_errors import InsufficientCredits, MissingWallet -from ..errors.http_error import create_error_json_response from ._common import API_SERVER_DEV_FEATURES_ENABLED from ._jobs import raise_if_job_not_associated_with_solver from .solvers_jobs import ( @@ -232,12 +231,13 @@ async def get_job_outputs( if product_price is not None: wallet = await webserver_api.get_project_wallet(project_id=project.uuid) if wallet is None: - msg = f"Job {project.uuid} does not have an associated wallet." - raise MissingWallet(msg) + raise MissingWalletError(job_id=project.uuid) wallet_with_credits = await webserver_api.get_wallet(wallet_id=wallet.wallet_id) - if wallet_with_credits.available_credits < 0.0: - msg = f"Wallet '{wallet_with_credits.name}' does not have any credits. Please add some before requesting solver ouputs" - raise InsufficientCredits(msg) + if wallet_with_credits.available_credits <= ZERO_CREDITS: + raise InsufficientCreditsError( + wallet_name=wallet_with_credits.name, + wallet_credit_amount=wallet_with_credits.available_credits, + ) outputs: dict[str, ResultsTypes] = await get_solver_output_results( user_id=user_id, @@ -345,25 +345,17 @@ async def get_job_custom_metadata( job_name = _compose_job_resource_name(solver_key, version, job_id) _logger.debug("Custom metadata for '%s'", job_name) - try: - project_metadata = await webserver_api.get_project_metadata(project_id=job_id) - return JobMetadata( + project_metadata = await webserver_api.get_project_metadata(project_id=job_id) + return JobMetadata( + job_id=job_id, + metadata=project_metadata.custom, + url=url_for( + "get_job_custom_metadata", + solver_key=solver_key, + version=version, job_id=job_id, - metadata=project_metadata.custom, - url=url_for( - "get_job_custom_metadata", - solver_key=solver_key, - version=version, - job_id=job_id, - ), - ) - - except HTTPException as err: - if err.status_code == status.HTTP_404_NOT_FOUND: - return create_error_json_response( - f"Cannot find job={job_name} ", - status_code=status.HTTP_404_NOT_FOUND, - ) + ), + ) @router.get( @@ -428,6 +420,8 @@ async def get_log_stream( user_id: Annotated[UserID, Depends(get_current_user_id)], log_check_timeout: Annotated[NonNegativeInt, Depends(get_log_check_timeout)], ): + assert request # nosec + job_name = _compose_job_resource_name(solver_key, version, job_id) with log_context( _logger, logging.DEBUG, f"Streaming logs for {job_name=} and {user_id=}" diff --git a/services/api-server/src/simcore_service_api_server/api/routes/users.py b/services/api-server/src/simcore_service_api_server/api/routes/users.py index cbc5f661773..67d907b2492 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/users.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/users.py @@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, Security, status +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.schemas.errors import ErrorGet from ...models.schemas.profiles import Profile, ProfileUpdate -from ...services.service_exception_handling import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...services.webserver import AuthSession from ..dependencies.webserver import get_webserver_session diff --git a/services/api-server/src/simcore_service_api_server/api/routes/wallets.py b/services/api-server/src/simcore_service_api_server/api/routes/wallets.py index 6980843c48d..400c39569a9 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/wallets.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/wallets.py @@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.wallets import WalletGetWithAvailableCredits +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.schemas.errors import ErrorGet -from ...services.service_exception_handling import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ..dependencies.webserver import AuthSession, get_webserver_session from ._common import API_SERVER_DEV_FEATURES_ENABLED diff --git a/services/api-server/src/simcore_service_api_server/core/application.py b/services/api-server/src/simcore_service_api_server/core/application.py index 09259f0c88e..da20e874169 100644 --- a/services/api-server/src/simcore_service_api_server/core/application.py +++ b/services/api-server/src/simcore_service_api_server/core/application.py @@ -1,28 +1,16 @@ import logging from fastapi import FastAPI -from fastapi.exceptions import RequestValidationError from fastapi_pagination import add_pagination -from httpx import HTTPError as HttpxException from models_library.basic_types import BootModeEnum from servicelib.fastapi.profiler_middleware import ProfilerMiddleware from servicelib.logging_utils import config_all_loggers -from starlette import status -from starlette.exceptions import HTTPException +from .. import exceptions from .._meta import API_VERSION, API_VTAG -from ..api.errors.custom_errors import CustomBaseError, custom_error_handler -from ..api.errors.http_error import ( - http_error_handler, - make_http_error_handler_for_exception, -) -from ..api.errors.httpx_client_error import handle_httpx_client_exceptions -from ..api.errors.log_handling_error import log_handling_error_handler -from ..api.errors.validation_error import http422_error_handler from ..api.root import create_router from ..api.routes.health import router as health_router from ..services import catalog, director_v2, storage, webserver -from ..services.log_streaming import LogDistributionBaseException from ..services.rabbitmq import setup_rabbitmq from ._prometheus_instrumentation import setup_prometheus_instrumentation from .events import create_start_app_handler, create_stop_app_handler @@ -96,31 +84,10 @@ def init_app(settings: ApplicationSettings | None = None) -> FastAPI: app.add_event_handler("startup", create_start_app_handler(app)) app.add_event_handler("shutdown", create_stop_app_handler(app)) - app.add_exception_handler(HTTPException, http_error_handler) - app.add_exception_handler(HttpxException, handle_httpx_client_exceptions) - app.add_exception_handler(RequestValidationError, http422_error_handler) - app.add_exception_handler(LogDistributionBaseException, log_handling_error_handler) - app.add_exception_handler(CustomBaseError, custom_error_handler) - - # SEE https://docs.python.org/3/library/exceptions.html#exception-hierarchy - app.add_exception_handler( - NotImplementedError, - make_http_error_handler_for_exception( - NotImplementedError, - status.HTTP_501_NOT_IMPLEMENTED, - detail_message="Endpoint not implemented", - ), - ) - app.add_exception_handler( - Exception, - make_http_error_handler_for_exception( - Exception, - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail_message="Unexpected error", - add_exception_to_message=(settings.SC_BOOT_MODE == BootModeEnum.DEBUG), - add_oec_to_message=True, - ), + exceptions.setup_exception_handlers( + app, is_debug=settings.SC_BOOT_MODE == BootModeEnum.DEBUG ) + if settings.API_SERVER_PROFILING: app.add_middleware(ProfilerMiddleware) diff --git a/services/api-server/src/simcore_service_api_server/core/errors.py b/services/api-server/src/simcore_service_api_server/core/errors.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/services/api-server/src/simcore_service_api_server/db/errors.py b/services/api-server/src/simcore_service_api_server/db/errors.py deleted file mode 100644 index c4d518c55dd..00000000000 --- a/services/api-server/src/simcore_service_api_server/db/errors.py +++ /dev/null @@ -1,2 +0,0 @@ -class EntityDoesNotExistError(Exception): - """Raised when entity was not found in database.""" diff --git a/services/api-server/src/simcore_service_api_server/exceptions/__init__.py b/services/api-server/src/simcore_service_api_server/exceptions/__init__.py new file mode 100644 index 00000000000..b6036dda040 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/__init__.py @@ -0,0 +1,5 @@ +from . import handlers + +setup_exception_handlers = handlers.setup + +__all__: tuple[str, ...] = ("setup_exception_handlers",) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/_base.py b/services/api-server/src/simcore_service_api_server/exceptions/_base.py new file mode 100644 index 00000000000..2e0b2e13c4f --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/_base.py @@ -0,0 +1,8 @@ +from typing import Any + +from models_library.errors_classes import OsparcErrorMixin + + +class ApiServerBaseError(OsparcErrorMixin, Exception): + def __init__(self, **ctx: Any) -> None: + super().__init__(**ctx) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py new file mode 100644 index 00000000000..17f22d16fae --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py @@ -0,0 +1,18 @@ +from ._base import ApiServerBaseError + + +class CustomBaseError(ApiServerBaseError): + pass + + +class InsufficientCreditsError(CustomBaseError): + # NOTE: Same message as WalletNotEnoughCreditsError + msg_template = "Wallet '{wallet_name}' has {wallet_credit_amount} credits. Please add some before requesting solver ouputs" + + +class MissingWalletError(CustomBaseError): + msg_template = "Job {job_id} does not have an associated wallet." + + +class ApplicationSetupError(CustomBaseError): + pass diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py new file mode 100644 index 00000000000..0814906fb8f --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py @@ -0,0 +1,44 @@ +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from httpx import HTTPError as HttpxException +from starlette import status +from starlette.exceptions import HTTPException + +from ..custom_errors import CustomBaseError +from ..log_streaming_errors import LogStreamingBaseError +from ._custom_errors import custom_error_handler +from ._handlers_factory import make_handler_for_exception +from ._http_exceptions import http_exception_handler +from ._httpx_client_exceptions import handle_httpx_client_exceptions +from ._log_streaming_errors import log_handling_error_handler +from ._validation_errors import http422_error_handler + +MSG_INTERNAL_ERROR_USER_FRIENDLY_TEMPLATE = "Oops! Something went wrong, but we've noted it down and we'll sort it out ASAP. Thanks for your patience!" + + +def setup(app: FastAPI, *, is_debug: bool = False): + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(HttpxException, handle_httpx_client_exceptions) + app.add_exception_handler(RequestValidationError, http422_error_handler) + app.add_exception_handler(LogStreamingBaseError, log_handling_error_handler) + app.add_exception_handler(CustomBaseError, custom_error_handler) + + # SEE https://docs.python.org/3/library/exceptions.html#exception-hierarchy + app.add_exception_handler( + NotImplementedError, + make_handler_for_exception( + NotImplementedError, + status.HTTP_501_NOT_IMPLEMENTED, + error_message="This endpoint is still not implemented (under development)", + ), + ) + app.add_exception_handler( + Exception, + make_handler_for_exception( + Exception, + status.HTTP_500_INTERNAL_SERVER_ERROR, + error_message=MSG_INTERNAL_ERROR_USER_FRIENDLY_TEMPLATE, + add_exception_to_message=is_debug, + add_oec_to_message=True, + ), + ) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_custom_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_custom_errors.py new file mode 100644 index 00000000000..48ab4aeab11 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_custom_errors.py @@ -0,0 +1,25 @@ +from fastapi import Request, status + +from ..custom_errors import ( + CustomBaseError, + InsufficientCreditsError, + MissingWalletError, +) +from ._utils import create_error_json_response + + +async def custom_error_handler(request: Request, exc: CustomBaseError): + assert request # nosec + + error_msg = f"{exc}" + if isinstance(exc, InsufficientCreditsError): + return create_error_json_response( + error_msg, status_code=status.HTTP_402_PAYMENT_REQUIRED + ) + if isinstance(exc, MissingWalletError): + return create_error_json_response( + error_msg, status_code=status.HTTP_424_FAILED_DEPENDENCY + ) + + msg = f"Exception handler is not implement for {exc=} [{type(exc)}]" + raise NotImplementedError(msg) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_factory.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_factory.py new file mode 100644 index 00000000000..fb9ae8ddc10 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_factory.py @@ -0,0 +1,48 @@ +import logging + +from fastapi.requests import Request +from fastapi.responses import JSONResponse +from servicelib.error_codes import create_error_code + +from ._utils import ExceptionHandler, create_error_json_response + +_logger = logging.getLogger(__file__) + + +def make_handler_for_exception( + exception_cls: type[BaseException], + status_code: int, + *, + error_message: str, + add_exception_to_message: bool = False, + add_oec_to_message: bool = False, +) -> ExceptionHandler: + """ + Produces a handler for BaseException-type exceptions which converts them + into an error JSON response with a given status code + + SEE https://docs.python.org/3/library/exceptions.html#concrete-exceptions + """ + + async def _http_error_handler( + request: Request, exception: BaseException + ) -> JSONResponse: + assert request # nosec + assert isinstance(exception, exception_cls) # nosec + + msg = error_message + if add_exception_to_message: + msg += f" {exception}" + + if add_oec_to_message: + error_code = create_error_code(exception) + msg += f" [{error_code}]" + _logger.exception( + "Unexpected %s: %s", + exception.__class__.__name__, + msg, + extra={"error_code": error_code}, + ) + return create_error_json_response(msg, status_code=status_code) + + return _http_error_handler diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_http_exceptions.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_http_exceptions.py new file mode 100644 index 00000000000..f0a03e2605b --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_http_exceptions.py @@ -0,0 +1,10 @@ +from fastapi import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse + +from ._utils import create_error_json_response + + +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + assert request # nosec + return create_error_json_response(exc.detail, status_code=exc.status_code) diff --git a/services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_httpx_client_exceptions.py similarity index 81% rename from services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py rename to services/api-server/src/simcore_service_api_server/exceptions/handlers/_httpx_client_exceptions.py index e2abf7650c0..265e68c9115 100644 --- a/services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_httpx_client_exceptions.py @@ -7,18 +7,21 @@ from typing import Any from fastapi import Request, status -from fastapi.responses import JSONResponse from httpx import HTTPError, TimeoutException +from ._utils import create_error_json_response + _logger = logging.getLogger(__file__) -async def handle_httpx_client_exceptions(_: Request, exc: HTTPError): +async def handle_httpx_client_exceptions(request: Request, exc: HTTPError): """ Default httpx exception handler. See https://www.python-httpx.org/exceptions/ With this in place only HTTPStatusErrors need to be customized closer to the httpx client itself. """ + assert request # nosec + status_code: Any detail: str headers: dict[str, str] = {} @@ -31,6 +34,7 @@ async def handle_httpx_client_exceptions(_: Request, exc: HTTPError): if status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR: _logger.exception("%s. host=%s. %s", detail, exc.request.url.host, f"{exc}") - return JSONResponse( - status_code=status_code, content={"detail": detail}, headers=headers + + return create_error_json_response( + f"{detail}", status_code=status_code, headers=headers ) diff --git a/services/api-server/src/simcore_service_api_server/api/errors/log_handling_error.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_log_streaming_errors.py similarity index 53% rename from services/api-server/src/simcore_service_api_server/api/errors/log_handling_error.py rename to services/api-server/src/simcore_service_api_server/exceptions/handlers/_log_streaming_errors.py index bc75dd79d15..066ee5dd2d6 100644 --- a/services/api-server/src/simcore_service_api_server/api/errors/log_handling_error.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_log_streaming_errors.py @@ -2,22 +2,24 @@ from starlette.requests import Request from starlette.responses import JSONResponse -from ...services.log_streaming import ( - LogDistributionBaseException, - LogStreamerNotRegistered, - LogStreamerRegistionConflict, +from ..log_streaming_errors import ( + LogStreamerNotRegisteredError, + LogStreamerRegistionConflictError, + LogStreamingBaseError, ) -from .http_error import create_error_json_response +from ._utils import create_error_json_response async def log_handling_error_handler( - _: Request, exc: LogDistributionBaseException + request: Request, exc: LogStreamingBaseError ) -> JSONResponse: + assert request # nosec + msg = f"{exc}" status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR - if isinstance(exc, LogStreamerNotRegistered): + if isinstance(exc, LogStreamerNotRegisteredError): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - elif isinstance(exc, LogStreamerRegistionConflict): + elif isinstance(exc, LogStreamerRegistionConflictError): status_code = status.HTTP_409_CONFLICT return create_error_json_response(msg, status_code=status_code) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py new file mode 100644 index 00000000000..cb2c382bf3a --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py @@ -0,0 +1,26 @@ +from typing import Any, Awaitable, Callable, TypeAlias + +from fastapi.encoders import jsonable_encoder +from fastapi.requests import Request +from fastapi.responses import JSONResponse + +from ...models.schemas.errors import ErrorGet + +ExceptionHandler: TypeAlias = Callable[ + [Request, BaseException], Awaitable[JSONResponse] +] + + +def create_error_json_response( + *errors: Any, status_code: int, **kwargs +) -> JSONResponse: + """ + Converts errors to Error response model defined in the OAS + """ + + error_model = ErrorGet(errors=list(errors)) + return JSONResponse( + content=jsonable_encoder(error_model), + status_code=status_code, + **kwargs, + ) diff --git a/services/api-server/src/simcore_service_api_server/api/errors/validation_error.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_validation_errors.py similarity index 66% rename from services/api-server/src/simcore_service_api_server/api/errors/validation_error.py rename to services/api-server/src/simcore_service_api_server/exceptions/handlers/_validation_errors.py index c40ed5db47f..de7fd25fecf 100644 --- a/services/api-server/src/simcore_service_api_server/api/errors/validation_error.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_validation_errors.py @@ -1,20 +1,21 @@ -from fastapi import Request -from fastapi.encoders import jsonable_encoder +from fastapi import Request, status from fastapi.exceptions import RequestValidationError from fastapi.openapi.constants import REF_PREFIX from fastapi.openapi.utils import validation_error_response_definition from pydantic import ValidationError from starlette.responses import JSONResponse -from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY + +from ._utils import create_error_json_response async def http422_error_handler( - _: Request, + request: Request, exc: RequestValidationError | ValidationError, ) -> JSONResponse: - return JSONResponse( - content=jsonable_encoder({"errors": exc.errors()}), - status_code=HTTP_422_UNPROCESSABLE_ENTITY, + assert request # nosec + + return create_error_json_response( + *exc.errors(), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY ) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/log_streaming_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/log_streaming_errors.py new file mode 100644 index 00000000000..864b274c336 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/log_streaming_errors.py @@ -0,0 +1,13 @@ +from ._base import ApiServerBaseError + + +class LogStreamingBaseError(ApiServerBaseError): + pass + + +class LogStreamerNotRegisteredError(LogStreamingBaseError): + msg_template = "{msg}" + + +class LogStreamerRegistionConflictError(LogStreamingBaseError): + msg_template = "A stream was already connected to {job_id}. Only a single stream can be connected at the time" diff --git a/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py b/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py new file mode 100644 index 00000000000..f71e15cd6f4 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py @@ -0,0 +1,143 @@ +import logging +from collections.abc import Callable, Mapping +from contextlib import contextmanager +from functools import wraps +from typing import Any, NamedTuple, TypeAlias + +import httpx +from fastapi import HTTPException, status +from pydantic import ValidationError + +from ..models.schemas.errors import ErrorGet + +_logger = logging.getLogger(__name__) + +MSG_INTERNAL_ERROR_USER_FRIENDLY_TEMPLATE = "Oops! Something went wrong, but we've noted it down and we'll sort it out ASAP. Thanks for your patience! [{}]" + +DEFAULT_BACKEND_SERVICE_STATUS_CODES: dict[int | str, dict[str, Any]] = { + status.HTTP_429_TOO_MANY_REQUESTS: { + "description": "Too many requests", + "model": ErrorGet, + }, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "description": "Internal server error", + "model": ErrorGet, + }, + status.HTTP_502_BAD_GATEWAY: { + "description": "Unexpected error when communicating with backend service", + "model": ErrorGet, + }, + status.HTTP_503_SERVICE_UNAVAILABLE: { + "description": "Service unavailable", + "model": ErrorGet, + }, + status.HTTP_504_GATEWAY_TIMEOUT: { + "description": "Request to a backend service timed out.", + "model": ErrorGet, + }, +} + + +ServiceHTTPStatus: TypeAlias = int +ApiHTTPStatus: TypeAlias = int + + +class ToApiTuple(NamedTuple): + status_code: ApiHTTPStatus + detail: Callable[[Any], str] | str | None = None + + +# service to public-api status maps +HttpStatusMap: TypeAlias = Mapping[ServiceHTTPStatus, ToApiTuple] + + +def _get_http_exception_kwargs( + service_name: str, + service_error: httpx.HTTPStatusError, + http_status_map: HttpStatusMap, + **detail_kwargs: Any, +): + detail: str = "" + headers: dict[str, str] = {} + + if mapped := http_status_map.get(service_error.response.status_code): + in_api = ToApiTuple(*mapped) + status_code = in_api.status_code + if in_api.detail: + if callable(in_api.detail): + detail = f"{in_api.detail(detail_kwargs)}." + else: + detail = in_api.detail + else: + detail = f"{service_error}." + + elif service_error.response.status_code in { + status.HTTP_429_TOO_MANY_REQUESTS, + status.HTTP_503_SERVICE_UNAVAILABLE, + status.HTTP_504_GATEWAY_TIMEOUT, + }: + status_code = service_error.response.status_code + detail = f"The {service_name} service was unavailable." + if retry_after := service_error.response.headers.get("Retry-After"): + headers["Retry-After"] = retry_after + else: + status_code = status.HTTP_502_BAD_GATEWAY + detail = f"Received unexpected response from {service_name}" + + if status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR: + _logger.exception( + "Converted status code %s from %s service to status code %s", + f"{service_error.response.status_code}", + service_name, + f"{status_code}", + ) + return status_code, detail, headers + + +@contextmanager +def service_exception_handler( + service_name: str, + http_status_map: HttpStatusMap, + **endpoint_kwargs, +): + # + status_code: int + detail: str + headers: dict[str, str] = {} + + try: + + yield + + except ValidationError as exc: + detail = f"{service_name} service returned invalid response" + _logger.exception( + "Invalid data exchanged with %s service. %s", service_name, detail + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=detail, headers=headers + ) from exc + + except httpx.HTTPStatusError as exc: + + status_code, detail, headers = _get_http_exception_kwargs( + service_name, exc, http_status_map=http_status_map, **endpoint_kwargs + ) + raise HTTPException( + status_code=status_code, detail=detail, headers=headers + ) from exc + + +def service_exception_mapper( + service_name: str, + http_status_map: HttpStatusMap, +): + def _decorator(func): + @wraps(func) + async def _wrapper(*args, **kwargs): + with service_exception_handler(service_name, http_status_map, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _decorator diff --git a/services/api-server/src/simcore_service_api_server/models/errors.py b/services/api-server/src/simcore_service_api_server/models/errors.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/errors.py b/services/api-server/src/simcore_service_api_server/models/schemas/errors.py index 5d830facc91..306ac959058 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/errors.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/errors.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, ClassVar from pydantic import BaseModel @@ -10,3 +10,13 @@ class ErrorGet(BaseModel): # - https://github.com/ITISFoundation/osparc-simcore/issues/2520 # - https://github.com/ITISFoundation/osparc-simcore/issues/2446 errors: list[Any] + + class Config: + schema_extra: ClassVar[dict[str, Any]] = { + "example": { + "errors": [ + "some error message", + "another error message", + ] + } + } diff --git a/services/api-server/src/simcore_service_api_server/services/catalog.py b/services/api-server/src/simcore_service_api_server/services/catalog.py index 00cf3f69f4f..9043febef7f 100644 --- a/services/api-server/src/simcore_service_api_server/services/catalog.py +++ b/services/api-server/src/simcore_service_api_server/services/catalog.py @@ -12,10 +12,10 @@ from pydantic import Extra, ValidationError, parse_obj_as, parse_raw_as from settings_library.catalog import CatalogSettings +from ..exceptions.service_errors_utils import service_exception_mapper from ..models.basic_types import VersionStr from ..models.schemas.solvers import LATEST_VERSION, Solver, SolverKeyId, SolverPort from ..utils.client_base import BaseServiceClientApi, setup_client_instance -from .service_exception_handling import service_exception_mapper _logger = logging.getLogger(__name__) @@ -91,6 +91,7 @@ async def list_solvers( product_name: str, predicate: Callable[[Solver], bool] | None = None, ) -> list[Solver]: + response = await self.client.get( "/services", params={"user_id": user_id, "details": True}, diff --git a/services/api-server/src/simcore_service_api_server/services/director_v2.py b/services/api-server/src/simcore_service_api_server/services/director_v2.py index fc5b919997c..8d4a820c084 100644 --- a/services/api-server/src/simcore_service_api_server/services/director_v2.py +++ b/services/api-server/src/simcore_service_api_server/services/director_v2.py @@ -13,9 +13,9 @@ from ..core.settings import DirectorV2Settings from ..db.repositories.groups_extra_properties import GroupsExtraPropertiesRepository +from ..exceptions.service_errors_utils import service_exception_mapper from ..models.schemas.jobs import PercentageInt from ..utils.client_base import BaseServiceClientApi, setup_client_instance -from .service_exception_handling import service_exception_mapper logger = logging.getLogger(__name__) diff --git a/services/api-server/src/simcore_service_api_server/services/log_streaming.py b/services/api-server/src/simcore_service_api_server/services/log_streaming.py index a03b75e51bf..7ca6b1b7c68 100644 --- a/services/api-server/src/simcore_service_api_server/services/log_streaming.py +++ b/services/api-server/src/simcore_service_api_server/services/log_streaming.py @@ -10,6 +10,10 @@ from servicelib.logging_utils import log_catch from servicelib.rabbitmq import RabbitMQClient +from ..exceptions.log_streaming_errors import ( + LogStreamerNotRegisteredError, + LogStreamerRegistionConflictError, +) from ..models.schemas.jobs import JobID, JobLog from .director_v2 import DirectorV2Api @@ -18,18 +22,6 @@ _NEW_LINE: Final[str] = "\n" -class LogDistributionBaseException(Exception): - pass - - -class LogStreamerNotRegistered(LogDistributionBaseException): - pass - - -class LogStreamerRegistionConflict(LogDistributionBaseException): - pass - - class LogDistributor: def __init__(self, rabbitmq_client: RabbitMQClient): self._rabbit_client = rabbitmq_client @@ -66,15 +58,14 @@ async def _distribute_logs(self, data: bytes): queue = self._log_streamers.get(item.job_id) if queue is None: msg = f"Could not forward log because a logstreamer associated with job_id={item.job_id} was not registered" - raise LogStreamerNotRegistered(msg) + raise LogStreamerNotRegisteredError(job_id=item.job_id, details=msg) await queue.put(item) return True return False async def register(self, job_id: JobID, queue: Queue[JobLog]): if job_id in self._log_streamers: - msg = f"A stream was already connected to {job_id=}. Only a single stream can be connected at the time" - raise LogStreamerRegistionConflict(msg) + raise LogStreamerRegistionConflictError(job_id=job_id) self._log_streamers[job_id] = queue await self._rabbit_client.add_topics( LoggerRabbitMessage.get_channel_name(), topics=[f"{job_id}.*"] @@ -82,8 +73,8 @@ async def register(self, job_id: JobID, queue: Queue[JobLog]): async def deregister(self, job_id: JobID): if job_id not in self._log_streamers: - msg = f"No stream was connected to {job_id=}." - raise LogStreamerNotRegistered(msg) + msg = f"No stream was connected to {job_id}." + raise LogStreamerNotRegisteredError(details=msg, job_id=job_id) await self._rabbit_client.remove_topics( LoggerRabbitMessage.get_channel_name(), topics=[f"{job_id}.*"] ) @@ -134,7 +125,7 @@ async def _project_done(self) -> bool: async def log_generator(self) -> AsyncIterable[str]: if not self._is_registered: msg = f"LogStreamer for job_id={self._job_id} is not correctly registered" - raise LogStreamerNotRegistered(msg) + raise LogStreamerNotRegisteredError(msg=msg) done: bool = False while not done: try: diff --git a/services/api-server/src/simcore_service_api_server/services/service_exception_handling.py b/services/api-server/src/simcore_service_api_server/services/service_exception_handling.py deleted file mode 100644 index 33e5baca29b..00000000000 --- a/services/api-server/src/simcore_service_api_server/services/service_exception_handling.py +++ /dev/null @@ -1,105 +0,0 @@ -import logging -from collections.abc import Callable, Mapping -from contextlib import contextmanager -from functools import wraps -from typing import Any - -import httpx -from fastapi import HTTPException, status -from pydantic import ValidationError - -from ..models.schemas.errors import ErrorGet - -_logger = logging.getLogger(__name__) - -MSG_INTERNAL_ERROR_USER_FRIENDLY_TEMPLATE = "Oops! Something went wrong, but we've noted it down and we'll sort it out ASAP. Thanks for your patience! [{}]" - -DEFAULT_BACKEND_SERVICE_STATUS_CODES: dict[int | str, dict[str, Any]] = { - status.HTTP_429_TOO_MANY_REQUESTS: { - "description": "Too many requests", - "model": ErrorGet, - }, - status.HTTP_500_INTERNAL_SERVER_ERROR: { - "description": "Internal server error", - "model": ErrorGet, - }, - status.HTTP_502_BAD_GATEWAY: { - "description": "Unexpected error when communicating with backend service", - "model": ErrorGet, - }, - status.HTTP_503_SERVICE_UNAVAILABLE: { - "description": "Service unavailable", - "model": ErrorGet, - }, - status.HTTP_504_GATEWAY_TIMEOUT: { - "description": "Request to a backend service timed out.", - "model": ErrorGet, - }, -} - - -def service_exception_mapper( - service_name: str, - http_status_map: Mapping[int, tuple[int, Callable[[Any], str] | None]], -): - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - with backend_service_exception_handler( - service_name, http_status_map, **kwargs - ): - return await func(*args, **kwargs) - - return wrapper - - return decorator - - -@contextmanager -def backend_service_exception_handler( - service_name: str, - http_status_map: Mapping[int, tuple[int, Callable[[dict], str] | None]], - **endpoint_kwargs, -): - status_code: int - detail: str - headers: dict[str, str] = {} - try: - yield - except ValidationError as exc: - status_code = status.HTTP_502_BAD_GATEWAY - detail = f"{service_name} service returned invalid response" - _logger.exception("Invalid data exchanged with %s service", service_name) - raise HTTPException( - status_code=status_code, detail=detail, headers=headers - ) from exc - except httpx.HTTPStatusError as exc: - if status_detail_tuple := http_status_map.get(exc.response.status_code): - status_code, detail_callback = status_detail_tuple - if detail_callback is None: - detail = f"{exc}." - else: - detail = f"{detail_callback(endpoint_kwargs)}." - elif exc.response.status_code in { - status.HTTP_429_TOO_MANY_REQUESTS, - status.HTTP_503_SERVICE_UNAVAILABLE, - status.HTTP_504_GATEWAY_TIMEOUT, - }: - status_code = exc.response.status_code - detail = f"The {service_name} service was unavailable." - if retry_after := exc.response.headers.get("Retry-After"): - headers["Retry-After"] = retry_after - else: - status_code = status.HTTP_502_BAD_GATEWAY - detail = f"Received unexpected response from {service_name}" - - if status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR: - _logger.exception( - "Converted status code %s from %s service to status code %s", - f"{exc.response.status_code}", - service_name, - f"{status_code}", - ) - raise HTTPException( - status_code=status_code, detail=detail, headers=headers - ) from exc diff --git a/services/api-server/src/simcore_service_api_server/services/storage.py b/services/api-server/src/simcore_service_api_server/services/storage.py index 86a66decf49..93d593625ed 100644 --- a/services/api-server/src/simcore_service_api_server/services/storage.py +++ b/services/api-server/src/simcore_service_api_server/services/storage.py @@ -17,9 +17,9 @@ from starlette.datastructures import URL from ..core.settings import StorageSettings +from ..exceptions.service_errors_utils import service_exception_mapper from ..models.schemas.files import File from ..utils.client_base import BaseServiceClientApi, setup_client_instance -from .service_exception_handling import service_exception_mapper _logger = logging.getLogger(__name__) diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py index ec252c500eb..1d458da4366 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services/webserver.py @@ -9,7 +9,7 @@ from uuid import UUID from cryptography import fernet -from fastapi import FastAPI +from fastapi import FastAPI, status from models_library.api_schemas_api_server.pricing_plans import ServicePricingPlanGet from models_library.api_schemas_long_running_tasks.tasks import TaskGet from models_library.api_schemas_webserver.computations import ComputationStart @@ -45,7 +45,6 @@ from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import PositiveInt from servicelib.aiohttp.long_running_tasks.server import TaskStatus -from starlette import status from tenacity import TryAgain from tenacity._asyncio import AsyncRetrying from tenacity.before_sleep import before_sleep_log @@ -53,6 +52,11 @@ from tenacity.wait import wait_fixed from ..core.settings import WebServerSettings +from ..exceptions.service_errors_utils import ( + ToApiTuple, + service_exception_handler, + service_exception_mapper, +) from ..models.basic_types import VersionStr from ..models.pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE from ..models.schemas.jobs import MetaValueType @@ -60,10 +64,6 @@ from ..models.schemas.solvers import SolverKeyId from ..models.schemas.studies import StudyPort from ..utils.client_base import BaseServiceClientApi, setup_client_instance -from .service_exception_handling import ( - backend_service_exception_handler, - service_exception_mapper, -) _logger = logging.getLogger(__name__) @@ -169,12 +169,11 @@ async def _page_projects( if search is not None: optional["search"] = search - with backend_service_exception_handler( - "Webserver", - { - status.HTTP_404_NOT_FOUND: ( - status.HTTP_404_NOT_FOUND, - lambda _: "Could not list jobs", + with service_exception_handler( + service_name="Webserver", + http_status_map={ + status.HTTP_404_NOT_FOUND: ToApiTuple( + status.HTTP_404_NOT_FOUND, "Could not list jobs" ) }, ): diff --git a/services/api-server/tests/unit/test_exceptions.py b/services/api-server/tests/unit/test_exceptions.py new file mode 100644 index 00000000000..c64741d8987 --- /dev/null +++ b/services/api-server/tests/unit/test_exceptions.py @@ -0,0 +1,91 @@ +# pylint: disable=unused-variable +# pylint: disable=unused-argument +# pylint: disable=redefined-outer-name + + +from http import HTTPStatus + +import httpx +import pytest +from fastapi import FastAPI, HTTPException, status +from httpx import HTTPStatusError, Request, Response +from simcore_service_api_server.exceptions import setup_exception_handlers +from simcore_service_api_server.exceptions.custom_errors import MissingWalletError +from simcore_service_api_server.exceptions.service_errors_utils import ( + service_exception_mapper, +) +from simcore_service_api_server.models.schemas.errors import ErrorGet + + +async def test_backend_service_exception_mapper(): + @service_exception_mapper( + "DummyService", + { + status.HTTP_400_BAD_REQUEST: ( + status.HTTP_200_OK, + lambda kwargs: "error message", + ) + }, + ) + async def my_endpoint(status_code: int): + raise HTTPStatusError( + message="hello", + request=Request("PUT", "https://asoubkjbasd.asjdbnsakjb"), + response=Response(status_code), + ) + + with pytest.raises(HTTPException) as exc_info: + await my_endpoint(status.HTTP_400_BAD_REQUEST) + assert exc_info.value.status_code == status.HTTP_200_OK + + with pytest.raises(HTTPException) as exc_info: + await my_endpoint(status.HTTP_500_INTERNAL_SERVER_ERROR) + assert exc_info.value.status_code == status.HTTP_502_BAD_GATEWAY + + +@pytest.fixture +def app() -> FastAPI: + """Overrides app to avoid real app and builds instead a simple app to tests exception handlers""" + app = FastAPI() + setup_exception_handlers(app) + + @app.post("/raise-http-exception") + def _raise_http_exception(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="fail message" + ) + + @app.post("/raise-custom-error") + def _raise_custom_exception(): + raise MissingWalletError(job_id=123) + + return app + + +async def test_raised_http_exception(client: httpx.AsyncClient): + response = await client.post("/raise-http-exception") + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + got = ErrorGet.parse_raw(response.text) + assert got.errors == ["fail message"] + + +async def test_fastapi_http_exception_respond_with_error_model( + client: httpx.AsyncClient, +): + response = await client.get("/invalid") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + got = ErrorGet.parse_raw(response.text) + assert got.errors == [HTTPStatus(response.status_code).phrase] + + +async def test_custom_error_handlers(client: httpx.AsyncClient): + response = await client.post("/raise-custom-error") + + assert response.status_code == status.HTTP_424_FAILED_DEPENDENCY + + got = ErrorGet.parse_raw(response.text) + assert got.errors == [f"{MissingWalletError(job_id=123)}"] diff --git a/services/api-server/tests/unit/test_services_rabbitmq.py b/services/api-server/tests/unit/test_services_rabbitmq.py index 48ca8c2d2e4..34c7ae48af0 100644 --- a/services/api-server/tests/unit/test_services_rabbitmq.py +++ b/services/api-server/tests/unit/test_services_rabbitmq.py @@ -44,8 +44,8 @@ from simcore_service_api_server.services.log_streaming import ( LogDistributor, LogStreamer, - LogStreamerNotRegistered, - LogStreamerRegistionConflict, + LogStreamerNotRegisteredError, + LogStreamerRegistionConflictError, ) from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_delay @@ -219,7 +219,7 @@ async def _(job_log: JobLog): pass await log_distributor.register(project_id, _) - with pytest.raises(LogStreamerRegistionConflict): + with pytest.raises(LogStreamerRegistionConflictError): await log_distributor.register(project_id, _) await log_distributor.deregister(project_id) @@ -459,7 +459,7 @@ async def test_log_generator(mocker: MockFixture, faker: Faker): async def test_log_generator_context(mocker: MockFixture, faker: Faker): log_streamer = LogStreamer(user_id=3, director2_api=None, job_id=None, log_distributor=None, log_check_timeout=1) # type: ignore - with pytest.raises(LogStreamerNotRegistered): + with pytest.raises(LogStreamerNotRegisteredError): async for log in log_streamer.log_generator(): print(log) diff --git a/services/api-server/tests/unit/test_services_utils.py b/services/api-server/tests/unit/test_services_utils.py deleted file mode 100644 index a6834e3ca7d..00000000000 --- a/services/api-server/tests/unit/test_services_utils.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from fastapi import HTTPException, status -from httpx import HTTPStatusError, Request, Response -from simcore_service_api_server.services.service_exception_handling import ( - service_exception_mapper, -) - - -async def test_backend_service_exception_mapper(): - @service_exception_mapper( - "DummyService", - { - status.HTTP_400_BAD_REQUEST: ( - status.HTTP_200_OK, - lambda kwargs: "error message", - ) - }, - ) - async def my_endpoint(status_code: int): - raise HTTPStatusError( - message="hello", - request=Request("PUT", "https://asoubkjbasd.asjdbnsakjb"), - response=Response(status_code), - ) - - with pytest.raises(HTTPException) as exc_info: - await my_endpoint(status.HTTP_400_BAD_REQUEST) - assert exc_info.value.status_code == status.HTTP_200_OK - - with pytest.raises(HTTPException) as exc_info: - await my_endpoint(status.HTTP_500_INTERNAL_SERVER_ERROR) - assert exc_info.value.status_code == status.HTTP_502_BAD_GATEWAY