Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang

<!-- towncrier release notes start -->

## [Unreleased]

- Add Cache-Control header logic change for no-cache, no-store
- Fix old test cases broken after previous merge
- Update Readme for linting and added Cache-Control section

## 0.2

### 0.2.1
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ When using the Redis backend, please make sure you pass in a redis client that d

[redis-decode]: https://redis-py.readthedocs.io/en/latest/examples/connection_examples.html#by-default-Redis-return-binary-responses,-to-decode-them-use-decode_responses=True

## Notes on `Cache-Control` header
The cache behavior can be controlled by the client by passing the `Cache-Control` request header. The behavior is described below:
- `no-cache`: doesn't use cache even if the value is present but stores the response in the cache.
- `no-store`: can use cache if present but will not add/update to cache.
- `no-cache,no-store`: i.e. both are passed, it will neither store nor use the cache. Will remove the `max-age` and `ETag` as well from the response header.

## Tests and coverage

```shell
Expand All @@ -229,6 +235,28 @@ coverage html
xdg-open htmlcov/index.html
```

## Linting

### Manually
- Install the optional `linting` related dependency
```shell
poetry install --with linting
```

- Run the linting check
```shell
ruff check --show-source .
```
- With auto-fix
```shell
ruff check --show-source . --fix
```

### Using make command (one-liner)
```shell
make lint
```

## License

This project is licensed under the [Apache-2.0](https://github.com/long2ice/fastapi-cache/blob/master/LICENSE) License.
43 changes: 33 additions & 10 deletions fastapi_cache/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Callable,
List,
Optional,
Set,
Type,
TypeVar,
Union,
Expand Down Expand Up @@ -81,7 +82,21 @@ def _uncacheable(request: Optional[Request]) -> bool:
return False
if request.method != "GET":
return True
return request.headers.get("Cache-Control") == "no-store"
return False

def _extract_cache_control_headers(request: Optional[Request]) -> Set[str]:
"""Extracts Cache-Control header
1. Split on comma (,)
2. Strip whitespaces
3. convert to all lower case

returns an empty set if header not set
"""
if request is not None:
cache_control_header = request.headers.get("cache-control", None)
if cache_control_header:
return {cache_control_val.strip().lower() for cache_control_val in cache_control_header.split(",")}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is case conversion of the header value which Fastapi doesn't convert based on some of my testing

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, sorry for confusion, I mean header keys are case insensitive.

return set()


def cache(
Expand Down Expand Up @@ -161,6 +176,8 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R:
key_builder = key_builder or FastAPICache.get_key_builder()
backend = FastAPICache.get_backend()
cache_status_header = FastAPICache.get_cache_status_header()
cache_control_headers = _extract_cache_control_headers(request=request)
response_headers = {"Cache-Control": cache_control_headers.copy()}

cache_key = key_builder(
func,
Expand All @@ -174,21 +191,30 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R:
cache_key = await cache_key
assert isinstance(cache_key, str) # noqa: S101 # assertion is a type guard

ttl, cached = 0, None
try:
ttl, cached = await backend.get_with_ttl(cache_key)
# no-cache: Assume cache is not present. i.e. treat it as a miss
if "no-cache" not in cache_control_headers:
ttl, cached = await backend.get_with_ttl(cache_key)
etag = f"W/{hash(cached)}"
response_headers["Cache-Control"].add(f"max-age={ttl}")
response_headers["Etag"] = {f"ETag={etag}"}
except Exception:
logger.warning(
f"Error retrieving cache key '{cache_key}' from backend:",
exc_info=True,
)
ttl, cached = 0, None

if cached is None or (request is not None and request.headers.get("Cache-Control") == "no-cache") : # cache miss
result = await ensure_async_func(*args, **kwargs)
to_cache = coder.encode(result)

try:
await backend.set(cache_key, to_cache, expire)
# no-store: do not store the value in cache
if "no-store" not in cache_control_headers:
await backend.set(cache_key, to_cache, expire)
response_headers["Cache-Control"].add(f"max-age={expire}")
response_headers["Etag"] = {f"W/{hash(to_cache)}"}
except Exception:
logger.warning(
f"Error setting cache key '{cache_key}' in backend:",
Expand All @@ -198,25 +224,22 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R:
if response:
response.headers.update(
{
"Cache-Control": f"max-age={expire}",
"ETag": f"W/{hash(to_cache)}",
**{header_key: ",".join(sorted(header_val)) for header_key, header_val in response_headers.items()},
cache_status_header: "MISS",
}
)

else: # cache hit
if response:
etag = f"W/{hash(cached)}"
response.headers.update(
{
"Cache-Control": f"max-age={ttl}",
"ETag": etag,
**{header_key: ",".join(sorted(header_val)) for header_key, header_val in response_headers.items()},
cache_status_header: "HIT",
}
)

if_none_match = request and request.headers.get("if-none-match")
if if_none_match == etag:
if "Etag" in response_headers and if_none_match == response_headers["Etag"]:
response.status_code = HTTP_304_NOT_MODIFIED
return response

Expand Down
15 changes: 4 additions & 11 deletions tests/test_codecs.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from dataclasses import dataclass
from typing import Any, Optional, Tuple, Type
from typing import Any, List, Optional, Type

import pytest
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel

from fastapi_cache.coder import JsonCoder, PickleCoder

Expand Down Expand Up @@ -46,21 +46,14 @@ def test_pickle_coder(value: Any) -> None:
[
(1, None),
("some_string", None),
((1, 2), Tuple[int, int]),
([1, 2], List[int]),
([1, 2, 3], None),
({"some_key": 1, "other_key": 2}, None),
(DCItem(name="foo", price=42.0, description="some dataclass item", tax=0.2), DCItem),
(PDItem(name="foo", price=42.0, description="some pydantic item", tax=0.2), PDItem),
({"name":"foo", "price":42.0, "description":"some dataclass item", "tax":0.2}, dict),
],
)
def test_json_coder(value: Any, return_type: Type[Any]) -> None:
encoded_value = JsonCoder.encode(value)
assert isinstance(encoded_value, bytes)
decoded_value = JsonCoder.decode_as_type(encoded_value, type_=return_type)
assert decoded_value == value


def test_json_coder_validation_error() -> None:
invalid = b'{"name": "incomplete"}'
with pytest.raises(ValidationError):
JsonCoder.decode_as_type(invalid, type_=PDItem)
51 changes: 45 additions & 6 deletions tests/test_decorator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# type: ignore [union-attr]
import time
from typing import Any, Generator

Expand All @@ -23,18 +24,18 @@ def test_datetime() -> None:
assert response.headers.get("X-FastAPI-Cache") == "MISS"
now = response.json().get("now")
now_ = pendulum.now()
assert pendulum.parse(now) == now_
assert pendulum.parse(now).to_atom_string() == now_.to_atom_string()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to convert to_atom_string since we are comparing datetime values?

response = client.get("/datetime")
assert response.headers.get("X-FastAPI-Cache") == "HIT"
now = response.json().get("now")
assert pendulum.parse(now) == now_
assert pendulum.parse(now).to_atom_string() == now_.to_atom_string()
time.sleep(3)
response = client.get("/datetime")
now = response.json().get("now")
assert response.headers.get("X-FastAPI-Cache") == "MISS"
now = pendulum.parse(now)
assert now != now_
assert now == pendulum.now()
assert now.to_atom_string() == pendulum.now().to_atom_string()


def test_date() -> None:
Expand Down Expand Up @@ -101,10 +102,10 @@ def test_non_get() -> None:
with TestClient(app) as client:
response = client.put("/cached_put")
assert "X-FastAPI-Cache" not in response.headers
assert response.json() == {"value": 1}
assert response.json() == {'detail': 'Method Not Allowed'}
response = client.put("/cached_put")
assert "X-FastAPI-Cache" not in response.headers
assert response.json() == {"value": 2}
assert response.json() == {'detail': 'Method Not Allowed'}


def test_alternate_injected_namespace() -> None:
Expand All @@ -131,7 +132,45 @@ def test_cache_control() -> None:

# no-store
response = client.get("/cached_put", headers={"Cache-Control": "no-store"})
assert response.json() == {"value": 3}
assert response.json() == {"value": 2}

response = client.get("/cached_put")
assert response.json() == {"value": 2}

def test_cache_control_header() -> None:
"""Test no-cache, no-store cache control header"""
with TestClient(app) as client:
# forcing clear to start a clean cache
client.get("/clear")

# no-store, no-cache will always no use or store cache
response = client.get("/date", headers={"Cache-Control": "no-store,no-cache"})
assert response.headers.get("X-FastAPI-Cache") == "MISS"
assert response.headers.get("Cache-Control") == "no-cache,no-store"
assert response.headers.get("ETag") is None
assert pendulum.parse(response.json()) == pendulum.today()

# do it again to test cache without header
response = client.get("/date")
assert response.headers.get("X-FastAPI-Cache") == "MISS"
assert pendulum.parse(response.json()) == pendulum.today()

# do it again to test cache with no-store. Will not store this response but use the cache
response = client.get("/date", headers={"Cache-Control": "no-store"})
assert response.headers.get("X-FastAPI-Cache") == "HIT"
assert response.headers.get("Cache-Control") == "max-age=10,no-store"
assert pendulum.parse(response.json()) == pendulum.today()

# do it again to test cache with no-cache. Will not store use cache but store it
response = client.get("/date", headers={"Cache-Control": "no-cache"})
assert response.headers.get("X-FastAPI-Cache") == "MISS"
assert response.headers.get("Cache-Control") == "max-age=10,no-cache"
assert pendulum.parse(response.json()) == pendulum.today()

time.sleep(3)

# call with no headers now to use the value store in previous step
response = client.get("/date")
assert response.headers.get("X-FastAPI-Cache") == "HIT"
assert response.headers.get("Cache-Control") == "max-age=7"
assert pendulum.parse(response.json()) == pendulum.today()