Repository: litestar-org/litestar

Issue:
Bug: Can't use `Response[None]` as return annotation when status code is `204`
### Description

As per the [docs](https://docs.litestar.dev/2/usage/responses.html#status-codes):

> For status codes < 100 or 204, 304 statuses, no response body is allowed. If you specify a return annotation other than None, an [ImproperlyConfiguredException](https://docs.litestar.dev/2/reference/exceptions.html#litestar.exceptions.ImproperlyConfiguredException) will be raised.

But sometimes you want to return a `Response`, even without content. For example, to add some headers dynamically:

```py
return Response(None, headers={"FOO": "BAR"})
```

Then to satisfy the types, the return type should be `Response[None]`. But in this case, `ImproperlyConfiguredException` is raised. I guess it shouldn't be.

### URL to code causing the issue

_No response_

### MCVE

```python
from litestar import Litestar, Response, get
from litestar.status_codes import HTTP_204_NO_CONTENT


@get("/", status_code=HTTP_204_NO_CONTENT)
async def index() -> Response[None]:
    return Response(None)


app = Litestar([index])
```


### Steps to reproduce

_No response_

### Screenshots

_No response_

### Logs

_No response_

### Litestar Version

`2.4.4`

### Platform

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



Discussion:

Resolving patch:
diff --git a/litestar/handlers/http_handlers/_utils.py b/litestar/handlers/http_handlers/_utils.py
--- a/litestar/handlers/http_handlers/_utils.py
+++ b/litestar/handlers/http_handlers/_utils.py
@@ -6,14 +6,15 @@
 
 from litestar.enums import HttpMethod
 from litestar.exceptions import ValidationException
+from litestar.response import Response
 from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT
+from litestar.types.builtin_types import NoneType
 
 if TYPE_CHECKING:
     from litestar.app import Litestar
     from litestar.background_tasks import BackgroundTask, BackgroundTasks
     from litestar.connection import Request
     from litestar.datastructures import Cookie, ResponseHeader
-    from litestar.response import Response
     from litestar.types import (
         AfterRequestHookHandler,
         ASGIApp,
@@ -22,12 +23,14 @@
         ResponseType,
         TypeEncodersMap,
     )
+    from litestar.typing import FieldDefinition
 
 __all__ = (
     "create_data_handler",
     "create_generic_asgi_response_handler",
     "create_response_handler",
     "get_default_status_code",
+    "is_empty_response_annotation",
     "normalize_headers",
     "normalize_http_method",
 )
@@ -206,4 +209,20 @@ def get_default_status_code(http_methods: set[Method]) -> int:
     return HTTP_200_OK
 
 
+def is_empty_response_annotation(return_annotation: FieldDefinition) -> bool:
+    """Return whether the return annotation is an empty response.
+
+    Args:
+        return_annotation: A return annotation.
+
+    Returns:
+        Whether the return annotation is an empty response.
+    """
+    return (
+        return_annotation.is_subclass_of(NoneType)
+        or return_annotation.is_subclass_of(Response)
+        and return_annotation.has_inner_subclass_of(NoneType)
+    )
+
+
 HTTP_METHOD_NAMES = {m.value for m in HttpMethod}
diff --git a/litestar/handlers/http_handlers/base.py b/litestar/handlers/http_handlers/base.py
--- a/litestar/handlers/http_handlers/base.py
+++ b/litestar/handlers/http_handlers/base.py
@@ -17,6 +17,7 @@
     create_generic_asgi_response_handler,
     create_response_handler,
     get_default_status_code,
+    is_empty_response_annotation,
     normalize_http_method,
 )
 from litestar.openapi.spec import Operation
@@ -41,7 +42,6 @@
     ResponseType,
     TypeEncodersMap,
 )
-from litestar.types.builtin_types import NoneType
 from litestar.utils import ensure_async_callable
 from litestar.utils.predicates import is_async_callable
 from litestar.utils.warnings import warn_implicit_sync_to_thread, warn_sync_to_thread_with_async_callable
@@ -552,7 +552,7 @@ def _validate_handler_function(self) -> None:
 
         if (
             self.status_code < 200 or self.status_code in {HTTP_204_NO_CONTENT, HTTP_304_NOT_MODIFIED}
-        ) and not return_type.is_subclass_of(NoneType):
+        ) and not is_empty_response_annotation(return_type):
             raise ImproperlyConfiguredException(
                 "A status code 204, 304 or in the range below 200 does not support a response body. "
                 "If the function should return a value, change the route handler status code to an appropriate value.",


Test patch:
diff --git a/tests/unit/test_handlers/test_http_handlers/test_validations.py b/tests/unit/test_handlers/test_http_handlers/test_validations.py
--- a/tests/unit/test_handlers/test_http_handlers/test_validations.py
+++ b/tests/unit/test_handlers/test_http_handlers/test_validations.py
@@ -1,5 +1,6 @@
 from pathlib import Path
-from typing import Dict
+from types import ModuleType
+from typing import Callable, Dict
 
 import pytest
 
@@ -110,3 +111,36 @@ def test_function_2(self, data: DataclassPerson) -> None:  # type: ignore
         Litestar(route_handlers=[test_function_2])
 
         test_function_2.on_registration(Litestar())
+
+
+@pytest.mark.parametrize(
+    ("return_annotation", "should_raise"),
+    [
+        ("None", False),
+        ("Response[None]", False),
+        ("int", True),
+        ("Response[int]", True),
+        ("Response", True),
+    ],
+)
+def test_204_response_annotations(
+    return_annotation: str, should_raise: bool, create_module: Callable[[str], ModuleType]
+) -> None:
+    module = create_module(
+        f"""
+from litestar import get
+from litestar.response import Response
+from litestar.status_codes import HTTP_204_NO_CONTENT
+
+@get(path="/", status_code=HTTP_204_NO_CONTENT)
+def no_response_handler() -> {return_annotation}:
+    pass
+"""
+    )
+
+    if should_raise:
+        with pytest.raises(ImproperlyConfiguredException):
+            Litestar(route_handlers=[module.no_response_handler])
+        return
+
+    Litestar(route_handlers=[module.no_response_handler])