Repository: litestar-org/litestar

Issue:
Bug: HTTPExceptions are not being handled correctly when dependency processing is offloaded to a TaskGroup
### Description

I have an application which uses heavily nested dependencies. Many of those dependencies have validation logic which throws HTTPExceptions so that users can update their requests and try again.

I am upgrading this from Starlite v1 and have hit an issue with how dependencies are handled when nesting occurs. It appears nested dependencies are processed concurrently within an `anyio.TaskGroup` instance. If any exception occurs in [This instance of create_task_group](https://github.com/litestar-org/litestar/blob/v2.2.1/litestar/_kwargs/kwargs_model.py#L393), they all result in a `GroupException`. Because of this, any HTTPExceptions thrown from inside the dependency logic gets yeeted into the void and the requester never sees it.

### URL to code causing the issue

_No response_

### MCVE

```python
from litestar import Litestar
from litestar.di import Provide
from litestar.handlers import get
from litestar.exceptions import HTTPException


def get_dependency_child1() -> str:
    raise HTTPException(detail="No sir, I don't like it.", status_code=400)


def get_dependency_child2() -> str:
    return "OK"


def get_dependency_parent(
    dependency_child1: str,
    dependency_child2: str,
) -> str:
    return dependency_child1 + dependency_child2


@get(
    path="/",
    dependencies={
        "dependency_child1": Provide(get_dependency_child1, sync_to_thread=False),
        "dependency_child2": Provide(get_dependency_child2, sync_to_thread=False),
        "dependency_parent": Provide(get_dependency_parent, sync_to_thread=False),
    },
)
def route(dependency_parent: str) -> str:
    return dependency_parent


app = Litestar(route_handlers=[route], debug=True)
```


### Steps to reproduce

_No response_

### Screenshots

```bash
"![SCREENSHOT_DESCRIPTION](SCREENSHOT_LINK.png)"
```


### Logs

```bash
ERROR - 2023-11-01 15:58:04,508 - litestar - config - exception raised on http connection to route /

  + Exception Group Traceback (most recent call last):
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/middleware/exceptions/middleware.py", line 191, in __call__
  |     await self.app(scope, receive, send)
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 81, in handle
  |     response = await self._get_response_for_request(
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
  |     return await self._call_handler_function(
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 153, in _call_handler_function
  |     response_data, cleanup_group = await self._get_response_data(
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 185, in _get_response_data
  |     cleanup_group = await parameter_model.resolve_dependencies(request, kwargs)
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/_kwargs/kwargs_model.py", line 393, in resolve_dependencies
  |     async with create_task_group() as task_group:
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 664, in __aexit__
  |     raise BaseExceptionGroup(
  | exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/_kwargs/dependencies.py", line 66, in resolve_dependency
    |     value = await dependency.provide(**dependency_kwargs)
    |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/di.py", line 84, in __call__
    |     value = self.dependency.value(**kwargs)
    |   File "/tmp/litestarTest/di_test_app.py", line 8, in get_dependency_child1
    |     raise HTTPException(detail="No sir, I don't like it.", status_code=400)
    | litestar.exceptions.http_exceptions.HTTPException: 400: No sir, I don't like it.
    +------------------------------------
  + Exception Group Traceback (most recent call last):
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/middleware/exceptions/middleware.py", line 191, in __call__
  |     await self.app(scope, receive, send)
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 81, in handle
  |     response = await self._get_response_for_request(
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 133, in _get_response_for_request
  |     return await self._call_handler_function(
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 153, in _call_handler_function
  |     response_data, cleanup_group = await self._get_response_data(
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/routes/http.py", line 185, in _get_response_data
  |     cleanup_group = await parameter_model.resolve_dependencies(request, kwargs)
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/_kwargs/kwargs_model.py", line 393, in resolve_dependencies
  |     async with create_task_group() as task_group:
  |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 664, in __aexit__
  |     raise BaseExceptionGroup(
  | exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/_kwargs/dependencies.py", line 66, in resolve_dependency
    |     value = await dependency.provide(**dependency_kwargs)
    |   File "/tmp/litestarTest/.venv/lib/python3.10/site-packages/litestar/di.py", line 84, in __call__
    |     value = self.dependency.value(**kwargs)
    |   File "/tmp/litestarTest/di_test_app.py", line 8, in get_dependency_child1
    |     raise HTTPException(detail="No sir, I don't like it.", status_code=400)
    | litestar.exceptions.http_exceptions.HTTPException: 400: No sir, I don't like it.
    +------------------------------------
INFO:     127.0.0.1:54512 - "GET / HTTP/1.1" 500 Internal Server Error
```


### Litestar Version

2.2.1

### Platform

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

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



Discussion:
> yeeted into the void 

😂 

Thanks for reporting!
> > yeeted into the void
> 
> 😂
> 
> Thanks for reporting!

Yeah, we should avoid such void-yeeting. 

Probably want to make sure things get properly re-raised.



Resolving patch:
diff --git a/litestar/_kwargs/kwargs_model.py b/litestar/_kwargs/kwargs_model.py
--- a/litestar/_kwargs/kwargs_model.py
+++ b/litestar/_kwargs/kwargs_model.py
@@ -34,6 +34,7 @@
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.params import BodyKwarg, ParameterKwarg
 from litestar.typing import FieldDefinition
+from litestar.utils.helpers import get_exception_group
 
 __all__ = ("KwargsModel",)
 
@@ -45,6 +46,8 @@
     from litestar.dto import AbstractDTO
     from litestar.utils.signature import ParsedSignature
 
+_ExceptionGroup = get_exception_group()
+
 
 class KwargsModel:
     """Model required kwargs for a given RouteHandler and its dependencies.
@@ -390,9 +393,13 @@ async def resolve_dependencies(self, connection: ASGIConnection, kwargs: dict[st
             if len(batch) == 1:
                 await resolve_dependency(next(iter(batch)), connection, kwargs, cleanup_group)
             else:
-                async with create_task_group() as task_group:
-                    for dependency in batch:
-                        task_group.start_soon(resolve_dependency, dependency, connection, kwargs, cleanup_group)
+                try:
+                    async with create_task_group() as task_group:
+                        for dependency in batch:
+                            task_group.start_soon(resolve_dependency, dependency, connection, kwargs, cleanup_group)
+                except _ExceptionGroup as excgroup:
+                    raise excgroup.exceptions[0] from excgroup  # type: ignore[attr-defined]
+
         return cleanup_group
 
     @classmethod
diff --git a/litestar/utils/helpers.py b/litestar/utils/helpers.py
--- a/litestar/utils/helpers.py
+++ b/litestar/utils/helpers.py
@@ -91,3 +91,13 @@ def unique_name_for_scope(base_name: str, scope: Container[str]) -> str:
         if (unique_name := f"{base_name}_{i}") not in scope:
             return unique_name
         i += 1
+
+
+def get_exception_group() -> type[BaseException]:
+    """Get the exception group class with version compatibility."""
+    try:
+        return cast("type[BaseException]", ExceptionGroup)  # type:ignore[name-defined]
+    except NameError:
+        from exceptiongroup import ExceptionGroup as _ExceptionGroup
+
+        return cast("type[BaseException]", _ExceptionGroup)


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -51,13 +51,3 @@ def maybe_async_cm(obj: ContextManager[T] | AsyncContextManager[T]) -> AsyncCont
     if isinstance(obj, AbstractContextManager):
         return cast(AsyncContextManager[T], _AsyncContextManagerWrapper(obj))
     return obj
-
-
-def get_exception_group() -> type[BaseException]:
-    """Get the exception group class with version compatibility."""
-    try:
-        return cast("type[BaseException]", ExceptionGroup)  # type:ignore[name-defined]
-    except NameError:
-        from exceptiongroup import ExceptionGroup as _ExceptionGroup
-
-        return _ExceptionGroup
diff --git a/tests/unit/test_asgi_router.py b/tests/unit/test_asgi_router.py
--- a/tests/unit/test_asgi_router.py
+++ b/tests/unit/test_asgi_router.py
@@ -11,7 +11,7 @@
 from litestar._asgi.asgi_router import ASGIRouter
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.testing import TestClient, create_test_client
-from tests.helpers import get_exception_group
+from litestar.utils.helpers import get_exception_group
 
 if TYPE_CHECKING:
     from contextlib import AbstractAsyncContextManager
diff --git a/tests/unit/test_kwargs/test_dependency_batches.py b/tests/unit/test_kwargs/test_dependency_batches.py
--- a/tests/unit/test_kwargs/test_dependency_batches.py
+++ b/tests/unit/test_kwargs/test_dependency_batches.py
@@ -4,6 +4,10 @@
 
 from litestar._kwargs.dependencies import Dependency, create_dependency_batches
 from litestar.di import Provide
+from litestar.exceptions import HTTPException, ValidationException
+from litestar.handlers import get
+from litestar.status_codes import HTTP_400_BAD_REQUEST, HTTP_422_UNPROCESSABLE_ENTITY, HTTP_500_INTERNAL_SERVER_ERROR
+from litestar.testing import create_test_client
 
 
 async def dummy() -> None:
@@ -56,3 +60,43 @@ async def dummy() -> None:
 def test_dependency_batches(dependency_tree: Set[Dependency], expected_batches: List[Set[Dependency]]) -> None:
     calculated_batches = create_dependency_batches(dependency_tree)
     assert calculated_batches == expected_batches
+
+
+@pytest.mark.parametrize(
+    "exception,status_code,text",
+    [
+        (ValueError("value_error"), HTTP_500_INTERNAL_SERVER_ERROR, "Exception Group Traceback"),
+        (
+            HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail="http_exception"),
+            HTTP_422_UNPROCESSABLE_ENTITY,
+            '{"status_code":422,"detail":"http_exception"}',
+        ),
+        (
+            ValidationException("validation_exception"),
+            HTTP_400_BAD_REQUEST,
+            '{"status_code":400,"detail":"validation_exception"}',
+        ),
+    ],
+)
+def test_dependency_batch_with_exception(exception: Exception, status_code: int, text: str) -> None:
+    def a() -> None:
+        raise exception
+
+    def c(a: None, b: None) -> None:
+        pass
+
+    @get(path="/")
+    def handler(c: None) -> None:
+        pass
+
+    with create_test_client(
+        route_handlers=handler,
+        dependencies={
+            "a": Provide(a),
+            "b": Provide(dummy),
+            "c": Provide(c),
+        },
+    ) as client:
+        response = client.get("/")
+        assert response.status_code == status_code
+        assert text in response.text
diff --git a/tests/unit/test_testing/test_test_client.py b/tests/unit/test_testing/test_test_client.py
--- a/tests/unit/test_testing/test_test_client.py
+++ b/tests/unit/test_testing/test_test_client.py
@@ -25,7 +25,8 @@
 from litestar import Litestar, Request, get, post
 from litestar.stores.base import Store
 from litestar.testing import TestClient
-from tests.helpers import get_exception_group, maybe_async, maybe_async_cm
+from litestar.utils.helpers import get_exception_group
+from tests.helpers import maybe_async, maybe_async_cm
 
 _ExceptionGroup = get_exception_group()