Skip to content

Commit b8b85c7

Browse files
committed
maps
1 parent ca08ce9 commit b8b85c7

File tree

3 files changed

+200
-45
lines changed

3 files changed

+200
-45
lines changed

services/web/server/src/simcore_service_webserver/exceptions_handlers_base.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Iterable
44
from contextlib import AbstractAsyncContextManager
55
from types import TracebackType
6-
from typing import Protocol
6+
from typing import Protocol, TypeAlias
77

88
from aiohttp import web
99
from servicelib.aiohttp.typing_extension import Handler as WebHandler
@@ -43,38 +43,41 @@ def _sort_exceptions_by_specificity(
4343
)
4444

4545

46+
ExceptionHandlersMap: TypeAlias = dict[type[BaseException], AiohttpExceptionHandler]
47+
48+
4649
class AsyncDynamicTryExceptContext(AbstractAsyncContextManager):
4750
"""Context manager to handle exceptions if they match any in the
4851
exception_handlers_map"""
4952

5053
def __init__(
5154
self,
52-
exception_handlers_map: dict[type[BaseException], AiohttpExceptionHandler],
55+
exception_handlers_map: ExceptionHandlersMap,
5356
*,
5457
request: web.Request,
5558
):
56-
self._exception_handlers_map = exception_handlers_map
57-
self._exceptions_types_priorized = _sort_exceptions_by_specificity(
58-
list(self._exception_handlers_map.keys()), concrete_first=True
59+
self._exc_handlers_map = exception_handlers_map
60+
self._exc_types_priorized = _sort_exceptions_by_specificity(
61+
list(self._exc_handlers_map.keys()), concrete_first=True
5962
)
6063
self._request = request
6164
self._response = None
6265

6366
def _get_exc_handler_or_none(
6467
self, exc_type: type[BaseException], exc_value: BaseException
6568
) -> AiohttpExceptionHandler | None:
66-
exc_handler = self._exception_handlers_map.get(exc_type)
69+
exc_handler = self._exc_handlers_map.get(exc_type)
6770
if not exc_handler and (
6871
base_exc_type := next(
6972
(
7073
_type
71-
for _type in self._exceptions_types_priorized
74+
for _type in self._exc_types_priorized
7275
if isinstance(exc_value, _type)
7376
),
7477
None,
7578
)
7679
):
77-
exc_handler = self._exception_handlers_map[base_exc_type]
80+
exc_handler = self._exc_handlers_map[base_exc_type]
7881
return exc_handler
7982

8083
async def __aenter__(self):

services/web/server/src/simcore_service_webserver/exceptions_handlers_http_error_map.py

Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@
1010
from servicelib.logging_errors import create_troubleshotting_log_kwargs
1111
from servicelib.status_codes_utils import is_5xx_server_error
1212

13-
from .exceptions_handlers_base import (
14-
AiohttpExceptionHandler,
15-
_sort_exceptions_by_specificity,
16-
)
13+
from .exceptions_handlers_base import AiohttpExceptionHandler, ExceptionHandlersMap
1714

1815
_logger = logging.getLogger(__name__)
1916

@@ -92,45 +89,22 @@ async def _exception_handler(
9289
return web.json_response(
9390
data={"error": error.model_dump(exclude_unset=True, mode="json")},
9491
dumps=json_dumps,
92+
reason=user_msg,
93+
status=status_code,
9594
)
9695

9796
return _exception_handler
9897

9998

100-
def create_exception_handler_from_http_error_map(
99+
def to_exceptions_handlers_map(
101100
exc_to_http_error_map: ExceptionToHttpErrorMap,
102-
) -> AiohttpExceptionHandler:
103-
"""
104-
Custom Exception-Handler factory
105-
106-
Creates a custom `WebApiExceptionHandler` that maps one-to-one exception to status code error codes
107-
108-
Analogous to `create_exception_handler_from_status_code` but ExceptionToHttpErrorMap as input
109-
"""
110-
111-
_exception_handlers = {
112-
exc_cls: create_exception_handler_from_http_error(
113-
status_code=http_error_info.status_code,
114-
msg_template=http_error_info.msg_template,
101+
) -> ExceptionHandlersMap:
102+
"""Converts { exc_type: (status, msg), ...} -> {exc_type: callable }"""
103+
exc_handlers_map: ExceptionHandlersMap = {
104+
exc_type: create_exception_handler_from_http_error(
105+
status_code=info.status_code, msg_template=info.msg_template
115106
)
116-
for exc_cls, http_error_info in exc_to_http_error_map.items()
107+
for exc_type, info in exc_to_http_error_map.items()
117108
}
118109

119-
_catch_exceptions = _sort_exceptions_by_specificity(
120-
list(_exception_handlers.keys())
121-
)
122-
123-
async def _exception_handler(
124-
request: web.Request,
125-
exception: BaseException,
126-
) -> web.Response:
127-
if exc_cls := next(
128-
(_ for _ in _catch_exceptions if isinstance(exception, _)), None
129-
):
130-
return await _exception_handlers[exc_cls](
131-
request=request, exception=exception
132-
)
133-
# NOTE: not in my list, return so it gets reraised
134-
return exception
135-
136-
return _exception_handler
110+
return exc_handlers_map
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=too-many-statements
5+
# pylint: disable=unused-argument
6+
# pylint: disable=unused-variable
7+
8+
9+
import pytest
10+
from aiohttp import web
11+
from aiohttp.test_utils import make_mocked_request
12+
from servicelib.aiohttp import status
13+
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
14+
from simcore_service_webserver.errors import WebServerBaseError
15+
from simcore_service_webserver.exceptions_handlers_base import (
16+
AsyncDynamicTryExceptContext,
17+
)
18+
from simcore_service_webserver.exceptions_handlers_http_error_map import (
19+
ExceptionToHttpErrorMap,
20+
HttpErrorInfo,
21+
create_exception_handler_from_http_error,
22+
to_exceptions_handlers_map,
23+
)
24+
25+
# Some custom errors in my service
26+
27+
28+
class BaseError(WebServerBaseError):
29+
...
30+
31+
32+
class OneError(BaseError):
33+
...
34+
35+
36+
class OtherError(BaseError):
37+
...
38+
39+
40+
@pytest.fixture
41+
def fake_request() -> web.Request:
42+
return make_mocked_request("GET", "/foo")
43+
44+
45+
async def test_factory__create_exception_handler_from_http_error(
46+
fake_request: web.Request,
47+
):
48+
one_error_to_404 = create_exception_handler_from_http_error(
49+
status_code=status.HTTP_404_NOT_FOUND,
50+
msg_template="one error message for the user: {code} {value}",
51+
)
52+
53+
# calling exception handler
54+
caught = OneError()
55+
response = await one_error_to_404(fake_request, caught)
56+
assert response.status == status.HTTP_404_NOT_FOUND
57+
assert response.text is not None
58+
assert "one error message" in response.reason
59+
assert response.content_type == MIMETYPE_APPLICATION_JSON
60+
61+
62+
async def test_factory__create_exception_handler_from_http_error_map(
63+
fake_request: web.Request,
64+
):
65+
exc_to_http_error_map: ExceptionToHttpErrorMap = {
66+
OneError: HttpErrorInfo(status.HTTP_400_BAD_REQUEST, "Error One mapped to 400")
67+
}
68+
69+
cm = AsyncDynamicTryExceptContext(
70+
to_exceptions_handlers_map(exc_to_http_error_map), request=fake_request
71+
)
72+
async with cm:
73+
raise OneError
74+
75+
response = cm.get_response()
76+
assert response is not None
77+
assert response.status == status.HTTP_400_BAD_REQUEST
78+
assert response.reason == "Error One mapped to 400"
79+
80+
# By-passes exceptions not listed
81+
err = RuntimeError()
82+
with pytest.raises(RuntimeError) as err_info:
83+
async with cm:
84+
raise err
85+
86+
assert cm.get_response() is None
87+
assert err_info.value == err
88+
89+
90+
# async def test__handled_exception_context_manager(fake_request: web.Request):
91+
92+
# expected_response = web.json_response({"error": {"msg": "Foo"}})
93+
94+
# async def _custom_handler(request, exception):
95+
# assert request == fake_request
96+
# assert isinstance(
97+
# exception, BaseError
98+
# ), "only BasePluginError exceptions should call this handler"
99+
# return expected_response
100+
101+
# exc_handling_ctx = _handled_exception_context_manager(
102+
# BaseError, _custom_handler, request=fake_request
103+
# )
104+
105+
# # handles any BaseError returning a response
106+
# async with exc_handling_ctx as ctx:
107+
# raise OneError
108+
# assert ctx.response == expected_response
109+
110+
# async with exc_handling_ctx as ctx:
111+
# raise OtherError
112+
# assert ctx.response == expected_response
113+
114+
# # otherwise thru
115+
# with pytest.raises(ArithmeticError):
116+
# async with exc_handling_ctx:
117+
# raise ArithmeticError
118+
119+
120+
# async def test_create_decorator_from_exception_handler(
121+
# caplog: pytest.LogCaptureFixture,
122+
# ):
123+
# # Create an SINGLE exception handler that acts as umbrella for all these exceptions
124+
# http_error_map: ExceptionToHttpErrorMap = {
125+
# OneError: HttpErrorInfo(
126+
# status.HTTP_503_SERVICE_UNAVAILABLE,
127+
# "Human readable error transmitted to the front-end",
128+
# )
129+
# }
130+
131+
# exc_handler = create_exception_handler_from_http_error_map(http_error_map)
132+
# _exc_handling_ctx = create_decorator_from_exception_handler(
133+
# exception_types=BaseError, # <--- FIXME" this is redundant because exception has been already passed in exc_handler!
134+
# exception_handler=exc_handler,
135+
# )
136+
137+
# @_exc_handling_ctx
138+
# async def _rest_handler(request: web.Request) -> web.Response:
139+
# if request.query.get("raise") == "OneError":
140+
# raise OneError
141+
# if request.query.get("raise") == "ArithmeticError":
142+
# raise ArithmeticError
143+
144+
# return web.Response(reason="all good")
145+
146+
# with caplog.at_level(logging.ERROR):
147+
148+
# # emulates successful call
149+
# resp = await _rest_handler(make_mocked_request("GET", "/foo"))
150+
# assert resp.status == status.HTTP_200_OK
151+
# assert resp.reason == "all good"
152+
153+
# assert not caplog.records
154+
155+
# # this will be passed and catched by the outermost error middleware
156+
# with pytest.raises(ArithmeticError):
157+
# await _rest_handler(
158+
# make_mocked_request("GET", "/foo?raise=ArithmeticError")
159+
# )
160+
161+
# assert not caplog.records
162+
163+
# # this is a 5XX will be converted to response but is logged as error as well
164+
# with pytest.raises(web.HTTPException) as exc_info:
165+
# await _rest_handler(make_mocked_request("GET", "/foo?raise=OneError"))
166+
167+
# resp = exc_info.value
168+
# assert resp.status == status.HTTP_503_SERVICE_UNAVAILABLE
169+
# assert "front-end" in resp.reason
170+
171+
# assert caplog.records, "Expected 5XX troubleshooting logged as error"
172+
# assert caplog.records[0].levelno == logging.ERROR
173+
174+
# # typically capture by last
175+
# with pytest.raises(ArithmeticError):
176+
# resp = await _rest_handler(
177+
# make_mocked_request("GET", "/foo?raise=ArithmeticError")
178+
# )

0 commit comments

Comments
 (0)