diff --git a/services/web/server/src/simcore_service_webserver/exception_handling/__init__.py b/services/web/server/src/simcore_service_webserver/exception_handling/__init__.py new file mode 100644 index 00000000000..a2f31a08861 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/exception_handling/__init__.py @@ -0,0 +1,12 @@ +from ._base import ExceptionHandlersMap, exception_handling_decorator +from ._factory import ExceptionToHttpErrorMap, HttpErrorInfo, to_exceptions_handlers_map + +__all__: tuple[str, ...] = ( + "ExceptionHandlersMap", + "ExceptionToHttpErrorMap", + "HttpErrorInfo", + "exception_handling_decorator", + "to_exceptions_handlers_map", +) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/exception_handling/_base.py b/services/web/server/src/simcore_service_webserver/exception_handling/_base.py new file mode 100644 index 00000000000..0c9c123bbfb --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/exception_handling/_base.py @@ -0,0 +1,172 @@ +import functools +import logging +from collections.abc import Callable, Iterable +from contextlib import AbstractAsyncContextManager +from types import TracebackType +from typing import Protocol, TypeAlias + +from aiohttp import web +from servicelib.aiohttp.typing_extension import Handler as WebHandler +from servicelib.aiohttp.typing_extension import Middleware as WebMiddleware + +_logger = logging.getLogger(__name__) + + +class AiohttpExceptionHandler(Protocol): + __name__: str + + async def __call__( + self, + request: web.Request, + exception: Exception, + ) -> web.StreamResponse: + """ + Callback that handles an exception produced during a request and transforms it into a response + + Arguments: + request -- current request + exception -- exception raised in web handler during this request + """ + + +ExceptionHandlersMap: TypeAlias = dict[type[Exception], AiohttpExceptionHandler] + + +def _sort_exceptions_by_specificity( + exceptions: Iterable[type[Exception]], *, concrete_first: bool = True +) -> list[type[Exception]]: + """ + Keyword Arguments: + concrete_first -- If True, concrete subclasses precede their superclass (default: {True}). + """ + return sorted( + exceptions, + key=lambda exc: sum(issubclass(e, exc) for e in exceptions if e is not exc), + reverse=not concrete_first, + ) + + +class ExceptionHandlingContextManager(AbstractAsyncContextManager): + """ + A dynamic try-except context manager for handling exceptions in web handlers. + Maps exception types to corresponding handlers, allowing structured error management, i.e. + essentially something like + ``` + try: + + resp = await handler(request) + + except exc_type1 as exc1: + resp = await exc_handler1(request) + except exc_type2 as exc1: + resp = await exc_handler2(request) + # etc + + ``` + and `exception_handlers_map` defines the mapping of exception types (`exc_type*`) to their handlers (`exc_handler*`). + """ + + def __init__( + self, + exception_handlers_map: ExceptionHandlersMap, + *, + request: web.Request, + ): + self._exc_handlers_map = exception_handlers_map + self._exc_types_by_specificity = _sort_exceptions_by_specificity( + list(self._exc_handlers_map.keys()), concrete_first=True + ) + self._request: web.Request = request + self._response: web.StreamResponse | None = None + + def _get_exc_handler_or_none( + self, exc_type: type[Exception], exc_value: Exception + ) -> AiohttpExceptionHandler | None: + exc_handler = self._exc_handlers_map.get(exc_type) + if not exc_handler and ( + base_exc_type := next( + ( + _type + for _type in self._exc_types_by_specificity + if isinstance(exc_value, _type) + ), + None, + ) + ): + exc_handler = self._exc_handlers_map[base_exc_type] + return exc_handler + + async def __aenter__(self): + self._response = None + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + if ( + exc_value is not None + and exc_type is not None + and isinstance(exc_value, Exception) + and issubclass(exc_type, Exception) + and (exc_handler := self._get_exc_handler_or_none(exc_type, exc_value)) + ): + self._response = await exc_handler( + request=self._request, exception=exc_value + ) + return True # suppress + return False # reraise + + def get_response_or_none(self) -> web.StreamResponse | None: + """ + Returns the response generated by the exception handler, if an exception was handled. Otherwise None + """ + return self._response + + +def exception_handling_decorator( + exception_handlers_map: dict[type[Exception], AiohttpExceptionHandler] +) -> Callable[[WebHandler], WebHandler]: + """Creates a decorator to manage exceptions raised in a given route handler. + Ensures consistent exception management across decorated handlers. + + SEE examples test_exception_handling + """ + + def _decorator(handler: WebHandler): + @functools.wraps(handler) + async def _wrapper(request: web.Request) -> web.StreamResponse: + cm = ExceptionHandlingContextManager( + exception_handlers_map, request=request + ) + async with cm: + return await handler(request) + + # If an exception was handled, return the exception handler's return value + response = cm.get_response_or_none() + assert response is not None # nosec + return response + + return _wrapper + + return _decorator + + +def exception_handling_middleware( + exception_handlers_map: dict[type[Exception], AiohttpExceptionHandler] +) -> WebMiddleware: + """Constructs middleware to handle exceptions raised across app routes + + SEE examples test_exception_handling + """ + _handle_excs = exception_handling_decorator( + exception_handlers_map=exception_handlers_map + ) + + @web.middleware + async def middleware_handler(request: web.Request, handler: WebHandler): + return await _handle_excs(handler)(request) + + return middleware_handler diff --git a/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py b/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py new file mode 100644 index 00000000000..baae399f76b --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/exception_handling/_factory.py @@ -0,0 +1,134 @@ +import logging +from typing import NamedTuple, TypeAlias + +from aiohttp import web +from common_library.error_codes import create_error_code +from common_library.json_serialization import json_dumps +from models_library.rest_error import ErrorGet +from servicelib.aiohttp.web_exceptions_extension import get_all_aiohttp_http_exceptions +from servicelib.logging_errors import create_troubleshotting_log_kwargs +from servicelib.status_codes_utils import is_5xx_server_error, is_error + +from ._base import AiohttpExceptionHandler, ExceptionHandlersMap + +_logger = logging.getLogger(__name__) + + +_STATUS_CODE_TO_HTTP_ERRORS: dict[ + int, type[web.HTTPError] +] = get_all_aiohttp_http_exceptions(web.HTTPError) + + +class _DefaultDict(dict): + def __missing__(self, key): + return f"'{key}=?'" + + +class HttpErrorInfo(NamedTuple): + """Info provided to auto-create HTTPError""" + + status_code: int + msg_template: str # sets HTTPError.reason + + +ExceptionToHttpErrorMap: TypeAlias = dict[type[Exception], HttpErrorInfo] + + +def create_error_response(error: ErrorGet, status_code: int) -> web.Response: + assert is_error(status_code), f"{status_code=} must be an error [{error=}]" # nosec + + return web.json_response( + data={"error": error.model_dump(exclude_unset=True, mode="json")}, + dumps=json_dumps, + reason=error.message, + status=status_code, + ) + + +def create_exception_handler_from_http_info( + status_code: int, + msg_template: str, +) -> AiohttpExceptionHandler: + """ + Custom Exception-Handler factory + + Creates a custom `WebApiExceptionHandler` that maps specific exception to a given http status code error + + Given an `ExceptionToHttpErrorMap`, this function returns a handler that checks if an exception + matches one in the map, returning an HTTP error with the mapped status code and message. + Server errors (5xx) include additional logging with request context. Unmapped exceptions are + returned as-is for re-raising. + + Arguments: + status_code: the http status code to associate at the web-api interface to this error + msg_template: a template string to pass to the HttpError + + Returns: + A web api exception handler + """ + assert is_error( # nosec + status_code + ), f"{status_code=} must be an error [{msg_template=}]" + + async def _exception_handler( + request: web.Request, + exception: BaseException, + ) -> web.Response: + + # safe formatting, i.e. does not raise + user_msg = msg_template.format_map( + _DefaultDict(getattr(exception, "__dict__", {})) + ) + + error = ErrorGet.model_construct(message=user_msg) + + if is_5xx_server_error(status_code): + oec = create_error_code(exception) + _logger.exception( + **create_troubleshotting_log_kwargs( + user_msg, + error=exception, + error_code=oec, + error_context={ + "request": request, + "request.remote": f"{request.remote}", + "request.method": f"{request.method}", + "request.path": f"{request.path}", + }, + ) + ) + error = ErrorGet.model_construct(message=user_msg, support_id=oec) + + return create_error_response(error, status_code=status_code) + + return _exception_handler + + +def to_exceptions_handlers_map( + exc_to_http_error_map: ExceptionToHttpErrorMap, +) -> ExceptionHandlersMap: + """Data adapter to convert ExceptionToHttpErrorMap ot ExceptionHandlersMap, i.e. + - from { exc_type: (status, msg), ... } + - to { exc_type: callable, ... } + """ + exc_handlers_map: ExceptionHandlersMap = { + exc_type: create_exception_handler_from_http_info( + status_code=info.status_code, msg_template=info.msg_template + ) + for exc_type, info in exc_to_http_error_map.items() + } + + return exc_handlers_map + + +def create_http_error_exception_handlers_map() -> ExceptionHandlersMap: + """ + Auto create handlers for **all** web.HTTPError + """ + exc_handlers_map: ExceptionHandlersMap = { + exc_type: create_exception_handler_from_http_info( + status_code=code, msg_template="{reason}" + ) + for code, exc_type in _STATUS_CODE_TO_HTTP_ERRORS.items() + } + return exc_handlers_map diff --git a/services/web/server/src/simcore_service_webserver/exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/exceptions_handlers.py deleted file mode 100644 index 7e1ae0bd3e0..00000000000 --- a/services/web/server/src/simcore_service_webserver/exceptions_handlers.py +++ /dev/null @@ -1,90 +0,0 @@ -import functools -import logging -from collections.abc import Iterable -from typing import NamedTuple, TypeAlias - -from aiohttp import web -from servicelib.aiohttp.typing_extension import Handler -from servicelib.aiohttp.web_exceptions_extension import get_http_error_class_or_none -from servicelib.logging_errors import create_troubleshotting_log_kwargs -from servicelib.status_codes_utils import is_5xx_server_error - -_logger = logging.getLogger(__name__) - - -class HttpErrorInfo(NamedTuple): - status_code: int - msg_template: str - - -ExceptionToHttpErrorMap: TypeAlias = dict[type[BaseException], HttpErrorInfo] - - -class _DefaultDict(dict): - def __missing__(self, key): - return f"'{key}=?'" - - -def _sort_exceptions_by_specificity( - exceptions: Iterable[type[BaseException]], *, concrete_first: bool = True -) -> list[type[BaseException]]: - return sorted( - exceptions, - key=lambda exc: sum(issubclass(e, exc) for e in exceptions if e is not exc), - reverse=not concrete_first, - ) - - -def create_exception_handlers_decorator( - exceptions_catch: type[BaseException] | tuple[type[BaseException], ...], - exc_to_status_map: ExceptionToHttpErrorMap, -): - mapped_classes: tuple[type[BaseException], ...] = tuple( - _sort_exceptions_by_specificity(exc_to_status_map.keys()) - ) - - assert all( # nosec - issubclass(cls, exceptions_catch) for cls in mapped_classes - ), f"Every {mapped_classes=} must inherit by one or more of {exceptions_catch=}" - - def _decorator(handler: Handler): - @functools.wraps(handler) - async def _wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except exceptions_catch as exc: - if exc_cls := next( - (cls for cls in mapped_classes if isinstance(exc, cls)), None - ): - http_error_info = exc_to_status_map[exc_cls] - - # safe formatting, i.e. does not raise - user_msg = http_error_info.msg_template.format_map( - _DefaultDict(getattr(exc, "__dict__", {})) - ) - - http_error_cls = get_http_error_class_or_none( - http_error_info.status_code - ) - assert http_error_cls # nosec - - if is_5xx_server_error(http_error_info.status_code): - _logger.exception( - **create_troubleshotting_log_kwargs( - user_msg, - error=exc, - error_context={ - "request": request, - "request.remote": f"{request.remote}", - "request.method": f"{request.method}", - "request.path": f"{request.path}", - }, - ) - ) - raise http_error_cls(reason=user_msg) from exc - raise # reraise - - return _wrapper - - return _decorator diff --git a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py index c611809decd..5d98db3647d 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py @@ -2,21 +2,17 @@ from servicelib.aiohttp import status -from ..exceptions_handlers import ( +from ..exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, - create_exception_handlers_decorator, -) -from ..projects.exceptions import ( - BaseProjectError, - ProjectRunningConflictError, - ProjectStoppingError, + exception_handling_decorator, + to_exceptions_handlers_map, ) +from ..projects.exceptions import ProjectRunningConflictError, ProjectStoppingError from ..workspaces.errors import ( WorkspaceAccessForbiddenError, WorkspaceFolderInconsistencyError, WorkspaceNotFoundError, - WorkspacesValueError, ) from .errors import ( FolderAccessForbiddenError, @@ -69,7 +65,7 @@ } -handle_plugin_requests_exceptions = create_exception_handlers_decorator( - exceptions_catch=(BaseProjectError, FoldersValueError, WorkspacesValueError), - exc_to_status_map=_TO_HTTP_ERROR_MAP, +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) ) +# this is one decorator with a single exception handler diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py index ced4d0442bd..963b81c4900 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py @@ -8,10 +8,11 @@ ) from .._meta import API_VTAG as VTAG -from ..exceptions_handlers import ( +from ..exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, - create_exception_handlers_decorator, + exception_handling_decorator, + to_exceptions_handlers_map, ) from ..login.decorators import get_user_id, login_required from ..products.api import get_product_name @@ -19,11 +20,7 @@ from ..security.decorators import permission_required from . import _trash_api from ._common_models import RemoveQueryParams -from .exceptions import ( - ProjectRunningConflictError, - ProjectStoppingError, - ProjectTrashError, -) +from .exceptions import ProjectRunningConflictError, ProjectStoppingError _logger = logging.getLogger(__name__) @@ -44,10 +41,11 @@ } -_handle_exceptions = create_exception_handlers_decorator( - exceptions_catch=ProjectTrashError, exc_to_status_map=_TO_HTTP_ERROR_MAP +_handle_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) ) + # # ROUTES # diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py index f6470f461f7..1bb16355b80 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py @@ -2,21 +2,17 @@ from servicelib.aiohttp import status -from ..exceptions_handlers import ( +from ..exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, - create_exception_handlers_decorator, -) -from ..projects.exceptions import ( - BaseProjectError, - ProjectRunningConflictError, - ProjectStoppingError, + exception_handling_decorator, + to_exceptions_handlers_map, ) +from ..projects.exceptions import ProjectRunningConflictError, ProjectStoppingError from .errors import ( WorkspaceAccessForbiddenError, WorkspaceGroupNotFoundError, WorkspaceNotFoundError, - WorkspacesValueError, ) _logger = logging.getLogger(__name__) @@ -47,7 +43,6 @@ } -handle_plugin_requests_exceptions = create_exception_handlers_decorator( - exceptions_catch=(BaseProjectError, WorkspacesValueError), - exc_to_status_map=_TO_HTTP_ERROR_MAP, +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) ) diff --git a/services/web/server/tests/unit/isolated/test_exception_handling.py b/services/web/server/tests/unit/isolated/test_exception_handling.py new file mode 100644 index 00000000000..775fe452a21 --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_exception_handling.py @@ -0,0 +1,198 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from collections.abc import Callable + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient +from models_library.rest_error import ErrorGet +from servicelib.aiohttp import status +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON, MIMETYPE_TEXT_PLAIN +from simcore_service_webserver.exception_handling import ( + ExceptionHandlersMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from simcore_service_webserver.exception_handling._base import ( + exception_handling_middleware, +) +from simcore_service_webserver.exception_handling._factory import ( + create_http_error_exception_handlers_map, +) + + +@pytest.fixture +def exception_handlers_map(build_method: str) -> ExceptionHandlersMap: + """ + Two different ways to build the exception_handlers_map + """ + exception_handlers_map: ExceptionHandlersMap = {} + + if build_method == "function": + + async def _value_error_as_422_func( + request: web.Request, exception: BaseException + ) -> web.Response: + # custom exception handler + return web.json_response( + reason=f"{build_method=}", status=status.HTTP_422_UNPROCESSABLE_ENTITY + ) + + exception_handlers_map = { + ValueError: _value_error_as_422_func, + } + + elif build_method == "http_map": + exception_handlers_map = to_exceptions_handlers_map( + { + ValueError: HttpErrorInfo( + status.HTTP_422_UNPROCESSABLE_ENTITY, f"{build_method=}" + ) + } + ) + else: + pytest.fail(f"Undefined {build_method=}") + + return exception_handlers_map + + +@pytest.mark.parametrize("build_method", ["function", "http_map"]) +async def test_handling_exceptions_decorating_a_route( + aiohttp_client: Callable, + exception_handlers_map: ExceptionHandlersMap, + build_method: str, +): + + # 1. create decorator + exc_handling = exception_handling_decorator(exception_handlers_map) + + # adding new routes + routes = web.RouteTableDef() + + @routes.post("/{what}") + @exc_handling # < ----- 2. using decorator + async def _handler(request: web.Request): + what = request.match_info["what"] + match what: + case "ValueError": + raise ValueError # handled + case "IndexError": + raise IndexError # not-handled + case "HTTPConflict": + raise web.HTTPConflict # not-handled + case "HTTPOk": + # non errors should NOT be raised, + # SEE https://github.com/ITISFoundation/osparc-simcore/pull/6829 + # but if it is so ... + raise web.HTTPOk # not-handled + + return web.Response(text=what) + + app = web.Application() + app.add_routes(routes) + + # 3. testing from the client side + client: TestClient = await aiohttp_client(app) + + # success + resp = await client.post("/ok") + assert resp.status == status.HTTP_200_OK + + # handled non-HTTPException exception + resp = await client.post("/ValueError") + assert resp.status == status.HTTP_422_UNPROCESSABLE_ENTITY + if build_method == "http_map": + body = await resp.json() + error = ErrorGet.model_validate(body["error"]) + assert error.message == f"{build_method=}" + + # undhandled non-HTTPException + resp = await client.post("/IndexError") + assert resp.status == status.HTTP_500_INTERNAL_SERVER_ERROR + + # undhandled HTTPError + resp = await client.post("/HTTPConflict") + assert resp.status == status.HTTP_409_CONFLICT + + # undhandled HTTPSuccess + resp = await client.post("/HTTPOk") + assert resp.status == status.HTTP_200_OK + + +@pytest.mark.parametrize("build_method", ["function", "http_map"]) +async def test_handling_exceptions_with_middelware( + aiohttp_client: Callable, + exception_handlers_map: ExceptionHandlersMap, + build_method: str, +): + routes = web.RouteTableDef() + + @routes.post("/{what}") # NO decorantor now + async def _handler(request: web.Request): + match request.match_info["what"]: + case "ValueError": + raise ValueError # handled + return web.Response() + + app = web.Application() + app.add_routes(routes) + + # 1. create & install middleware + exc_handling = exception_handling_middleware(exception_handlers_map) + app.middlewares.append(exc_handling) + + # 2. testing from the client side + client: TestClient = await aiohttp_client(app) + + # success + resp = await client.post("/ok") + assert resp.status == status.HTTP_200_OK + + # handled non-HTTPException exception + resp = await client.post("/ValueError") + assert resp.status == status.HTTP_422_UNPROCESSABLE_ENTITY + if build_method == "http_map": + body = await resp.json() + error = ErrorGet.model_validate(body["error"]) + assert error.message == f"{build_method=}" + + +@pytest.mark.parametrize("with_middleware", [True, False]) +async def test_raising_aiohttp_http_errors( + aiohttp_client: Callable, with_middleware: bool +): + routes = web.RouteTableDef() + + @routes.post("/raise-http-error") + async def _handler1(request: web.Request): + # 1. raises aiohttp.web_exceptions.HttpError + raise web.HTTPConflict + + app = web.Application() + app.add_routes(routes) + + # 2. create & install middleware handlers for ALL http (optional) + if with_middleware: + exc_handling = exception_handling_middleware( + exception_handlers_map=create_http_error_exception_handlers_map() + ) + app.middlewares.append(exc_handling) + + # 3. testing from the client side + client: TestClient = await aiohttp_client(app) + + resp = await client.post("/raise-http-error") + assert resp.status == status.HTTP_409_CONFLICT + + if with_middleware: + assert resp.content_type == MIMETYPE_APPLICATION_JSON + ErrorGet.model_construct((await resp.json())["error"]) + else: + # default + assert resp.content_type == MIMETYPE_TEXT_PLAIN diff --git a/services/web/server/tests/unit/isolated/test_exception_handling_base.py b/services/web/server/tests/unit/isolated/test_exception_handling_base.py new file mode 100644 index 00000000000..b9c3bc87f9d --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_exception_handling_base.py @@ -0,0 +1,126 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from simcore_service_webserver.errors import WebServerBaseError +from simcore_service_webserver.exception_handling._base import ( + AiohttpExceptionHandler, + ExceptionHandlingContextManager, + _sort_exceptions_by_specificity, + exception_handling_decorator, +) + +# Some custom errors in my service + + +class BaseError(WebServerBaseError): + ... + + +class OneError(BaseError): + ... + + +class OtherError(BaseError): + ... + + +def test_sort_concrete_first(): + assert _sort_exceptions_by_specificity([Exception, BaseError]) == [ + BaseError, + Exception, + ] + + assert _sort_exceptions_by_specificity( + [Exception, BaseError], concrete_first=False + ) == [ + Exception, + BaseError, + ] + + +def test_sort_exceptions_by_specificity(): + + got_exceptions_cls = _sort_exceptions_by_specificity( + [ + Exception, + OtherError, + OneError, + BaseError, + ValueError, + ArithmeticError, + ZeroDivisionError, + ] + ) + + for from_, exc in enumerate(got_exceptions_cls, start=1): + for exc_after in got_exceptions_cls[from_:]: + assert not issubclass(exc_after, exc), f"{got_exceptions_cls=}" + + +async def test__handled_exception_context_manager(): + + expected_request = make_mocked_request("GET", "/foo") + expected_response = web.json_response({"error": {"msg": "Foo"}}) + + # define exception-handler function + async def _base_exc_handler(request, exception): + assert request == expected_request + assert isinstance(exception, BaseError) + assert not isinstance(exception, OtherError) + return expected_response + + async def _concrete_exc_handler(request, exception): + assert request == expected_request + assert isinstance(exception, OtherError) + return expected_response + + exception_handlers_map: dict[type[BaseException], AiohttpExceptionHandler] = { + BaseError: _base_exc_handler, + OtherError: _concrete_exc_handler, + } + + # handles any BaseError returning a response + cm = ExceptionHandlingContextManager( + exception_handlers_map, request=expected_request + ) + async with cm: + raise OneError + assert cm.get_response_or_none() == expected_response + + async with cm: + raise OtherError + assert cm.get_response_or_none() == expected_response + + # reraises + with pytest.raises(ArithmeticError): + async with cm: + raise ArithmeticError + + +@pytest.mark.parametrize("exception_cls", [OneError, OtherError]) +async def test_async_try_except_decorator(exception_cls: type[Exception]): + expected_request = make_mocked_request("GET", "/foo") + expected_exception = exception_cls() + expected_response = web.Response(reason=f"suppressed {exception_cls}") + + # creates exception handler + async def _suppress_all(request: web.Request, exception): + assert exception == expected_exception + assert request == expected_request + return expected_response + + @exception_handling_decorator({BaseError: _suppress_all}) + async def _rest_handler(request: web.Request) -> web.Response: + raise expected_exception + + # emulates request/response workflow + resp = await _rest_handler(expected_request) + assert resp == expected_response diff --git a/services/web/server/tests/unit/isolated/test_exception_handling_factory.py b/services/web/server/tests/unit/isolated/test_exception_handling_factory.py new file mode 100644 index 00000000000..e87ef0b53c3 --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_exception_handling_factory.py @@ -0,0 +1,154 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import logging + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from servicelib.aiohttp import status +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from simcore_service_webserver.errors import WebServerBaseError +from simcore_service_webserver.exception_handling._base import ( + ExceptionHandlingContextManager, + exception_handling_decorator, +) +from simcore_service_webserver.exception_handling._factory import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + create_exception_handler_from_http_info, + to_exceptions_handlers_map, +) + +# Some custom errors in my service + + +class BaseError(WebServerBaseError): + ... + + +class OneError(BaseError): + ... + + +class OtherError(BaseError): + ... + + +@pytest.fixture +def fake_request() -> web.Request: + return make_mocked_request("GET", "/foo") + + +async def test_factory__create_exception_handler_from_http_error( + fake_request: web.Request, +): + one_error_to_404 = create_exception_handler_from_http_info( + status_code=status.HTTP_404_NOT_FOUND, + msg_template="one error message for the user: {code} {value}", + ) + + # calling exception handler + caught = OneError() + response = await one_error_to_404(fake_request, caught) + assert response.status == status.HTTP_404_NOT_FOUND + assert response.text is not None + assert "one error message" in response.reason + assert response.content_type == MIMETYPE_APPLICATION_JSON + + +async def test_handling_different_exceptions_with_context( + fake_request: web.Request, + caplog: pytest.LogCaptureFixture, +): + exc_to_http_error_map: ExceptionToHttpErrorMap = { + OneError: HttpErrorInfo(status.HTTP_400_BAD_REQUEST, "Error {code} to 400"), + OtherError: HttpErrorInfo(status.HTTP_500_INTERNAL_SERVER_ERROR, "{code}"), + } + cm = ExceptionHandlingContextManager( + to_exceptions_handlers_map(exc_to_http_error_map), request=fake_request + ) + + with caplog.at_level(logging.ERROR): + # handles as 4XX + async with cm: + raise OneError + + response = cm.get_response_or_none() + assert response is not None + assert response.status == status.HTTP_400_BAD_REQUEST + assert response.reason == exc_to_http_error_map[OneError].msg_template.format( + code="WebServerBaseError.BaseError.OneError" + ) + assert not caplog.records + + # unhandled -> reraises + err = RuntimeError() + with pytest.raises(RuntimeError) as err_info: + async with cm: + raise err + + assert cm.get_response_or_none() is None + assert err_info.value == err + + # handles as 5XX and logs + async with cm: + raise OtherError + + response = cm.get_response_or_none() + assert response is not None + assert response.status == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.reason == exc_to_http_error_map[OtherError].msg_template.format( + code="WebServerBaseError.BaseError.OtherError" + ) + assert caplog.records, "Expected 5XX troubleshooting logged as error" + assert caplog.records[0].levelno == logging.ERROR + + +async def test_handling_different_exceptions_with_decorator( + fake_request: web.Request, + caplog: pytest.LogCaptureFixture, +): + exc_to_http_error_map: ExceptionToHttpErrorMap = { + OneError: HttpErrorInfo(status.HTTP_503_SERVICE_UNAVAILABLE, "{code}"), + } + + exc_handling_decorator = exception_handling_decorator( + to_exceptions_handlers_map(exc_to_http_error_map) + ) + + @exc_handling_decorator + async def _rest_handler(request: web.Request) -> web.Response: + if request.query.get("raise") == "OneError": + raise OneError + if request.query.get("raise") == "ArithmeticError": + raise ArithmeticError + return web.json_response(reason="all good") + + with caplog.at_level(logging.ERROR): + + # emulates successful call + resp = await _rest_handler(make_mocked_request("GET", "/foo")) + assert resp.status == status.HTTP_200_OK + assert resp.reason == "all good" + + assert not caplog.records + + # reraised + with pytest.raises(ArithmeticError): + await _rest_handler( + make_mocked_request("GET", "/foo?raise=ArithmeticError") + ) + + assert not caplog.records + + # handles as 5XX and logs + resp = await _rest_handler(make_mocked_request("GET", "/foo?raise=OneError")) + assert resp.status == status.HTTP_503_SERVICE_UNAVAILABLE + assert caplog.records, "Expected 5XX troubleshooting logged as error" + assert caplog.records[0].levelno == logging.ERROR diff --git a/services/web/server/tests/unit/isolated/test_exceptions_handlers.py b/services/web/server/tests/unit/isolated/test_exceptions_handlers.py deleted file mode 100644 index 27cde72283b..00000000000 --- a/services/web/server/tests/unit/isolated/test_exceptions_handlers.py +++ /dev/null @@ -1,117 +0,0 @@ -# pylint: disable=protected-access -# pylint: disable=redefined-outer-name -# pylint: disable=too-many-arguments -# pylint: disable=too-many-statements -# pylint: disable=unused-argument -# pylint: disable=unused-variable - - -import logging - -import pytest -from aiohttp import web -from aiohttp.test_utils import make_mocked_request -from servicelib.aiohttp import status -from simcore_service_webserver.errors import WebServerBaseError -from simcore_service_webserver.exceptions_handlers import ( - HttpErrorInfo, - _sort_exceptions_by_specificity, - create_exception_handlers_decorator, -) - - -class BasePluginError(WebServerBaseError): - ... - - -class OneError(BasePluginError): - ... - - -class OtherError(BasePluginError): - ... - - -def test_sort_concrete_first(): - assert _sort_exceptions_by_specificity([Exception, BasePluginError]) == [ - BasePluginError, - Exception, - ] - - assert _sort_exceptions_by_specificity( - [Exception, BasePluginError], concrete_first=False - ) == [ - Exception, - BasePluginError, - ] - - -def test_sort_exceptions_by_specificity(): - - got_exceptions_cls = _sort_exceptions_by_specificity( - [ - Exception, - OtherError, - OneError, - BasePluginError, - ValueError, - ArithmeticError, - ZeroDivisionError, - ] - ) - - for from_, exc in enumerate(got_exceptions_cls, start=1): - for exc_after in got_exceptions_cls[from_:]: - assert not issubclass(exc_after, exc), f"{got_exceptions_cls=}" - - -async def test_exception_handlers_decorator( - caplog: pytest.LogCaptureFixture, -): - - _handle_exceptions = create_exception_handlers_decorator( - exceptions_catch=BasePluginError, - exc_to_status_map={ - OneError: HttpErrorInfo( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - msg_template="This is one error for front-end", - ) - }, - ) - - @_handle_exceptions - async def _rest_handler(request: web.Request) -> web.Response: - if request.query.get("raise") == "OneError": - raise OneError - if request.query.get("raise") == "ArithmeticError": - raise ArithmeticError - - return web.Response(reason="all good") - - with caplog.at_level(logging.ERROR): - - # emulates successful call - resp = await _rest_handler(make_mocked_request("GET", "/foo")) - assert resp.status == status.HTTP_200_OK - assert resp.reason == "all good" - - assert not caplog.records - - # this will be passed and catched by the outermost error middleware - with pytest.raises(ArithmeticError): - await _rest_handler( - make_mocked_request("GET", "/foo?raise=ArithmeticError") - ) - - assert not caplog.records - - # this is a 5XX will be converted to response but is logged as error as well - with pytest.raises(web.HTTPException) as exc_info: - await _rest_handler(make_mocked_request("GET", "/foo?raise=OneError")) - - resp = exc_info.value - assert resp.status == status.HTTP_503_SERVICE_UNAVAILABLE - assert "front-end" in resp.reason - - assert caplog.records - assert caplog.records[0].levelno == logging.ERROR diff --git a/services/web/server/tests/unit/with_dbs/03/test_trash.py b/services/web/server/tests/unit/with_dbs/03/test_trash.py index 2489ea6107c..76f4aefb46b 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_trash.py +++ b/services/web/server/tests/unit/with_dbs/03/test_trash.py @@ -133,7 +133,6 @@ async def test_trash_projects( # noqa: PLR0915 could_not_trash = is_project_running and not force if could_not_trash: - assert error["status"] == status.HTTP_409_CONFLICT assert "Current study is in use" in error["message"] # GET