-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
🎨 web-server: exception handling framework #6655
Changes from all commits
131fb7b
835b190
4bc45e8
1a5dee9
a8aaea0
e5abec1
15adfaf
4a7a3ed
e604522
b1d545a
ee8fd6c
dd83c48
78e868a
b82a939
25e1b75
2edc790
35eef01
4592a70
aa03686
32b9769
605c41a
5b62541
34c7dc2
d3894f2
01f03e5
bdb9010
f760de3
89a6058
a1dcd60
7a25fd4
8cd9589
b55506b
c8649b3
d94761c
5d03bfb
047f0c5
112922e
1347afa
5dd1296
978e105
750c472
d83a587
3a4d800
f46e52e
91bc1fc
d944f07
5ad382f
c53b7bf
9bb482d
135e3ce
382d94c
3e4ddd9
c275789
9fc06bc
9b10abb
3454d97
de74e86
429cbb8
aefd384
eacd769
4705c81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know I am late to the party (sorry). But I am wondering if you profit from the sorting and traversing the exception types 🤔. exc_handler = None
for type_ in exc_type.__mro__:
if exc_handler := self._exc_handlers_map.get(type_):
break
return exc_handler |
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Starlette/FastAPI approach to exception handlers (using this type of middleware) is quite elegant, but what always confuses me is that when I raise somewhere I don't see if and where that exception will be handled. For that I have to manually "follow" the exception up until I see it reaches the middleware. Perhaps it would make sense to have an easy way to test that a handler for a specific exception type is already handled. And maybe even ensure you cannot add two handlers for the same exception type. But I guess this is closely related to the general question of the python exception mechanism: I cannot know what a given function will raise unless I look into the code. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would have named this
subclasses_first
. It is not obvious to me whatconcrete_first
does