Repository: litestar-org/litestar

Issue:
Enhancement: Add (or document) method for caching a route indefinitely
### Summary

Currently, the low-level cache access permits setting a value which will be cached indefinitely, by passing `expiration=None` to the cache's `set()` method. However, the `cache` argument to route decorators does not accept `None` (and raises an exception if `cache=None` is passed), and I can find no other mechanism for specifying that the response from a route should be cached indefinitely.

### Basic Example

It would be possible to write:

```python
from starlite import get

@get("/", cache=<sentinel value indicating no expiration>)
async def my_indefinitely_cached_handler() -> str:
    ...
```

and have the response be cached with no explicit expiration.

### Drawbacks and Impact

I'm not sure that there would be any negative impact beyond enabling a use case like this; no existing cache functionality would be removed or changed in a backwards incompatible way, as this is purely adding to the cache functionality.

For positive impact, it would be possible to have endpoints that effectively serve out of cache *forever*.

For a concrete example, I currently have an application which wants to do this due to having a data set that changes rarely and is expensive to query on the fly; I've instrumented its data-update logic to both insert the new data set into the database and also calculate and clear the affected set of cache key(s), after which I'd like the endpoints which serve the data to just query the DB *once* and then cache "forever" (or until the next time a data update occurs and clears some keys out of the cache).

At the moment I'm simulating this as best I can by doing the caching in my DB query layer. If I could reliably work out how generate the correct Starlite `Response` objects I could just pickle them and do a low-level cache `set()` with the appropriate key and with `expiration=None`, but ideally I would just let Starlite itself generate the responses and cache them with indefinite expiration.

### Unresolved questions

The main question I can see is what the correct value for the `cache` argument would be. With the low-level cache `set()` method (at least on Redis, which is the cache backend I'm using), a key can be set not to expire by passing `expiration=None`, but passing `cache=None` in a route decorator feels like it should have the semantics of not caching at all, rather than of caching indefinitely. So probably some kind of special sentinel value would be needed which could be passed in -- perhaps something like:

```python
from starlite import get
from starlite.cache import NO_EXPIRATION

@get("/", cache=NO_EXPIRATION)
async def my_indefinitely_cached_handler() -> str:
    ...
```

I believe internally Redis implements a non-expiring key as having an expiration "timestamp" of `-1`, so perhaps either that value, or a named constant holding that value, would work.


Discussion:
So, we actually discussed this previously, but to be honest, I can't recall what the verdict was. @Goldziher?

In any case, there should be a way. I remember raising the point that, from a semantic perspective, `cache=True` should cache indefinitely, but that would then make the default expiration require some sort of sentinel value. 

Personally I don't particularly care for `-1`, as it's not very explicit. We could however add a sentinel value like `NO_EXPIRATION` which would achieve the same effect.

Alternatively, and as a workaround, you could just pass something like `timedelta(days=365).total_seconds()` to cache for a year (multiply as your heart desires).
Sentinel sounds good
Related issue: #1301

Resolving patch:
diff --git a/starlite/config/response_cache.py b/starlite/config/response_cache.py
--- a/starlite/config/response_cache.py
+++ b/starlite/config/response_cache.py
@@ -4,7 +4,7 @@
 from typing import TYPE_CHECKING, Any
 from urllib.parse import urlencode
 
-__all__ = ("ResponseCacheConfig", "default_cache_key_builder")
+__all__ = ("ResponseCacheConfig", "default_cache_key_builder", "CACHE_FOREVER")
 
 
 if TYPE_CHECKING:
@@ -14,6 +14,12 @@
     from starlite.types import CacheKeyBuilder
 
 
+class CACHE_FOREVER:  # noqa: N801
+    """Sentinel value indicating that a cached response should be stored without an expiration, explicitly skipping the
+    default expiration
+    """
+
+
 def default_cache_key_builder(request: Request[Any, Any, Any]) -> str:
     """Given a request object, returns a cache key by combining the path with the sorted query params.
 
@@ -36,8 +42,8 @@ class ResponseCacheConfig:
     ``response_cache_config`` key.
     """
 
-    default_expiration: int = field(default=60)
-    """Default cache expiration in seconds."""
+    default_expiration: int | None = 60
+    """Default cache expiration in seconds used when a route handler is configured with ``cache=True``."""
     key_builder: CacheKeyBuilder = field(default=default_cache_key_builder)
     """:class:`CacheKeyBuilder <.types.CacheKeyBuilder>`. Defaults to :func:`default_cache_key_builder`."""
     store: str = "response_cache"
diff --git a/starlite/handlers/http_handlers/base.py b/starlite/handlers/http_handlers/base.py
--- a/starlite/handlers/http_handlers/base.py
+++ b/starlite/handlers/http_handlers/base.py
@@ -52,6 +52,7 @@
 
     from starlite.app import Starlite
     from starlite.background_tasks import BackgroundTask, BackgroundTasks
+    from starlite.config.response_cache import CACHE_FOREVER
     from starlite.connection import Request
     from starlite.datastructures import CacheControlHeader, ETag
     from starlite.datastructures.headers import Header
@@ -61,6 +62,7 @@
     from starlite.plugins import SerializationPluginProtocol
     from starlite.types import MaybePartial  # noqa: F401
 
+
 __all__ = ("HTTPRouteHandler", "route")
 
 
@@ -120,7 +122,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: Mapping[str, Provide] | None = None,
diff --git a/starlite/handlers/http_handlers/decorators.py b/starlite/handlers/http_handlers/decorators.py
--- a/starlite/handlers/http_handlers/decorators.py
+++ b/starlite/handlers/http_handlers/decorators.py
@@ -14,6 +14,7 @@
     from typing import Any, Mapping
 
     from starlite.background_tasks import BackgroundTask, BackgroundTasks
+    from starlite.config.response_cache import CACHE_FOREVER
     from starlite.datastructures import CacheControlHeader, ETag
     from starlite.di import Provide
     from starlite.openapi.datastructures import ResponseSpec
@@ -32,6 +33,7 @@
         TypeEncodersMap,
     )
 
+
 __all__ = ("get", "head", "post", "put", "patch", "delete")
 
 MSG_SEMANTIC_ROUTE_HANDLER_WITH_HTTP = "semantic route handlers cannot define http_method"
@@ -51,7 +53,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: dict[str, Provide] | None = None,
@@ -204,7 +206,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: dict[str, Provide] | None = None,
@@ -358,7 +360,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: dict[str, Provide] | None = None,
@@ -530,7 +532,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: dict[str, Provide] | None = None,
@@ -683,7 +685,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: dict[str, Provide] | None = None,
@@ -836,7 +838,7 @@ def __init__(
         after_response: AfterResponseHookHandler | None = None,
         background: BackgroundTask | BackgroundTasks | None = None,
         before_request: BeforeRequestHookHandler | None = None,
-        cache: bool | int = False,
+        cache: bool | int | type[CACHE_FOREVER] = False,
         cache_control: CacheControlHeader | None = None,
         cache_key_builder: CacheKeyBuilder | None = None,
         dependencies: dict[str, Provide] | None = None,


Test patch:
diff --git a/tests/test_response_caching.py b/tests/test_response_caching.py
--- a/tests/test_response_caching.py
+++ b/tests/test_response_caching.py
@@ -1,14 +1,15 @@
 import random
 from datetime import timedelta
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Optional
 from unittest.mock import MagicMock
 from uuid import uuid4
 
 import pytest
 
 from starlite import Request, Starlite, get
-from starlite.config.response_cache import ResponseCacheConfig
+from starlite.config.response_cache import CACHE_FOREVER, ResponseCacheConfig
 from starlite.stores.base import Store
+from starlite.stores.memory import MemoryStore
 from starlite.testing import TestClient, create_test_client
 
 if TYPE_CHECKING:
@@ -88,6 +89,42 @@ async def handler() -> str:
         assert mock.call_count == 2
 
 
+@pytest.mark.parametrize("expiration,expected_expiration", [(True, None), (10, 10)])
+def test_default_expiration_none(
+    memory_store: MemoryStore, expiration: int, expected_expiration: Optional[int]
+) -> None:
+    @get("/cached", cache=expiration)
+    def handler() -> None:
+        return None
+
+    app = Starlite(
+        [handler],
+        stores={"response_cache": memory_store},
+        response_cache_config=ResponseCacheConfig(default_expiration=None),
+    )
+
+    with TestClient(app) as client:
+        client.get("/cached")
+
+    if expected_expiration is None:
+        assert memory_store._store["/cached"].expires_at is None
+    else:
+        assert memory_store._store["/cached"].expires_at
+
+
+def test_cache_forever(memory_store: MemoryStore) -> None:
+    @get("/cached", cache=CACHE_FOREVER)
+    async def handler() -> None:
+        return None
+
+    app = Starlite([handler], stores={"response_cache": memory_store})
+
+    with TestClient(app) as client:
+        client.get("/cached")
+
+    assert memory_store._store["/cached"].expires_at is None
+
+
 @pytest.mark.parametrize("sync_to_thread", (True, False))
 async def test_custom_cache_key(sync_to_thread: bool, anyio_backend: str, mock: MagicMock) -> None:
     def custom_cache_key_builder(request: Request) -> str: