Skip to content

Commit da3be32

Browse files
committed
handler return json.response
1 parent a5accac commit da3be32

File tree

3 files changed

+95
-73
lines changed

3 files changed

+95
-73
lines changed
Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import functools
22
import logging
33
from contextlib import asynccontextmanager
4-
from typing import Protocol
4+
from dataclasses import dataclass
5+
from typing import AsyncIterator, Protocol
56

67
from aiohttp import web
78
from servicelib.aiohttp.typing_extension import Handler as WebHandler
@@ -13,55 +14,54 @@
1314
#
1415

1516

16-
class WebApiExceptionHandler(Protocol):
17+
class AiohttpExceptionHandler(Protocol):
1718
__name__: str
1819

1920
async def __call__(
2021
self,
2122
request: web.Request,
2223
exception: BaseException,
23-
) -> web.HTTPException | BaseException | None:
24+
) -> web.Response:
2425
"""
2526
Callback to process an exception raised during a web request, allowing custom handling.
2627
27-
This function can be implemented to suppress, reraise, or transform the exception
28-
into an `web.HTTPException` (i.e. exceptions defined at the web-api)
28+
This function can be implemented to suppress, (transform &) reraise as web.HTTPError
29+
or return a web.Response
2930
3031
Arguments:
3132
request -- current request
3233
exception -- exception raised in web handler during this request
33-
34-
Returns:
35-
- None: to suppress `exception`
36-
- `exception`: to reraise it
37-
- an instance of `web.HTTPException` to transform to HTTP Api exceptions (NOTE: that they can either be errors or success!)
3834
"""
35+
... # pylint: disable=unnecessary-ellipsis
36+
37+
38+
@dataclass
39+
class _ExceptionContext:
40+
response: web.Response | None = None
3941

4042

4143
@asynccontextmanager
4244
async def _handled_exception_context_manager(
4345
exception_catch: type[BaseException] | tuple[type[BaseException], ...],
44-
exception_handler: WebApiExceptionHandler,
46+
exception_handler: AiohttpExceptionHandler,
4547
**forward_ctx,
46-
):
47-
"""Calls `exception_handler` on exceptions raised in this context and caught in `exception_catch`"""
48+
) -> AsyncIterator[_ExceptionContext]:
49+
"""Calls `exception_handler` on exceptions raised in this context
50+
and caught in `exception_catch`
51+
"""
52+
ctx = _ExceptionContext()
4853
try:
4954

50-
yield
55+
yield ctx
5156

5257
except exception_catch as e:
53-
exc = await exception_handler(exception=e, **forward_ctx)
54-
if exc:
55-
assert isinstance(exc, BaseException)
56-
raise exc from e
57-
58-
_logger.debug(
59-
"%s suppressed %s: %s", exception_handler.__name__, type(e).__name__, f"{e}"
60-
)
58+
response = await exception_handler(exception=e, **forward_ctx)
59+
assert isinstance(response, web.Response) # nosec
60+
ctx.response = response
6161

6262

6363
def create_decorator_from_exception_handler(
64-
exception_handler: WebApiExceptionHandler,
64+
exception_handler: AiohttpExceptionHandler,
6565
exception_types: type[BaseException] | tuple[type[BaseException], ...] = Exception,
6666
):
6767
"""Returns a decorator for aiohttp's web.Handler functions
@@ -77,9 +77,12 @@ async def _wrapper(request: web.Request) -> web.StreamResponse:
7777
exception_types,
7878
exception_handler,
7979
request=request,
80-
):
80+
) as exc_ctx:
81+
8182
return await handler(request)
8283

84+
return exc_ctx.response
85+
8386
return _wrapper
8487

8588
return _decorator

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
from servicelib.logging_errors import create_troubleshotting_log_kwargs
88
from servicelib.status_codes_utils import is_5xx_server_error
99

10-
from .exceptions_handlers_base import WebApiExceptionHandler
10+
from .exceptions_handlers_base import AiohttpExceptionHandler
1111

1212
_logger = logging.getLogger(__name__)
1313

1414

15-
_STATUS_CODE_TO_HTTP_EXCEPTIONS: dict[
15+
_STATUS_CODE_TO_HTTP_ERRORS: dict[
1616
int, type[web.HTTPException]
17-
] = get_all_aiohttp_http_exceptions(web.HTTPException)
17+
] = get_all_aiohttp_http_exceptions(web.HTTPError)
1818

1919

2020
class _DefaultDict(dict):
@@ -37,7 +37,7 @@ def create_exception_handler_from_http_error(
3737
*,
3838
status_code: int,
3939
msg_template: str,
40-
) -> WebApiExceptionHandler:
40+
) -> AiohttpExceptionHandler:
4141
"""
4242
Custom Exception-Handler factory
4343
@@ -60,17 +60,14 @@ def create_exception_handler_from_http_error(
6060
async def _exception_handler(
6161
request: web.Request,
6262
exception: BaseException,
63-
) -> web.HTTPException | BaseException | None:
63+
) -> web.Response:
6464
assert isinstance(exception, exception_cls) # nosec
6565

6666
# safe formatting, i.e. does not raise
6767
user_msg = msg_template.format_map(
6868
_DefaultDict(getattr(exception, "__dict__", {}))
6969
)
7070

71-
http_error_cls = _STATUS_CODE_TO_HTTP_EXCEPTIONS[status_code]
72-
assert http_error_cls # nosec
73-
7471
if is_5xx_server_error(status_code):
7572
_logger.exception(
7673
**create_troubleshotting_log_kwargs(
@@ -84,7 +81,17 @@ async def _exception_handler(
8481
},
8582
)
8683
)
87-
return http_error_cls(reason=user_msg)
84+
85+
# TODO: make this part customizable? e.g. so we can inject e.g. oec?
86+
# TODO: connect with ErrorModel
87+
return web.json_response(
88+
{
89+
"error": {
90+
"msg": user_msg,
91+
}
92+
},
93+
status=status_code,
94+
)
8895

8996
return _exception_handler
9097

@@ -101,7 +108,7 @@ def _sort_exceptions_by_specificity(
101108

102109
def create_exception_handler_from_http_error_map(
103110
exc_to_http_error_map: ExceptionToHttpErrorMap,
104-
) -> WebApiExceptionHandler:
111+
) -> AiohttpExceptionHandler:
105112
"""
106113
Custom Exception-Handler factory
107114
@@ -126,7 +133,7 @@ def create_exception_handler_from_http_error_map(
126133
async def _exception_handler(
127134
request: web.Request,
128135
exception: BaseException,
129-
) -> web.HTTPError | BaseException | None:
136+
) -> web.Response:
130137
if exc_cls := next(
131138
(_ for _ in _catch_exceptions if isinstance(exception, _)), None
132139
):

services/web/server/tests/unit/isolated/test_exceptions_handlers.py

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from servicelib.aiohttp import status
1515
from simcore_service_webserver.errors import WebServerBaseError
1616
from simcore_service_webserver.exceptions_handlers import (
17+
ExceptionToHttpErrorMap,
1718
HttpErrorInfo,
1819
create_decorator_from_exception_handler,
1920
)
@@ -29,29 +30,29 @@
2930
# Some custom errors in my service
3031

3132

32-
class BasePluginError(WebServerBaseError):
33+
class BaseError(WebServerBaseError):
3334
...
3435

3536

36-
class OneError(BasePluginError):
37+
class OneError(BaseError):
3738
...
3839

3940

40-
class OtherError(BasePluginError):
41+
class OtherError(BaseError):
4142
...
4243

4344

4445
def test_sort_concrete_first():
45-
assert _sort_exceptions_by_specificity([Exception, BasePluginError]) == [
46-
BasePluginError,
46+
assert _sort_exceptions_by_specificity([Exception, BaseError]) == [
47+
BaseError,
4748
Exception,
4849
]
4950

5051
assert _sort_exceptions_by_specificity(
51-
[Exception, BasePluginError], concrete_first=False
52+
[Exception, BaseError], concrete_first=False
5253
) == [
5354
Exception,
54-
BasePluginError,
55+
BaseError,
5556
]
5657

5758

@@ -62,7 +63,7 @@ def test_sort_exceptions_by_specificity():
6263
Exception,
6364
OtherError,
6465
OneError,
65-
BasePluginError,
66+
BaseError,
6667
ValueError,
6768
ArithmeticError,
6869
ZeroDivisionError,
@@ -82,7 +83,6 @@ def fake_request() -> web.Request:
8283
async def test_factory__create_exception_handler_from_http_error(
8384
fake_request: web.Request,
8485
):
85-
8686
one_error_to_404 = create_exception_handler_from_http_error(
8787
OneError,
8888
status_code=status.HTTP_404_NOT_FOUND,
@@ -92,12 +92,14 @@ async def test_factory__create_exception_handler_from_http_error(
9292
# calling exception handler
9393
caught = OneError()
9494
response = await one_error_to_404(fake_request, caught)
95-
assert response
9695
assert response.status == status.HTTP_404_NOT_FOUND
97-
assert "one error" in response.text
96+
assert response.text is not None
97+
assert "one error message" in response.text
9898

9999

100-
async def test_create_exception_handler_from_http_error_map(fake_request: web.Request):
100+
async def test_factory__create_exception_handler_from_http_error_map(
101+
fake_request: web.Request,
102+
):
101103

102104
# call exception handler factory
103105
exc_handler = create_exception_handler_from_http_error_map(
@@ -109,8 +111,10 @@ async def test_create_exception_handler_from_http_error_map(fake_request: web.Re
109111
)
110112

111113
# Converts exception in map
112-
got_exc = await exc_handler(fake_request, OneError())
114+
with pytest.raises(web.HTTPBadRequest) as exc_info:
115+
await exc_handler(fake_request, OneError())
113116

117+
got_exc = exc_info.value
114118
assert isinstance(got_exc, web.HTTPBadRequest)
115119
assert got_exc.reason == "Error One mapped to 400"
116120

@@ -120,46 +124,53 @@ async def test_create_exception_handler_from_http_error_map(fake_request: web.Re
120124

121125

122126
async def test__handled_exception_context_manager(fake_request: web.Request):
123-
async def _suppress_handler(request, exception):
127+
128+
expected_response = web.json_response({"error": {"msg": "Foo"}})
129+
130+
async def _custom_handler(request, exception):
124131
assert request == fake_request
125132
assert isinstance(
126-
exception, BasePluginError
133+
exception, BaseError
127134
), "only BasePluginError exceptions should call this handler"
128-
return None # noqa: RET501, PLR1711
135+
return expected_response
129136

130-
async def _fun(raises):
131-
async with _handled_exception_context_manager(
132-
BasePluginError, _suppress_handler, request=fake_request
133-
):
134-
raise raises
137+
exc_handling_ctx = _handled_exception_context_manager(
138+
BaseError, _custom_handler, request=fake_request
139+
)
140+
141+
# handles any BaseError returning a response
142+
async with exc_handling_ctx as ctx:
143+
raise OneError
144+
assert ctx.response == expected_response
135145

136-
# checks
137-
await _fun(raises=OneError)
138-
await _fun(raises=OtherError)
146+
async with exc_handling_ctx as ctx:
147+
raise OtherError
148+
assert ctx.response == expected_response
139149

150+
# otherwise thru
140151
with pytest.raises(ArithmeticError):
141-
await _fun(raises=ArithmeticError)
152+
async with exc_handling_ctx:
153+
raise ArithmeticError
142154

143155

144156
async def test_create_decorator_from_exception_handler(
145157
caplog: pytest.LogCaptureFixture,
146158
):
159+
# Create an SINGLE exception handler that acts as umbrella for all these exceptions
160+
http_error_map: ExceptionToHttpErrorMap = {
161+
OneError: HttpErrorInfo(
162+
status.HTTP_503_SERVICE_UNAVAILABLE,
163+
"Human readable error transmitted to the front-end",
164+
)
165+
}
147166

148-
exc_handler = create_exception_handler_from_http_error_map(
149-
{
150-
OneError: HttpErrorInfo(
151-
status.HTTP_503_SERVICE_UNAVAILABLE,
152-
"Human readable error transmitted to the front-end",
153-
)
154-
}
155-
)
156-
157-
_handle_exceptions = create_decorator_from_exception_handler(
158-
exception_types=BasePluginError, # <--- FIXME" this is redundant because exception has been already passed in exc_handler!
167+
exc_handler = create_exception_handler_from_http_error_map(http_error_map)
168+
_exc_handling_ctx = create_decorator_from_exception_handler(
169+
exception_types=BaseError, # <--- FIXME" this is redundant because exception has been already passed in exc_handler!
159170
exception_handler=exc_handler,
160171
)
161172

162-
@_handle_exceptions
173+
@_exc_handling_ctx
163174
async def _rest_handler(request: web.Request) -> web.Response:
164175
if request.query.get("raise") == "OneError":
165176
raise OneError
@@ -193,8 +204,9 @@ async def _rest_handler(request: web.Request) -> web.Response:
193204
assert resp.status == status.HTTP_503_SERVICE_UNAVAILABLE
194205
assert "front-end" in resp.reason
195206

196-
assert caplog.records
207+
assert caplog.records, "Expected 5XX troubleshooting logged as error"
197208
assert caplog.records[0].levelno == logging.ERROR
209+
198210
# typically capture by last
199211
with pytest.raises(ArithmeticError):
200212
resp = await _rest_handler(

0 commit comments

Comments
 (0)