Repository: litestar-org/litestar

Issue:
Bug: `HTMXRequest` not passed to exception handlers, only gets access to standard `Request` object
### Description

I am trying to handle exceptions caused during HTMX requests.  But it looks like the exception handler only receives a standard `request` object, even when the routes are set to receive an `HTMXRequest`.

In the exception handler, the only flag I can find to indicate it's an HTMX request is `request._headers.get("hx-request")`.  We lose context on all other HTMX attributes in the request, which makes it difficult to troubleshoot and handle nicely.

Having access to the original HTMX request attributes can improve the flexibility of exception handling for these cases. 

### URL to code causing the issue

_No response_

### MCVE

```python
import logging
from typing import Callable

from litestar import Litestar, MediaType, Request, Response, get
from litestar.contrib.htmx.request import HTMXRequest
from litestar.contrib.htmx.response import HTMXTemplate
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.response import Template
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
from litestar.template import TemplateConfig

logger = logging.getLogger("litestar")

html = """
            <html>
                <head>
                    <script src="https://unpkg.com/htmx.org@1.9.6"></script>
                </head>
                <body>
                    <button hx-get="/htmx">Trigger HTMX</button>
                </body>
            </html>
        """


def http_500(request: Request | HTMXRequest, exc: Exception) -> Template:
    """Something broke, lets fix it"""
    request.logger.exception(f"🚨 500 ERROR: something broke on {request.url}: {exc}")

    meta = {
        "title": "500 Error... something broke bad",
        "description": "Sorry about this, we'll work on it",
    }
    return Template(
        template_name="errors/500.html.j2",
        context={
            "request": request,
            "meta": meta,
        },
    )


exception_config: dict[int | type[Exception], Callable] = {
    HTTP_500_INTERNAL_SERVER_ERROR: http_500,
}


@get("/")
async def serveHomepage(request: Request) -> Response:
    return Response(html, media_type=MediaType.HTML)


@get("/htmx")
async def serveHTMXResponse(request: HTMXRequest) -> Template:
    break_here = 1 / 0
    request.logger.info(break_here)

    return HTMXTemplate(template_name="Some HTMX Template")


app = Litestar(
    route_handlers=[serveHomepage, serveHTMXResponse],
    request_class=HTMXRequest,
    debug=True,
    exception_handlers=exception_config,
    template_config=TemplateConfig(
        directory="src/templates", engine=JinjaTemplateEngine
    ),
)
```


### Steps to reproduce

```bash
1. Load up the MRE in your favorite IDE
2. Add a breakpoint inside the `http_500` function
3. Run the app in debug mode
4. Open the homepage and click the button.  It should automatically trigger a DivisionByZero exception
5. Your IDE should break in the `http_500` function.
6. Inspect the `request` object.  If it was an `HTMXRequest`, the object type would be `litestar.contrib.htmx.request.HTMXDetails` and you would see attributes for `request.htmx`.
```


### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

Litestar `2.1.1 ` and Python `3.11.5`

### Platform

- [ ] Linux
- [X] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---

## Funding
* If you would like to see an issue prioritized, make a pledge towards it!
* We receive the pledge once the issue is completed & verified

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



Discussion:

Resolving patch:
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
@@ -7,7 +7,6 @@
 from traceback import format_exception
 from typing import TYPE_CHECKING, Any, Type, cast
 
-from litestar.connection import Request
 from litestar.datastructures import Headers
 from litestar.enums import MediaType, ScopeType
 from litestar.exceptions import WebSocketException
@@ -23,6 +22,7 @@
 if TYPE_CHECKING:
     from litestar import Response
     from litestar.app import Litestar
+    from litestar.connection import Request
     from litestar.logging import BaseLoggingConfig
     from litestar.types import (
         ASGIApp,
@@ -230,7 +230,7 @@ async def handle_request_exception(
             send = cors_middleware.send_wrapper(send=send, origin=origin, has_cookie="cookie" in headers)
 
         exception_handler = get_exception_handler(self.exception_handlers, exc) or self.default_http_exception_handler
-        request = Request[Any, Any, Any](scope=scope, receive=receive, send=send)
+        request: Request[Any, Any, Any] = litestar_app.request_class(scope=scope, receive=receive, send=send)
         response = exception_handler(request, exc)
         await response.to_asgi_response(app=None, request=request)(scope=scope, receive=receive, send=send)
 


Test patch:
diff --git a/tests/e2e/test_exception_handlers.py b/tests/e2e/test_exception_handlers.py
--- a/tests/e2e/test_exception_handlers.py
+++ b/tests/e2e/test_exception_handlers.py
@@ -67,3 +67,20 @@ def my_handler(self) -> None:
         response = client.get("/base/test/")
         assert response.status_code == exc_to_raise.status_code, response.json()
         assert caller["name"] == expected_layer
+
+
+def test_exception_handler_with_custom_request_class() -> None:
+    class CustomRequest(Request):
+        ...
+
+    def handler(req: Request, exc: Exception) -> Response:
+        assert isinstance(req, CustomRequest)
+
+        return Response(content={})
+
+    @get()
+    async def index() -> None:
+        _ = 1 / 0
+
+    with create_test_client([index], exception_handlers={Exception: handler}, request_class=CustomRequest) as client:
+        client.get("/")