Repository: litestar-org/litestar

Issue:
Bug: Exception handler leaks internal exceptions
### Discussed in https://github.com/orgs/litestar-org/discussions/3060

<div type='discussions-op-text'>

<sup>Originally posted by **floxay** February  1, 2024</sup>
I have posted this [on Discord](https://discord.com/channels/919193495116337154/919193495690936353/1197749178399465542) before but it did not really go anywhere.
As I am unsure whether this should be a bug or enhancement I post it now on Github as a discussion and then can be converted to either if needed. (This is basically the same message as the one on Discord, nothing new has been added just structured slightly differently.)

The [documented behavior of exception handling](https://docs.litestar.dev/latest/usage/exceptions.html#exception-handling) mentions:
> If the errors are ***instances of [HTTPException](https://docs.litestar.dev/latest/reference/exceptions.html#litestar.exceptions.HTTPException)***, the responses will include the appropriate status_code.

Which is incorrect; what actually is happening is that Litestar tries to retrieve the value of the `status_code` field/property, regardless whether the object is an instance of `HTTPException` or not.
Code; https://github.com/litestar-org/litestar/blob/24095740e7c12220a8ca5cc14766cf0ec95764ca/litestar/middleware/exceptions/middleware.py#L125-L127 Notes from the same function above:
https://github.com/litestar-org/litestar/blob/24095740e7c12220a8ca5cc14766cf0ec95764ca/litestar/middleware/exceptions/middleware.py#L113-L116 One of these is definitely incorrect, and I would guess it is the documentation that is incomplete.

Before the documentation just gets changed to reflect what actually is happening I would also like to point out that this behavior is problematic.
I only noticed this issue because I got an unhandled Elasticsearch error back from the backend stringified in the exception details:
![image](https://github.com/litestar-org/litestar/assets/57007485/de631fa3-c4f8-436d-9237-fa0f58407d45)
Related code: https://github.com/elastic/elasticsearch-py/blob/5014ce5337594f66040c81a2610220b1e8c0527e/elasticsearch/exceptions.py#L43-L46
This error also contains other information such as how (e.g. IP:port) the client is connected to elastic and headers which expose versions of used modules.
I do not think this kind of stuff generally should make it out to the client by default, debug mode was also not enabled.

For now I have added a custom exception handler for Elastic errors but on the long term this does not feel right, I would essentially need check out all external modules whether they have a `status_code` filed/property or not and if it would pose a problem.

I am not really sure what the right(?, better?, proper?) way to handle this would be, I've mentioned an exception class whitelist mechanic for the exception handler in the Discord comment as a potential solution but I imagine it would also affect the mounting of other ASGI apps, like Starlette apps and would be *very* breaking.</div>

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/3082">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3082/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3082/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/app.py b/litestar/app.py
--- a/litestar/app.py
+++ b/litestar/app.py
@@ -421,6 +421,15 @@ def __init__(
         if self.pdb_on_exception:
             warn_pdb_on_exception()
 
+        try:
+            from starlette.exceptions import HTTPException as StarletteHTTPException
+
+            from litestar.middleware.exceptions.middleware import _starlette_exception_handler
+
+            config.exception_handlers.setdefault(StarletteHTTPException, _starlette_exception_handler)
+        except ImportError:
+            pass
+
         super().__init__(
             after_request=config.after_request,
             after_response=config.after_response,
diff --git a/litestar/middleware/exceptions/middleware.py b/litestar/middleware/exceptions/middleware.py
--- a/litestar/middleware/exceptions/middleware.py
+++ b/litestar/middleware/exceptions/middleware.py
@@ -9,7 +9,7 @@
 
 from litestar.datastructures import Headers
 from litestar.enums import MediaType, ScopeType
-from litestar.exceptions import WebSocketException
+from litestar.exceptions import HTTPException, LitestarException, WebSocketException
 from litestar.middleware.cors import CORSMiddleware
 from litestar.middleware.exceptions._debug_response import _get_type_encoders_for_request, create_debug_response
 from litestar.serialization import encode_json
@@ -20,6 +20,8 @@
 
 
 if TYPE_CHECKING:
+    from starlette.exceptions import HTTPException as StarletteHTTPException
+
     from litestar import Response
     from litestar.app import Litestar
     from litestar.connection import Request
@@ -58,15 +60,16 @@ def get_exception_handler(exception_handlers: ExceptionHandlersMap, exc: Excepti
     if not exception_handlers:
         return None
 
-    status_code: int | None = getattr(exc, "status_code", None)
-    if status_code and (exception_handler := exception_handlers.get(status_code)):
-        return exception_handler
+    default_handler: ExceptionHandler | None = None
+    if isinstance(exc, HTTPException):
+        if exception_handler := exception_handlers.get(exc.status_code):
+            return exception_handler
+    else:
+        default_handler = exception_handlers.get(HTTP_500_INTERNAL_SERVER_ERROR)
 
     return next(
         (exception_handlers[cast("Type[Exception]", cls)] for cls in getmro(type(exc)) if cls in exception_handlers),
-        exception_handlers[HTTP_500_INTERNAL_SERVER_ERROR]
-        if not hasattr(exc, "status_code") and HTTP_500_INTERNAL_SERVER_ERROR in exception_handlers
-        else None,
+        default_handler,
     )
 
 
@@ -107,6 +110,17 @@ def to_response(self, request: Request | None = None) -> Response:
         )
 
 
+def _starlette_exception_handler(request: Request[Any, Any, Any], exc: StarletteHTTPException) -> Response:
+    return create_exception_response(
+        request=request,
+        exc=HTTPException(
+            detail=exc.detail,
+            status_code=exc.status_code,
+            headers=exc.headers,
+        ),
+    )
+
+
 def create_exception_response(request: Request[Any, Any, Any], exc: Exception) -> Response:
     """Construct a response from an exception.
 
@@ -122,11 +136,23 @@ def create_exception_response(request: Request[Any, Any, Any], exc: Exception) -
     Returns:
         Response: HTTP response constructed from exception details.
     """
-    status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
-    if status_code == HTTP_500_INTERNAL_SERVER_ERROR:
-        detail = "Internal Server Error"
+    headers: dict[str, Any] | None
+    extra: dict[str, Any] | list | None
+
+    if isinstance(exc, HTTPException):
+        status_code = exc.status_code
+        headers = exc.headers
+        extra = exc.extra
     else:
-        detail = getattr(exc, "detail", repr(exc))
+        status_code = HTTP_500_INTERNAL_SERVER_ERROR
+        headers = None
+        extra = None
+
+    detail = (
+        exc.detail
+        if isinstance(exc, LitestarException) and status_code != HTTP_500_INTERNAL_SERVER_ERROR
+        else "Internal Server Error"
+    )
 
     try:
         media_type = request.route_handler.media_type
@@ -136,8 +162,8 @@ def create_exception_response(request: Request[Any, Any, Any], exc: Exception) -
     content = ExceptionResponseContent(
         status_code=status_code,
         detail=detail,
-        headers=getattr(exc, "headers", None),
-        extra=getattr(exc, "extra", None),
+        headers=headers,
+        extra=extra,
         media_type=media_type,
     )
     return content.to_response(request=request)
@@ -246,12 +272,13 @@ async def handle_websocket_exception(send: Send, exc: Exception) -> None:
         Returns:
             None.
         """
+        code = 4000 + HTTP_500_INTERNAL_SERVER_ERROR
+        reason = "Internal Server Error"
         if isinstance(exc, WebSocketException):
             code = exc.code
             reason = exc.detail
-        else:
-            code = 4000 + getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
-            reason = getattr(exc, "detail", repr(exc))
+        elif isinstance(exc, LitestarException):
+            reason = exc.detail
 
         event: WebSocketCloseEvent = {"type": "websocket.close", "code": code, "reason": reason}
         await send(event)
@@ -266,7 +293,7 @@ def default_http_exception_handler(self, request: Request, exc: Exception) -> Re
         Returns:
             An HTTP response.
         """
-        status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR)
+        status_code = exc.status_code if isinstance(exc, HTTPException) else HTTP_500_INTERNAL_SERVER_ERROR
         if status_code == HTTP_500_INTERNAL_SERVER_ERROR and self._get_debug_scope(request.scope):
             return create_debug_response(request=request, exc=exc)
         return create_exception_response(request=request, exc=exc)


Test patch:
diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py
--- a/tests/unit/test_app.py
+++ b/tests/unit/test_app.py
@@ -164,15 +164,7 @@ def test_app_config_object_used(app_config_object: AppConfig, monkeypatch: pytes
     # have been accessed during app instantiation.
     property_mocks: List[Tuple[str, Mock]] = []
     for field in fields(AppConfig):
-        if field.name == "response_cache_config":
-            property_mock = PropertyMock(return_value=ResponseCacheConfig())
-        if field.name in ["event_emitter_backend", "response_cache_config"]:
-            property_mock = PropertyMock(return_value=Mock())
-        else:
-            # default iterable return value allows the mock properties that need to be iterated over in
-            # `Litestar.__init__()` to not blow up, for other properties it shouldn't matter what the value is for the
-            # sake of this test.
-            property_mock = PropertyMock(return_value=[])
+        property_mock = PropertyMock()
         property_mocks.append((field.name, property_mock))
         monkeypatch.setattr(type(app_config_object), field.name, property_mock, raising=False)
 
diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py
--- a/tests/unit/test_exceptions.py
+++ b/tests/unit/test_exceptions.py
@@ -111,15 +111,13 @@ def test_create_exception_response_utility_litestar_http_exception(media_type: M
 
 @pytest.mark.parametrize("media_type", [MediaType.JSON, MediaType.TEXT])
 def test_create_exception_response_utility_starlette_http_exception(media_type: MediaType) -> None:
-    exc = StarletteHTTPException(detail="starlette http exception", status_code=HTTP_400_BAD_REQUEST)
-    request = RequestFactory(handler_kwargs={"media_type": media_type}).get()
-    response = create_exception_response(request=request, exc=exc)
-    assert response.status_code == HTTP_400_BAD_REQUEST
-    assert response.media_type == media_type
-    if media_type == MediaType.JSON:
-        assert response.content == {"status_code": 400, "detail": "starlette http exception"}
-    else:
-        assert response.content == b'{"status_code":400,"detail":"starlette http exception"}'
+    @get("/", media_type=media_type)
+    def handler() -> str:
+        raise StarletteHTTPException(status_code=400)
+
+    with create_test_client(handler) as client:
+        response = client.get("/", headers={"Accept": media_type})
+        assert response.json() == {"status_code": 400, "detail": "Bad Request"}
 
 
 @pytest.mark.parametrize("media_type", [MediaType.JSON, MediaType.TEXT])
@@ -171,3 +169,30 @@ def handler() -> None:
             assert response.json().get("details").startswith("Traceback (most recent call last")
         else:
             assert response.text.startswith("Traceback (most recent call last")
+
+
+def test_non_litestar_exception_with_status_code_is_500() -> None:
+    # https://github.com/litestar-org/litestar/issues/3082
+    class MyException(Exception):
+        status_code: int = 400
+
+    @get("/")
+    def handler() -> None:
+        raise MyException("hello")
+
+    with create_test_client([handler]) as client:
+        assert client.get("/").status_code == 500
+
+
+def test_non_litestar_exception_with_detail_is_not_included() -> None:
+    # https://github.com/litestar-org/litestar/issues/3082
+    class MyException(Exception):
+        status_code: int = 400
+        detail: str = "hello"
+
+    @get("/")
+    def handler() -> None:
+        raise MyException()
+
+    with create_test_client([handler], debug=False) as client:
+        assert client.get("/", headers={"Accept": MediaType.JSON}).json().get("detail") == "Internal Server Error"
diff --git a/tests/unit/test_middleware/test_exception_handler_middleware.py b/tests/unit/test_middleware/test_exception_handler_middleware.py
--- a/tests/unit/test_middleware/test_exception_handler_middleware.py
+++ b/tests/unit/test_middleware/test_exception_handler_middleware.py
@@ -12,7 +12,7 @@
 from litestar.logging.config import LoggingConfig, StructLoggingConfig
 from litestar.middleware.exceptions import ExceptionHandlerMiddleware
 from litestar.middleware.exceptions._debug_response import get_symbol_name
-from litestar.middleware.exceptions.middleware import get_exception_handler
+from litestar.middleware.exceptions.middleware import _starlette_exception_handler, get_exception_handler
 from litestar.status_codes import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR
 from litestar.testing import TestClient, create_test_client
 from litestar.types import ExceptionHandlersMap
@@ -124,7 +124,8 @@ def exception_handler(request: Request, exc: Exception) -> Response:
 
     app = Litestar(route_handlers=[handler], exception_handlers={Exception: exception_handler}, openapi_config=None)
     assert app.asgi_router.root_route_map_node.children["/"].asgi_handlers["GET"][0].exception_handlers == {  # type: ignore
-        Exception: exception_handler
+        Exception: exception_handler,
+        StarletteHTTPException: _starlette_exception_handler,
     }