Skip to content

Add no-store, no-cache in Cache-Control headers #373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
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
Loading
Loading