Repository: litestar-org/litestar

Issue:
Bug: multiple connection objects can cause the app to hang
### Description

If multiple request objects are created before the first one calls one of the cached data access methods, e.g., `Request.json()` et. al, then the 2nd one to attempt to access the data will cause the request to block on `receive()` for a 2nd time, waiting for message that will never come.

The reason is that we only check whether the data has been cached upon instantiation of the connection objects, not when the connection object attempts to access the request data.

### URL to code causing the issue

_No response_

### MCVE

```python
from typing import Dict

from litestar import Litestar, Request, post
from litestar.types.asgi_types import ASGIApp, Message, Receive, Scope, Send


def some_middleware(app: ASGIApp) -> ASGIApp:
    async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope, receive, send)

        async def wrapped_send(message: Message) -> None:
            await request.json()
            # do something with data
            await send(message)

        await app(scope, receive, wrapped_send)

    return middleware


@post(middleware=[some_middleware])
async def root(request: Request) -> Dict[str, str]:
    return request.json()


app = Litestar(route_handlers=[root], debug=True)
```


### Steps to reproduce

```bash
1. run the above application
2. make any POST request to `/`
```


### Screenshots

```bash
"![SCREENSHOT_DESCRIPTION](SCREENSHOT_LINK.png)"
```


### Logs

_No response_

### Litestar Version

main

### Platform

- [X] Linux
- [X] Mac
- [X] 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/2727">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/2727/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/2727/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/litestar/connection/base.py b/litestar/connection/base.py
--- a/litestar/connection/base.py
+++ b/litestar/connection/base.py
@@ -81,15 +81,10 @@ def __init__(self, scope: Scope, receive: Receive = empty_receive, send: Send =
         self.scope = scope
         self.receive = receive
         self.send = send
-        self._base_url = cast("URL | EmptyType", get_litestar_scope_state(scope, SCOPE_STATE_BASE_URL_KEY, Empty))
-        self._url = cast("URL | EmptyType", get_litestar_scope_state(scope, SCOPE_STATE_URL_KEY, Empty))
-        self._parsed_query = cast(
-            "tuple[tuple[str, str], ...] | EmptyType",
-            get_litestar_scope_state(scope, SCOPE_STATE_PARSED_QUERY_KEY, Empty),
-        )
-        self._cookies = cast(
-            "dict[str, str] | EmptyType", get_litestar_scope_state(scope, SCOPE_STATE_COOKIES_KEY, Empty)
-        )
+        self._base_url: URL | EmptyType = Empty
+        self._url: URL | EmptyType = Empty
+        self._parsed_query: tuple[tuple[str, str], ...] | EmptyType = Empty
+        self._cookies: dict[str, str] | EmptyType = Empty
 
     @property
     def app(self) -> Litestar:
@@ -126,8 +121,11 @@ def url(self) -> URL:
             A URL instance constructed from the request's scope.
         """
         if self._url is Empty:
-            self._url = URL.from_scope(self.scope)
-            set_litestar_scope_state(self.scope, SCOPE_STATE_URL_KEY, self._url)
+            if url := get_litestar_scope_state(self.scope, SCOPE_STATE_URL_KEY):
+                self._url = cast("URL", url)
+            else:
+                self._url = URL.from_scope(self.scope)
+                set_litestar_scope_state(self.scope, SCOPE_STATE_URL_KEY, self._url)
 
         return self._url
 
@@ -140,17 +138,20 @@ def base_url(self) -> URL:
             (host + domain + prefix) of the request.
         """
         if self._base_url is Empty:
-            scope = cast(
-                "Scope",
-                {
-                    **self.scope,
-                    "path": "/",
-                    "query_string": b"",
-                    "root_path": self.scope.get("app_root_path") or self.scope.get("root_path", ""),
-                },
-            )
-            self._base_url = URL.from_scope(scope)
-            set_litestar_scope_state(self.scope, SCOPE_STATE_BASE_URL_KEY, self._base_url)
+            if base_url := get_litestar_scope_state(self.scope, SCOPE_STATE_BASE_URL_KEY):
+                self._base_url = cast("URL", base_url)
+            else:
+                scope = cast(
+                    "Scope",
+                    {
+                        **self.scope,
+                        "path": "/",
+                        "query_string": b"",
+                        "root_path": self.scope.get("app_root_path") or self.scope.get("root_path", ""),
+                    },
+                )
+                self._base_url = URL.from_scope(scope)
+                set_litestar_scope_state(self.scope, SCOPE_STATE_BASE_URL_KEY, self._base_url)
         return self._base_url
 
     @property
@@ -170,8 +171,11 @@ def query_params(self) -> MultiDict[Any]:
             A normalized dict of query parameters. Multiple values for the same key are returned as a list.
         """
         if self._parsed_query is Empty:
-            self._parsed_query = parse_query_string(self.scope.get("query_string", b""))
-            set_litestar_scope_state(self.scope, SCOPE_STATE_PARSED_QUERY_KEY, self._parsed_query)
+            if (parsed_query := get_litestar_scope_state(self.scope, SCOPE_STATE_PARSED_QUERY_KEY, Empty)) is not Empty:
+                self._parsed_query = cast("tuple[tuple[str, str], ...]", parsed_query)
+            else:
+                self._parsed_query = parse_query_string(self.scope.get("query_string", b""))
+                set_litestar_scope_state(self.scope, SCOPE_STATE_PARSED_QUERY_KEY, self._parsed_query)
         return MultiDict(self._parsed_query)
 
     @property
@@ -191,8 +195,13 @@ def cookies(self) -> dict[str, str]:
             Returns any cookies stored in the header as a parsed dictionary.
         """
         if self._cookies is Empty:
-            self._cookies = parse_cookie_string(cookie_header) if (cookie_header := self.headers.get("cookie")) else {}
-            set_litestar_scope_state(self.scope, SCOPE_STATE_COOKIES_KEY, self._cookies)
+            if (cookies := get_litestar_scope_state(self.scope, SCOPE_STATE_COOKIES_KEY, Empty)) is not Empty:
+                self._cookies = cast("dict[str, str]", cookies)
+            else:
+                self._cookies = (
+                    parse_cookie_string(cookie_header) if (cookie_header := self.headers.get("cookie")) else {}
+                )
+                set_litestar_scope_state(self.scope, SCOPE_STATE_COOKIES_KEY, self._cookies)
 
         return self._cookies
 
diff --git a/litestar/connection/request.py b/litestar/connection/request.py
--- a/litestar/connection/request.py
+++ b/litestar/connection/request.py
@@ -34,6 +34,7 @@
 if TYPE_CHECKING:
     from litestar.handlers.http_handlers import HTTPRouteHandler  # noqa: F401
     from litestar.types.asgi_types import HTTPScope, Method, Receive, Scope, Send
+    from litestar.types.empty import EmptyType
 
 
 SERVER_PUSH_HEADERS = {
@@ -67,12 +68,12 @@ def __init__(self, scope: Scope, receive: Receive = empty_receive, send: Send =
         """
         super().__init__(scope, receive, send)
         self.is_connected: bool = True
-        self._body = get_litestar_scope_state(scope, SCOPE_STATE_BODY_KEY, Empty)
-        self._form = get_litestar_scope_state(scope, SCOPE_STATE_FORM_KEY, Empty)
-        self._json = get_litestar_scope_state(scope, SCOPE_STATE_JSON_KEY, Empty)
-        self._msgpack = get_litestar_scope_state(scope, SCOPE_STATE_MSGPACK_KEY, Empty)
-        self._content_type = get_litestar_scope_state(scope, SCOPE_STATE_CONTENT_TYPE_KEY, Empty)
-        self._accept = get_litestar_scope_state(scope, SCOPE_STATE_ACCEPT_KEY, Empty)
+        self._body: bytes | EmptyType = Empty
+        self._form: dict[str, str | list[str]] | EmptyType = Empty
+        self._json: Any = Empty
+        self._msgpack: Any = Empty
+        self._content_type: tuple[str, dict[str, str]] | EmptyType = Empty
+        self._accept: Accept | EmptyType = Empty
 
     @property
     def method(self) -> Method:
@@ -91,9 +92,12 @@ def content_type(self) -> tuple[str, dict[str, str]]:
             A tuple with the parsed value and a dictionary containing any options send in it.
         """
         if self._content_type is Empty:
-            self._content_type = parse_content_header(self.headers.get("Content-Type", ""))
-            set_litestar_scope_state(self.scope, SCOPE_STATE_CONTENT_TYPE_KEY, self._content_type)
-        return cast("tuple[str, dict[str, str]]", self._content_type)
+            if (content_type := get_litestar_scope_state(self.scope, SCOPE_STATE_CONTENT_TYPE_KEY, Empty)) is not Empty:
+                self._content_type = cast("tuple[str, dict[str, str]]", content_type)
+            else:
+                self._content_type = parse_content_header(self.headers.get("Content-Type", ""))
+                set_litestar_scope_state(self.scope, SCOPE_STATE_CONTENT_TYPE_KEY, self._content_type)
+        return self._content_type
 
     @property
     def accept(self) -> Accept:
@@ -103,9 +107,12 @@ def accept(self) -> Accept:
             An :class:`Accept <litestar.datastructures.headers.Accept>` instance, representing the list of acceptable media types.
         """
         if self._accept is Empty:
-            self._accept = Accept(self.headers.get("Accept", "*/*"))
-            set_litestar_scope_state(self.scope, SCOPE_STATE_ACCEPT_KEY, self._accept)
-        return cast("Accept", self._accept)
+            if accept := get_litestar_scope_state(self.scope, SCOPE_STATE_ACCEPT_KEY):
+                self._accept = cast("Accept", accept)
+            else:
+                self._accept = Accept(self.headers.get("Accept", "*/*"))
+                set_litestar_scope_state(self.scope, SCOPE_STATE_ACCEPT_KEY, self._accept)
+        return self._accept
 
     async def json(self) -> Any:
         """Retrieve the json request body from the request.
@@ -114,9 +121,12 @@ async def json(self) -> Any:
             An arbitrary value
         """
         if self._json is Empty:
-            body = await self.body()
-            self._json = decode_json(body or b"null", type_decoders=self.route_handler.resolve_type_decoders())
-            set_litestar_scope_state(self.scope, SCOPE_STATE_JSON_KEY, self._json)
+            if (json_ := get_litestar_scope_state(self.scope, SCOPE_STATE_JSON_KEY, Empty)) is not Empty:
+                self._json = json_
+            else:
+                body = await self.body()
+                self._json = decode_json(body or b"null", type_decoders=self.route_handler.resolve_type_decoders())
+                set_litestar_scope_state(self.scope, SCOPE_STATE_JSON_KEY, self._json)
         return self._json
 
     async def msgpack(self) -> Any:
@@ -126,9 +136,14 @@ async def msgpack(self) -> Any:
             An arbitrary value
         """
         if self._msgpack is Empty:
-            body = await self.body()
-            self._msgpack = decode_msgpack(body or b"\xc0", type_decoders=self.route_handler.resolve_type_decoders())
-            set_litestar_scope_state(self.scope, SCOPE_STATE_MSGPACK_KEY, self._msgpack)
+            if (msgpack := get_litestar_scope_state(self.scope, SCOPE_STATE_MSGPACK_KEY, Empty)) is not Empty:
+                self._msgpack = msgpack
+            else:
+                body = await self.body()
+                self._msgpack = decode_msgpack(
+                    body or b"\xc0", type_decoders=self.route_handler.resolve_type_decoders()
+                )
+                set_litestar_scope_state(self.scope, SCOPE_STATE_MSGPACK_KEY, self._msgpack)
         return self._msgpack
 
     async def stream(self) -> AsyncGenerator[bytes, None]:
@@ -169,9 +184,12 @@ async def body(self) -> bytes:
             A byte-string representing the body of the request.
         """
         if self._body is Empty:
-            self._body = b"".join([c async for c in self.stream()])
-            set_litestar_scope_state(self.scope, SCOPE_STATE_BODY_KEY, self._body)
-        return cast("bytes", self._body)
+            if (body := get_litestar_scope_state(self.scope, SCOPE_STATE_BODY_KEY)) is not None:
+                self._body = cast("bytes", body)
+            else:
+                self._body = b"".join([c async for c in self.stream()])
+                set_litestar_scope_state(self.scope, SCOPE_STATE_BODY_KEY, self._body)
+        return self._body
 
     async def form(self) -> FormMultiDict:
         """Retrieve form data from the request. If the request is either a 'multipart/form-data' or an
@@ -182,21 +200,24 @@ async def form(self) -> FormMultiDict:
             A FormMultiDict instance
         """
         if self._form is Empty:
-            content_type, options = self.content_type
-            if content_type == RequestEncodingType.MULTI_PART:
-                self._form = parse_multipart_form(
-                    body=await self.body(),
-                    boundary=options.get("boundary", "").encode(),
-                    multipart_form_part_limit=self.app.multipart_form_part_limit,
-                )
-            elif content_type == RequestEncodingType.URL_ENCODED:
-                self._form = parse_url_encoded_form_data(
-                    await self.body(),
-                )
+            if (form := get_litestar_scope_state(self.scope, SCOPE_STATE_FORM_KEY, Empty)) is not Empty:
+                self._form = cast("dict[str, str | list[str]]", form)
             else:
-                self._form = {}
-
-            set_litestar_scope_state(self.scope, SCOPE_STATE_FORM_KEY, self._form)
+                content_type, options = self.content_type
+                if content_type == RequestEncodingType.MULTI_PART:
+                    self._form = parse_multipart_form(
+                        body=await self.body(),
+                        boundary=options.get("boundary", "").encode(),
+                        multipart_form_part_limit=self.app.multipart_form_part_limit,
+                    )
+                elif content_type == RequestEncodingType.URL_ENCODED:
+                    self._form = parse_url_encoded_form_data(
+                        await self.body(),
+                    )
+                else:
+                    self._form = {}
+
+                set_litestar_scope_state(self.scope, SCOPE_STATE_FORM_KEY, self._form)
 
         return FormMultiDict(self._form)
 
diff --git a/litestar/middleware/logging.py b/litestar/middleware/logging.py
--- a/litestar/middleware/logging.py
+++ b/litestar/middleware/logging.py
@@ -126,7 +126,7 @@ async def log_request(self, scope: Scope, receive: Receive) -> None:
         Returns:
             None
         """
-        extracted_data = await self.extract_request_data(request=scope["app"].request_class(scope, receive=receive))
+        extracted_data = await self.extract_request_data(request=scope["app"].request_class(scope, receive))
         self.log_message(values=extracted_data)
 
     def log_response(self, scope: Scope) -> None:


Test patch:
diff --git a/tests/unit/test_connection/test_connection_caching.py b/tests/unit/test_connection/test_connection_caching.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_connection/test_connection_caching.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+from typing import Any, Awaitable, Callable
+from unittest.mock import ANY, MagicMock, call
+
+import pytest
+
+from litestar import Request, constants
+from litestar.testing import RequestFactory
+from litestar.types import Empty, HTTPReceiveMessage, Scope
+from litestar.utils import get_litestar_scope_state, set_litestar_scope_state
+
+
+async def test_multiple_request_object_data_caching(create_scope: Callable[..., Scope], mock: MagicMock) -> None:
+    """Test that accessing the request data on multiple request objects only attempts to await `receive()` once.
+
+    https://github.com/litestar-org/litestar/issues/2727
+    """
+
+    async def test_receive() -> HTTPReceiveMessage:
+        mock()
+        return {"type": "http.request", "body": b"abc", "more_body": False}
+
+    scope = create_scope()
+    request_1 = Request[Any, Any, Any](scope, test_receive)
+    request_2 = Request[Any, Any, Any](scope, test_receive)
+    assert (await request_1.body()) == b"abc"
+    assert (await request_2.body()) == b"abc"
+    assert mock.call_count == 1
+
+
+@pytest.fixture(name="get_mock")
+def get_mock_fixture() -> MagicMock:
+    return MagicMock()
+
+
+@pytest.fixture(name="set_mock")
+def set_mock_fixture() -> MagicMock:
+    return MagicMock()
+
+
+@pytest.fixture(name="create_connection")
+def create_connection_fixture(
+    get_mock: MagicMock, set_mock: MagicMock, monkeypatch: pytest.MonkeyPatch
+) -> Callable[..., Request]:
+    def create_connection(body_type: str = "json") -> Request:
+        def wrapped_get_litestar_scope_state(scope_: Scope, key: str, default: Any = None) -> Any:
+            get_mock(key)
+            return get_litestar_scope_state(scope_, key, default)
+
+        def wrapped_set_litestar_scope_state(scope_: Scope, key: str, value: Any) -> None:
+            set_mock(key, value)
+            set_litestar_scope_state(scope_, key, value)
+
+        monkeypatch.setattr("litestar.connection.base.get_litestar_scope_state", wrapped_get_litestar_scope_state)
+        monkeypatch.setattr("litestar.connection.base.set_litestar_scope_state", wrapped_set_litestar_scope_state)
+        monkeypatch.setattr("litestar.connection.request.get_litestar_scope_state", wrapped_get_litestar_scope_state)
+        monkeypatch.setattr("litestar.connection.request.set_litestar_scope_state", wrapped_set_litestar_scope_state)
+
+        connection = RequestFactory().get()
+
+        async def fake_receive() -> HTTPReceiveMessage:
+            if body_type == "msgpack":
+                return {"type": "http.request", "body": b"\x81\xa3abc\xa3def", "more_body": False}
+            return {"type": "http.request", "body": b'{"abc":"def"}', "more_body": False}
+
+        monkeypatch.setattr(connection, "receive", fake_receive)
+
+        return connection
+
+    return create_connection
+
+
+@pytest.fixture(name="get_value")
+def get_value_fixture() -> Callable[[Request, str, bool], Awaitable[Any]]:
+    """Fixture to get the value of a connection cached property.
+
+    Returns:
+        A function to get the value of a connection cached property.
+    """
+
+    async def get_value_(connection: Request, prop_name: str, is_coro: bool) -> Any:
+        """Helper to get the value of the tested cached property."""
+        value = getattr(connection, prop_name)
+        if is_coro:
+            return await value()
+        return value
+
+    return get_value_
+
+
+caching_tests = [
+    (constants.SCOPE_STATE_URL_KEY, "url", "_url", False),
+    (constants.SCOPE_STATE_BASE_URL_KEY, "base_url", "_base_url", False),
+    (
+        constants.SCOPE_STATE_PARSED_QUERY_KEY,
+        "query_params",
+        "_parsed_query",
+        False,
+    ),
+    (constants.SCOPE_STATE_COOKIES_KEY, "cookies", "_cookies", False),
+    (constants.SCOPE_STATE_BODY_KEY, "body", "_body", True),
+    (constants.SCOPE_STATE_FORM_KEY, "form", "_form", True),
+    (constants.SCOPE_STATE_MSGPACK_KEY, "msgpack", "_msgpack", True),
+    (constants.SCOPE_STATE_JSON_KEY, "json", "_json", True),
+    (constants.SCOPE_STATE_ACCEPT_KEY, "accept", "_accept", False),
+    (constants.SCOPE_STATE_CONTENT_TYPE_KEY, "content_type", "_content_type", False),
+]
+
+
+@pytest.mark.parametrize(("state_key", "prop_name", "cache_attr_name", "is_coro"), caching_tests)
+async def test_connection_cached_properties_no_scope_or_connection_caching(
+    state_key: str,
+    prop_name: str,
+    cache_attr_name: str,
+    is_coro: bool,
+    create_connection: Callable[..., Request],
+    get_mock: MagicMock,
+    set_mock: MagicMock,
+    get_value: Callable[[Request, str, bool], Awaitable[Any]],
+) -> None:
+    def check_get_mock() -> None:
+        """Helper to check the get mock.
+
+        For certain properties, we call `get_litestar_scope_state()` twice, once for the property and once for the
+        body. For these cases, we check that the mock was called twice.
+        """
+        if state_key in ("json", "msgpack"):
+            get_mock.assert_has_calls([call(state_key), call("body")])
+        elif state_key == "form":
+            get_mock.assert_has_calls([call(state_key), call("content_type")])
+        else:
+            get_mock.assert_called_once_with(state_key)
+
+    def check_set_mock() -> None:
+        """Helper to check the set mock.
+
+        For certain properties, we call `set_litestar_scope_state()` twice, once for the property and once for the
+        body. For these cases, we check that the mock was called twice.
+        """
+        if state_key in ("json", "msgpack"):
+            set_mock.assert_has_calls([call("body", ANY), call(state_key, ANY)])
+        elif state_key == "form":
+            set_mock.assert_has_calls([call("content_type", ANY), call("form", ANY)])
+        else:
+            set_mock.assert_called_once_with(state_key, ANY)
+
+    connection = create_connection("msgpack" if state_key == "msgpack" else "json")
+
+    assert get_litestar_scope_state(connection.scope, state_key, Empty) is Empty
+    setattr(connection, cache_attr_name, Empty)
+
+    await get_value(connection, prop_name, is_coro)
+    check_get_mock()
+    check_set_mock()
+
+
+@pytest.mark.parametrize(("state_key", "prop_name", "cache_attr_name", "is_coro"), caching_tests)
+async def test_connection_cached_properties_cached_in_scope(
+    state_key: str,
+    prop_name: str,
+    cache_attr_name: str,
+    is_coro: bool,
+    create_connection: Callable[..., Request],
+    get_mock: MagicMock,
+    set_mock: MagicMock,
+    get_value: Callable[[Request, str, bool], Awaitable[Any]],
+) -> None:
+    # set the value in the scope and ensure empty on connection
+    connection = create_connection()
+
+    set_litestar_scope_state(connection.scope, state_key, {"a": "b"})
+    setattr(connection, cache_attr_name, Empty)
+
+    await get_value(connection, prop_name, is_coro)
+    get_mock.assert_called_once_with(state_key)
+    set_mock.assert_not_called()
+
+
+@pytest.mark.parametrize(("state_key", "prop_name", "cache_attr_name", "is_coro"), caching_tests)
+async def test_connection_cached_properties_cached_on_connection(
+    state_key: str,
+    prop_name: str,
+    cache_attr_name: str,
+    is_coro: bool,
+    create_connection: Callable[..., Request],
+    get_mock: MagicMock,
+    set_mock: MagicMock,
+    get_value: Callable[[Request, str, bool], Awaitable[Any]],
+) -> None:
+    connection = create_connection()
+    # set the value on the connection
+    setattr(connection, cache_attr_name, {"a": "b"})
+    await get_value(connection, prop_name, is_coro)
+    get_mock.assert_not_called()
+    set_mock.assert_not_called()