Skip to content

Commit 8a317f9

Browse files
authored
Merge branch 'master' into feature/vip-store
2 parents e91a2d3 + 3dded08 commit 8a317f9

File tree

12 files changed

+816
-239
lines changed

12 files changed

+816
-239
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from ._base import ExceptionHandlersMap, exception_handling_decorator
2+
from ._factory import ExceptionToHttpErrorMap, HttpErrorInfo, to_exceptions_handlers_map
3+
4+
__all__: tuple[str, ...] = (
5+
"ExceptionHandlersMap",
6+
"ExceptionToHttpErrorMap",
7+
"HttpErrorInfo",
8+
"exception_handling_decorator",
9+
"to_exceptions_handlers_map",
10+
)
11+
12+
# nopycln: file
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import functools
2+
import logging
3+
from collections.abc import Callable, Iterable
4+
from contextlib import AbstractAsyncContextManager
5+
from types import TracebackType
6+
from typing import Protocol, TypeAlias
7+
8+
from aiohttp import web
9+
from servicelib.aiohttp.typing_extension import Handler as WebHandler
10+
from servicelib.aiohttp.typing_extension import Middleware as WebMiddleware
11+
12+
_logger = logging.getLogger(__name__)
13+
14+
15+
class AiohttpExceptionHandler(Protocol):
16+
__name__: str
17+
18+
async def __call__(
19+
self,
20+
request: web.Request,
21+
exception: Exception,
22+
) -> web.StreamResponse:
23+
"""
24+
Callback that handles an exception produced during a request and transforms it into a response
25+
26+
Arguments:
27+
request -- current request
28+
exception -- exception raised in web handler during this request
29+
"""
30+
31+
32+
ExceptionHandlersMap: TypeAlias = dict[type[Exception], AiohttpExceptionHandler]
33+
34+
35+
def _sort_exceptions_by_specificity(
36+
exceptions: Iterable[type[Exception]], *, concrete_first: bool = True
37+
) -> list[type[Exception]]:
38+
"""
39+
Keyword Arguments:
40+
concrete_first -- If True, concrete subclasses precede their superclass (default: {True}).
41+
"""
42+
return sorted(
43+
exceptions,
44+
key=lambda exc: sum(issubclass(e, exc) for e in exceptions if e is not exc),
45+
reverse=not concrete_first,
46+
)
47+
48+
49+
class ExceptionHandlingContextManager(AbstractAsyncContextManager):
50+
"""
51+
A dynamic try-except context manager for handling exceptions in web handlers.
52+
Maps exception types to corresponding handlers, allowing structured error management, i.e.
53+
essentially something like
54+
```
55+
try:
56+
57+
resp = await handler(request)
58+
59+
except exc_type1 as exc1:
60+
resp = await exc_handler1(request)
61+
except exc_type2 as exc1:
62+
resp = await exc_handler2(request)
63+
# etc
64+
65+
```
66+
and `exception_handlers_map` defines the mapping of exception types (`exc_type*`) to their handlers (`exc_handler*`).
67+
"""
68+
69+
def __init__(
70+
self,
71+
exception_handlers_map: ExceptionHandlersMap,
72+
*,
73+
request: web.Request,
74+
):
75+
self._exc_handlers_map = exception_handlers_map
76+
self._exc_types_by_specificity = _sort_exceptions_by_specificity(
77+
list(self._exc_handlers_map.keys()), concrete_first=True
78+
)
79+
self._request: web.Request = request
80+
self._response: web.StreamResponse | None = None
81+
82+
def _get_exc_handler_or_none(
83+
self, exc_type: type[Exception], exc_value: Exception
84+
) -> AiohttpExceptionHandler | None:
85+
exc_handler = self._exc_handlers_map.get(exc_type)
86+
if not exc_handler and (
87+
base_exc_type := next(
88+
(
89+
_type
90+
for _type in self._exc_types_by_specificity
91+
if isinstance(exc_value, _type)
92+
),
93+
None,
94+
)
95+
):
96+
exc_handler = self._exc_handlers_map[base_exc_type]
97+
return exc_handler
98+
99+
async def __aenter__(self):
100+
self._response = None
101+
return self
102+
103+
async def __aexit__(
104+
self,
105+
exc_type: type[BaseException] | None,
106+
exc_value: BaseException | None,
107+
traceback: TracebackType | None,
108+
) -> bool:
109+
if (
110+
exc_value is not None
111+
and exc_type is not None
112+
and isinstance(exc_value, Exception)
113+
and issubclass(exc_type, Exception)
114+
and (exc_handler := self._get_exc_handler_or_none(exc_type, exc_value))
115+
):
116+
self._response = await exc_handler(
117+
request=self._request, exception=exc_value
118+
)
119+
return True # suppress
120+
return False # reraise
121+
122+
def get_response_or_none(self) -> web.StreamResponse | None:
123+
"""
124+
Returns the response generated by the exception handler, if an exception was handled. Otherwise None
125+
"""
126+
return self._response
127+
128+
129+
def exception_handling_decorator(
130+
exception_handlers_map: dict[type[Exception], AiohttpExceptionHandler]
131+
) -> Callable[[WebHandler], WebHandler]:
132+
"""Creates a decorator to manage exceptions raised in a given route handler.
133+
Ensures consistent exception management across decorated handlers.
134+
135+
SEE examples test_exception_handling
136+
"""
137+
138+
def _decorator(handler: WebHandler):
139+
@functools.wraps(handler)
140+
async def _wrapper(request: web.Request) -> web.StreamResponse:
141+
cm = ExceptionHandlingContextManager(
142+
exception_handlers_map, request=request
143+
)
144+
async with cm:
145+
return await handler(request)
146+
147+
# If an exception was handled, return the exception handler's return value
148+
response = cm.get_response_or_none()
149+
assert response is not None # nosec
150+
return response
151+
152+
return _wrapper
153+
154+
return _decorator
155+
156+
157+
def exception_handling_middleware(
158+
exception_handlers_map: dict[type[Exception], AiohttpExceptionHandler]
159+
) -> WebMiddleware:
160+
"""Constructs middleware to handle exceptions raised across app routes
161+
162+
SEE examples test_exception_handling
163+
"""
164+
_handle_excs = exception_handling_decorator(
165+
exception_handlers_map=exception_handlers_map
166+
)
167+
168+
@web.middleware
169+
async def middleware_handler(request: web.Request, handler: WebHandler):
170+
return await _handle_excs(handler)(request)
171+
172+
return middleware_handler
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import logging
2+
from typing import NamedTuple, TypeAlias
3+
4+
from aiohttp import web
5+
from common_library.error_codes import create_error_code
6+
from common_library.json_serialization import json_dumps
7+
from models_library.rest_error import ErrorGet
8+
from servicelib.aiohttp.web_exceptions_extension import get_all_aiohttp_http_exceptions
9+
from servicelib.logging_errors import create_troubleshotting_log_kwargs
10+
from servicelib.status_codes_utils import is_5xx_server_error, is_error
11+
12+
from ._base import AiohttpExceptionHandler, ExceptionHandlersMap
13+
14+
_logger = logging.getLogger(__name__)
15+
16+
17+
_STATUS_CODE_TO_HTTP_ERRORS: dict[
18+
int, type[web.HTTPError]
19+
] = get_all_aiohttp_http_exceptions(web.HTTPError)
20+
21+
22+
class _DefaultDict(dict):
23+
def __missing__(self, key):
24+
return f"'{key}=?'"
25+
26+
27+
class HttpErrorInfo(NamedTuple):
28+
"""Info provided to auto-create HTTPError"""
29+
30+
status_code: int
31+
msg_template: str # sets HTTPError.reason
32+
33+
34+
ExceptionToHttpErrorMap: TypeAlias = dict[type[Exception], HttpErrorInfo]
35+
36+
37+
def create_error_response(error: ErrorGet, status_code: int) -> web.Response:
38+
assert is_error(status_code), f"{status_code=} must be an error [{error=}]" # nosec
39+
40+
return web.json_response(
41+
data={"error": error.model_dump(exclude_unset=True, mode="json")},
42+
dumps=json_dumps,
43+
reason=error.message,
44+
status=status_code,
45+
)
46+
47+
48+
def create_exception_handler_from_http_info(
49+
status_code: int,
50+
msg_template: str,
51+
) -> AiohttpExceptionHandler:
52+
"""
53+
Custom Exception-Handler factory
54+
55+
Creates a custom `WebApiExceptionHandler` that maps specific exception to a given http status code error
56+
57+
Given an `ExceptionToHttpErrorMap`, this function returns a handler that checks if an exception
58+
matches one in the map, returning an HTTP error with the mapped status code and message.
59+
Server errors (5xx) include additional logging with request context. Unmapped exceptions are
60+
returned as-is for re-raising.
61+
62+
Arguments:
63+
status_code: the http status code to associate at the web-api interface to this error
64+
msg_template: a template string to pass to the HttpError
65+
66+
Returns:
67+
A web api exception handler
68+
"""
69+
assert is_error( # nosec
70+
status_code
71+
), f"{status_code=} must be an error [{msg_template=}]"
72+
73+
async def _exception_handler(
74+
request: web.Request,
75+
exception: BaseException,
76+
) -> web.Response:
77+
78+
# safe formatting, i.e. does not raise
79+
user_msg = msg_template.format_map(
80+
_DefaultDict(getattr(exception, "__dict__", {}))
81+
)
82+
83+
error = ErrorGet.model_construct(message=user_msg)
84+
85+
if is_5xx_server_error(status_code):
86+
oec = create_error_code(exception)
87+
_logger.exception(
88+
**create_troubleshotting_log_kwargs(
89+
user_msg,
90+
error=exception,
91+
error_code=oec,
92+
error_context={
93+
"request": request,
94+
"request.remote": f"{request.remote}",
95+
"request.method": f"{request.method}",
96+
"request.path": f"{request.path}",
97+
},
98+
)
99+
)
100+
error = ErrorGet.model_construct(message=user_msg, support_id=oec)
101+
102+
return create_error_response(error, status_code=status_code)
103+
104+
return _exception_handler
105+
106+
107+
def to_exceptions_handlers_map(
108+
exc_to_http_error_map: ExceptionToHttpErrorMap,
109+
) -> ExceptionHandlersMap:
110+
"""Data adapter to convert ExceptionToHttpErrorMap ot ExceptionHandlersMap, i.e.
111+
- from { exc_type: (status, msg), ... }
112+
- to { exc_type: callable, ... }
113+
"""
114+
exc_handlers_map: ExceptionHandlersMap = {
115+
exc_type: create_exception_handler_from_http_info(
116+
status_code=info.status_code, msg_template=info.msg_template
117+
)
118+
for exc_type, info in exc_to_http_error_map.items()
119+
}
120+
121+
return exc_handlers_map
122+
123+
124+
def create_http_error_exception_handlers_map() -> ExceptionHandlersMap:
125+
"""
126+
Auto create handlers for **all** web.HTTPError
127+
"""
128+
exc_handlers_map: ExceptionHandlersMap = {
129+
exc_type: create_exception_handler_from_http_info(
130+
status_code=code, msg_template="{reason}"
131+
)
132+
for code, exc_type in _STATUS_CODE_TO_HTTP_ERRORS.items()
133+
}
134+
return exc_handlers_map

0 commit comments

Comments
 (0)