Skip to content
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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
131fb7b
new model
pcrespov Nov 27, 2024
835b190
using new model
pcrespov Nov 27, 2024
4bc45e8
logging
pcrespov Nov 27, 2024
1a5dee9
minor
pcrespov Nov 27, 2024
a8aaea0
oas
pcrespov Nov 27, 2024
e5abec1
oas
pcrespov Nov 27, 2024
15adfaf
error enveloped
pcrespov Nov 27, 2024
4a7a3ed
fix
pcrespov Nov 27, 2024
e604522
updates error envelope
pcrespov Nov 27, 2024
b1d545a
fixes agent
pcrespov Nov 27, 2024
ee8fd6c
mypy
pcrespov Nov 27, 2024
dd83c48
exception handlers
pcrespov Nov 1, 2024
78e868a
slowly conforming to fastapi
pcrespov Nov 1, 2024
b82a939
compiling old ideas
pcrespov Nov 1, 2024
25e1b75
split and renamed
pcrespov Nov 22, 2024
2edc790
WIP: base2
pcrespov Nov 22, 2024
35eef01
cleanup
pcrespov Nov 22, 2024
4592a70
cleanup
pcrespov Nov 22, 2024
aa03686
cleanup
pcrespov Nov 25, 2024
32b9769
exceptions handlers async
pcrespov Nov 25, 2024
605c41a
handler return json.response
pcrespov Nov 25, 2024
5b62541
json-error
pcrespov Nov 25, 2024
34c7dc2
args
pcrespov Nov 26, 2024
d3894f2
naming
pcrespov Nov 26, 2024
01f03e5
refactor concept
pcrespov Nov 26, 2024
bdb9010
base ready
pcrespov Nov 26, 2024
f760de3
maps
pcrespov Nov 26, 2024
89a6058
tests
pcrespov Nov 26, 2024
a1dcd60
using new functions
pcrespov Nov 26, 2024
7a25fd4
renames
pcrespov Nov 26, 2024
8cd9589
doc
pcrespov Nov 27, 2024
b55506b
doc
pcrespov Nov 27, 2024
c8649b3
rename
pcrespov Nov 27, 2024
d94761c
rename
pcrespov Nov 27, 2024
5d03bfb
tests usage decorators
pcrespov Nov 27, 2024
047f0c5
rest errors
pcrespov Nov 27, 2024
112922e
rename
pcrespov Nov 27, 2024
1347afa
cleanup
pcrespov Nov 27, 2024
5dd1296
drop base2
pcrespov Nov 27, 2024
978e105
cleanup
pcrespov Nov 27, 2024
750c472
mypy
pcrespov Nov 27, 2024
d83a587
cleanup
pcrespov Nov 27, 2024
3a4d800
eneveloped error
pcrespov Nov 27, 2024
f46e52e
fixes
pcrespov Nov 27, 2024
91bc1fc
updates workslpaces
pcrespov Nov 27, 2024
d944f07
wront import
pcrespov Nov 27, 2024
5ad382f
fixes tests
pcrespov Nov 27, 2024
c53b7bf
updates oas
pcrespov Nov 27, 2024
9bb482d
Merge branch 'master' into mai/aihttp-exception-handlers
pcrespov Nov 28, 2024
135e3ce
fixes merge
pcrespov Nov 28, 2024
382d94c
cleanup
pcrespov Nov 28, 2024
3e4ddd9
cleanup
pcrespov Nov 28, 2024
c275789
Merge branch 'master' into mai/aihttp-exception-handlers
pcrespov Dec 1, 2024
9fc06bc
@sanderegg review: ellipis
pcrespov Dec 1, 2024
9b10abb
@sanderegg review: doc
pcrespov Dec 1, 2024
3454d97
@sanderegg review: rename
pcrespov Dec 1, 2024
de74e86
@sanderegg @GitHK review: annotations
pcrespov Dec 1, 2024
429cbb8
@sanderegg review: avoid BaseExceptions
pcrespov Dec 1, 2024
aefd384
@sanderegg review: folder
pcrespov Dec 1, 2024
eacd769
mypy
pcrespov Dec 1, 2024
4705c81
cleanup
pcrespov Dec 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}).
Copy link
Contributor

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 what concrete_first does

"""
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]
Copy link
Contributor

Choose a reason for hiding this comment

The 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 🤔.
Python has a builtin way to resolve inherritance and you could use that to only traverse the super-types of an exception

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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Loading
Loading