Repository: litestar-org/litestar

Issue:
Documentation: Unnecessary use of sync_to_thread=True
The following are calling object constructors with no I/O, so should be right to run sync.

https://github.com/litestar-org/litestar/blob/0d4fe01a9d361e09748f71aa7adf70672b97c822/docs/examples/contrib/sqlalchemy/sqlalchemy_sync_repository.py#L120

https://github.com/litestar-org/litestar/blob/0d4fe01a9d361e09748f71aa7adf70672b97c822/docs/examples/contrib/sqlalchemy/sqlalchemy_sync_repository.py#L154

https://github.com/litestar-org/litestar/blob/0d4fe01a9d361e09748f71aa7adf70672b97c822/docs/examples/contrib/sqlalchemy/sqlalchemy_sync_repository.py#L170



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



Discussion:

Resolving patch:
diff --git a/litestar/config/response_cache.py b/litestar/config/response_cache.py
--- a/litestar/config/response_cache.py
+++ b/litestar/config/response_cache.py
@@ -1,17 +1,23 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, final
+from typing import TYPE_CHECKING, Any, Callable, final
 from urllib.parse import urlencode
 
-__all__ = ("ResponseCacheConfig", "default_cache_key_builder", "CACHE_FOREVER")
-
+from litestar.status_codes import (
+    HTTP_200_OK,
+    HTTP_300_MULTIPLE_CHOICES,
+    HTTP_301_MOVED_PERMANENTLY,
+    HTTP_308_PERMANENT_REDIRECT,
+)
 
 if TYPE_CHECKING:
     from litestar import Litestar
     from litestar.connection import Request
     from litestar.stores.base import Store
-    from litestar.types import CacheKeyBuilder
+    from litestar.types import CacheKeyBuilder, HTTPScope
+
+__all__ = ("ResponseCacheConfig", "default_cache_key_builder", "CACHE_FOREVER")
 
 
 @final
@@ -35,6 +41,22 @@ def default_cache_key_builder(request: Request[Any, Any, Any]) -> str:
     return request.url.path + urlencode(query_params, doseq=True)
 
 
+def default_do_cache_predicate(_: HTTPScope, status_code: int) -> bool:
+    """Given a status code, returns a boolean indicating whether the response should be cached.
+
+    Args:
+        _: ASGI scope.
+        status_code: status code of the response.
+
+    Returns:
+        A boolean indicating whether the response should be cached.
+    """
+    return HTTP_200_OK <= status_code < HTTP_300_MULTIPLE_CHOICES or status_code in (
+        HTTP_301_MOVED_PERMANENTLY,
+        HTTP_308_PERMANENT_REDIRECT,
+    )
+
+
 @dataclass
 class ResponseCacheConfig:
     """Configuration for response caching.
@@ -49,6 +71,9 @@ class ResponseCacheConfig:
     """:class:`CacheKeyBuilder <.types.CacheKeyBuilder>`. Defaults to :func:`default_cache_key_builder`."""
     store: str = "response_cache"
     """Name of the :class:`Store <.stores.base.Store>` to use."""
+    cache_response_filter: Callable[[HTTPScope, int], bool] = field(default=default_do_cache_predicate)
+    """A callable that receives connection scope and a status code, and returns a boolean indicating whether the
+    response should be cached."""
 
     def get_store_from_app(self, app: Litestar) -> Store:
         """Get the store defined in :attr:`store` from an :class:`Litestar <.app.Litestar>` instance."""
diff --git a/litestar/constants.py b/litestar/constants.py
--- a/litestar/constants.py
+++ b/litestar/constants.py
@@ -21,6 +21,7 @@
 SCOPE_STATE_DEPENDENCY_CACHE: Final = "dependency_cache"
 SCOPE_STATE_NAMESPACE: Final = "__litestar__"
 SCOPE_STATE_RESPONSE_COMPRESSED: Final = "response_compressed"
+SCOPE_STATE_DO_CACHE: Final = "do_cache"
 SCOPE_STATE_IS_CACHED: Final = "is_cached"
 SKIP_VALIDATION_NAMES: Final = {"request", "socket", "scope", "receive", "send"}
 UNDEFINED_SENTINELS: Final = {Signature.empty, Empty, Ellipsis, MISSING, UnsetType}
diff --git a/litestar/middleware/response_cache.py b/litestar/middleware/response_cache.py
--- a/litestar/middleware/response_cache.py
+++ b/litestar/middleware/response_cache.py
@@ -1,23 +1,22 @@
 from __future__ import annotations
 
+from typing import TYPE_CHECKING, cast
+
 from msgspec.msgpack import encode as encode_msgpack
 
+from litestar import Request
+from litestar.constants import HTTP_RESPONSE_BODY, HTTP_RESPONSE_START, SCOPE_STATE_DO_CACHE, SCOPE_STATE_IS_CACHED
 from litestar.enums import ScopeType
-from litestar.utils import get_litestar_scope_state
+from litestar.utils import get_litestar_scope_state, set_litestar_scope_state
 
 from .base import AbstractMiddleware
 
-__all__ = ["ResponseCacheMiddleware"]
-
-from typing import TYPE_CHECKING, cast
-
-from litestar import Request
-from litestar.constants import SCOPE_STATE_IS_CACHED
-
 if TYPE_CHECKING:
     from litestar.config.response_cache import ResponseCacheConfig
     from litestar.handlers import HTTPRouteHandler
-    from litestar.types import ASGIApp, Message, Receive, Scope, Send
+    from litestar.types import ASGIApp, HTTPScope, Message, Receive, Scope, Send
+
+__all__ = ["ResponseCacheMiddleware"]
 
 
 class ResponseCacheMiddleware(AbstractMiddleware):
@@ -27,7 +26,6 @@ def __init__(self, app: ASGIApp, config: ResponseCacheConfig) -> None:
 
     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
         route_handler = cast("HTTPRouteHandler", scope["route_handler"])
-        store = self.config.get_store_from_app(scope["app"])
 
         expires_in: int | None = None
         if route_handler.cache is True:
@@ -35,13 +33,21 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
         elif route_handler.cache is not False and isinstance(route_handler.cache, int):
             expires_in = route_handler.cache
 
-        messages = []
+        messages: list[Message] = []
 
         async def wrapped_send(message: Message) -> None:
             if not get_litestar_scope_state(scope, SCOPE_STATE_IS_CACHED):
-                messages.append(message)
-                if message["type"] == "http.response.body" and not message["more_body"]:
+                if message["type"] == HTTP_RESPONSE_START:
+                    do_cache = self.config.cache_response_filter(cast("HTTPScope", scope), message["status"])
+                    set_litestar_scope_state(scope, SCOPE_STATE_DO_CACHE, do_cache)
+                    if do_cache:
+                        messages.append(message)
+                elif get_litestar_scope_state(scope, SCOPE_STATE_DO_CACHE):
+                    messages.append(message)
+
+                if messages and message["type"] == HTTP_RESPONSE_BODY and not message["more_body"]:
                     key = (route_handler.cache_key_builder or self.config.key_builder)(Request(scope))
+                    store = self.config.get_store_from_app(scope["app"])
                     await store.set(key, encode_msgpack(messages), expires_in=expires_in)
             await send(message)
 


Test patch:
diff --git a/tests/e2e/test_response_caching.py b/tests/e2e/test_response_caching.py
--- a/tests/e2e/test_response_caching.py
+++ b/tests/e2e/test_response_caching.py
@@ -8,11 +8,12 @@
 import msgspec
 import pytest
 
-from litestar import Litestar, Request, get
+from litestar import Litestar, Request, Response, get
 from litestar.config.compression import CompressionConfig
 from litestar.config.response_cache import CACHE_FOREVER, ResponseCacheConfig
 from litestar.enums import CompressionEncoding
 from litestar.middleware.response_cache import ResponseCacheMiddleware
+from litestar.status_codes import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR
 from litestar.stores.base import Store
 from litestar.stores.memory import MemoryStore
 from litestar.testing import TestClient, create_test_client
@@ -20,8 +21,6 @@
 if TYPE_CHECKING:
     from time_machine import Coordinates
 
-    from litestar import Response
-
 
 @pytest.fixture()
 def mock() -> MagicMock:
@@ -248,3 +247,42 @@ def handler_fn() -> str:
     assert stored_value
     stored_messages = msgspec.msgpack.decode(stored_value)
     assert gzip.decompress(stored_messages[1]["body"]).decode() == return_value
+
+
+@pytest.mark.parametrize(
+    ("response", "should_cache"),
+    [
+        (HTTP_200_OK, True),
+        (HTTP_400_BAD_REQUEST, False),
+        (HTTP_500_INTERNAL_SERVER_ERROR, False),
+        (RuntimeError, False),
+    ],
+)
+def test_default_do_response_cache_predicate(
+    mock: MagicMock, response: Union[int, Type[RuntimeError]], should_cache: bool
+) -> None:
+    @get("/", cache=True)
+    def handler() -> Response:
+        mock()
+        if isinstance(response, int):
+            return Response(None, status_code=response)
+        raise RuntimeError
+
+    with create_test_client([handler]) as client:
+        client.get("/")
+        client.get("/")
+        assert mock.call_count == 1 if should_cache else 2
+
+
+def test_custom_do_response_cache_predicate(mock: MagicMock) -> None:
+    @get("/", cache=True)
+    def handler() -> str:
+        mock()
+        return "OK"
+
+    with create_test_client(
+        [handler], response_cache_config=ResponseCacheConfig(cache_response_filter=lambda *_: False)
+    ) as client:
+        client.get("/")
+        client.get("/")
+        assert mock.call_count == 2