Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# FastAPI-CacheX Cache Flow

This document explains in detail how FastAPI-CacheX applies its caching logic to
HTTP requests. All behaviour described here lives in
[`fastapi_cachex/cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/cache.py)
unless stated otherwise.
HTTP requests. The decorator lives in
[`fastapi_cachex/cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/cache.py), and the
parts it calls in private modules beside it:
[`_key_builders.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/_key_builders.py) (cache
keys), [`_vary.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/_vary.py) (`vary=`),
[`_cache_control.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/_cache_control.py)
(`Cache-Control`),
[`_stored_response.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/_stored_response.py)
(storing and replaying a response, ETags and 304s) and
[`_rendering.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/_rendering.py) (running the
handler and adding its dependencies' headers).

## Overall flow

Expand Down
150 changes: 150 additions & 0 deletions fastapi_cachex/_cache_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""The `Cache-Control` header `@cache` sends, and which responses stay private."""

from collections.abc import Iterable
from typing import Literal

from fastapi import Response

from .directives import DirectiveType

_NO_STORE = DirectiveType.NO_STORE.value


class CacheControl:
"""Manages Cache-Control header directives."""

def __init__(self) -> None:
"""Initialize an empty CacheControl instance."""
self.directives: list[str] = []

def add(self, directive: DirectiveType, value: int | None = None) -> None:
"""Add a Cache-Control directive.

Args:
directive: The directive type to add
value: Optional value for the directive
"""
if value is not None:
self.directives.append(f"{directive.value}={value}")
else:
self.directives.append(directive.value)

def __str__(self) -> str:
"""Return the Cache-Control header value as a string."""
return ", ".join(self.directives)


def _build_cache_control(
*,
ttl: int | None,
stale: Literal["error", "revalidate"] | None,
stale_ttl: int | None,
no_cache: bool,
public: bool,
private: bool,
immutable: bool,
must_revalidate: bool,
) -> str:
"""The ``Cache-Control`` value for a ``@cache`` route's arguments.

``no_cache`` sends only ``no-cache`` (plus ``must-revalidate``); otherwise
the directives follow in a fixed order: scope, ``max-age``,
``must-revalidate``, the stale directive, ``immutable``.
"""
cache_control = CacheControl()
if no_cache:
cache_control.add(DirectiveType.NO_CACHE)
if must_revalidate:
cache_control.add(DirectiveType.MUST_REVALIDATE)
return str(cache_control)

# 1. Access scope (public/private)
if public:
cache_control.add(DirectiveType.PUBLIC)
elif private:
cache_control.add(DirectiveType.PRIVATE)

# 2. Cache time settings
if ttl is not None:
cache_control.add(DirectiveType.MAX_AGE, ttl)

# 3. Validation related
if must_revalidate:
cache_control.add(DirectiveType.MUST_REVALIDATE)

# 4. Stale response handling (stale_ttl is validated at decoration time)
if stale == "revalidate":
cache_control.add(DirectiveType.STALE_WHILE_REVALIDATE, stale_ttl)
elif stale == "error":
cache_control.add(DirectiveType.STALE_IF_ERROR, stale_ttl)

# 5. Special flags
if immutable:
cache_control.add(DirectiveType.IMMUTABLE)

return str(cache_control)


# Response directives by which the handler says its response belongs to one
# caller (``private``) or must not be kept at all (``no-store``).
_UNSHAREABLE_DIRECTIVES = frozenset(
{DirectiveType.PRIVATE.value, DirectiveType.NO_STORE.value}
)


def _marked_unshareable(response: Response) -> bool:
"""Whether the handler's own ``Cache-Control`` has ``private`` or ``no-store``.

Directive names are matched as whole tokens, case-insensitively, across
every ``Cache-Control`` field the response carries.
"""
return _has_unshareable_directive(response.headers.getlist("cache-control"))


def _has_unshareable_directive(cache_control: Iterable[str]) -> bool:
"""Whether any of these ``Cache-Control`` values has ``private`` or ``no-store``."""
return any(
directive.split("=", 1)[0].strip().lower() in _UNSHAREABLE_DIRECTIVES
for value in cache_control
for directive in value.split(",")
)


def _unshareable_reason(response: Response) -> str | None:
"""Why a rendered response must not be stored, or None when it may be."""
if _marked_unshareable(response):
return "response Cache-Control is private or no-store"
if "set-cookie" in response.headers:
return "response sets a cookie"
return None


def _cache_control_for(
response: Response, cache_control: str, private_cache_control: str
) -> str:
"""The ``Cache-Control`` to send for a response the handler just rendered.

A handler that marked its response ``private`` or ``no-store`` keeps its own
header; the decorator's would widen what the handler allowed. A response
that sets a cookie gets ``private_cache_control``, so a shared cache in
front of the app does not store it either. When the decorator has no
directive to send (a bare ``@cache()``), the handler's own header is kept,
or none is sent (#363); a cookie still gets ``private_cache_control``.
"""
if _marked_unshareable(response):
return ", ".join(response.headers.getlist("cache-control"))
if "set-cookie" in response.headers:
return private_cache_control
if not cache_control:
return ", ".join(response.headers.getlist("cache-control"))
return cache_control


def _with_cache_control(
response: Response, cache_control: str, private_cache_control: str
) -> Response:
if not _marked_unshareable(response):
value = _cache_control_for(response, cache_control, private_cache_control)
if value:
response.headers["Cache-Control"] = value
return response
17 changes: 17 additions & 0 deletions fastapi_cachex/_callables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Telling whether a callable returns a coroutine, for handlers and key builders."""

import inspect
from collections.abc import Callable


def _is_coroutine_callable(func: Callable[..., object]) -> bool:
"""Report whether calling `func` returns a coroutine.

`inspect.iscoroutinefunction` already sees through `functools.partial`;
an instance with an ``async def __call__`` needs its method checked. The
method is looked up on the type, as the call itself does, so a class
(whose type is ``type``) counts as sync.
"""
return inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(
type(func).__call__
)
175 changes: 175 additions & 0 deletions fastapi_cachex/_key_builders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Cache keys for `@cache`: the default key builder and custom ones."""

import inspect
import logging
from collections.abc import Callable
from collections.abc import Sequence
from functools import partial

from fastapi import Request

from ._callables import _is_coroutine_callable
from .cache_key import CacheKey
from .exceptions import CacheXError
from .types import CACHE_KEY_SEPARATOR
from .types import CacheKeyBuilder
from .types import escape_key_component

# The documented logger: every part of `@cache` logs under this name.
logger = logging.getLogger("fastapi_cachex.cache")


def build_cache_key(
request: Request, *components: str | int, sort_query: bool = True
) -> str:
"""Build the default cache key for ``request``, plus extra components.

With no ``components`` the key is ``http:v2|method|host|path|query``,
exactly what ``@cache`` uses by default. Each extra component is appended
after another separator, so a custom ``key_builder`` can add a dimension
(user ID, tenant, locale) without rebuilding the default key by hand::

def per_user_key(request: Request) -> str:
return build_cache_key(request, request.state.user_id)

The host is the ``Host`` header lower-cased, without an empty or default
port (``:80`` on http, ``:443`` on https), or ``unknown`` when there is
none. ``|`` and ``%`` in the host, the path and every extra component are
percent-encoded (see ``escape_key_component``), so none of them can
contain the separator and make one request's key equal another's. The
query string is already URL-encoded and never contains ``|``.

Keys built this way keep the tag, method, host and path in front, so
``clear_path()`` still finds them and the monitoring routes still show
their method, host, path and query. This is
``CacheKey.from_request(...).to_str()``; ``CacheKey.parse()`` decodes the
key again.

Args:
request: The FastAPI Request object
*components: Extra key components, appended in order. A ``str`` is
used as is and an ``int`` is written in decimal, so ``1`` and
``"1"`` give the same key. An empty string is a component of its
own: ``build_cache_key(request, "")`` differs from
``build_cache_key(request)``.
sort_query: Order the query parameters by name (a stable sort, so
``?tag=b&tag=a`` stays distinct from ``?tag=a&tag=b``), so that
``?a=1&b=2`` and ``?b=2&a=1`` give the same key. On by default,
as in ``@cache``; ``False`` keeps the order the client sent, as
``@cache(sort_query=False)`` does.

Returns:
Generated cache key string

Raises:
TypeError: If a component is not a ``str`` or ``int`` (``bool`` is
rejected too), e.g. ``None`` from a missing user ID, which would
otherwise put every such caller under one ``"None"`` key.
"""
key = CacheKey.from_request(request, *components, sort_query=sort_query).to_str()
logger.debug("Built cache key: %s", key)
return key


def _append_key_components(key: str, components: Sequence[str]) -> str:
"""Append each component to ``key``, escaped, after another separator."""
return CACHE_KEY_SEPARATOR.join(
[key, *(escape_key_component(component) for component in components)]
)


def default_key_builder(request: Request) -> str:
"""Default cache key builder function: ``build_cache_key(request)``.

Generates cache key in format: http:v2|method|host|path|query, with the
query parameters sorted by name.

Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add
components to the default key, call ``build_cache_key`` instead.

Args:
request: The FastAPI Request object

Returns:
Generated cache key string
"""
return build_cache_key(request)


def _unsorted_query_key_builder(request: Request) -> str:
"""The key builder of ``@cache(sort_query=False)``."""
return build_cache_key(request, sort_query=False)


_SORT_QUERY_WITH_KEY_BUILDER_MSG = (
"sort_query only applies to the default key builder; a custom key_builder "
"builds its own key, so pass sort_query to build_cache_key() in it instead "
"(it sorts unless told otherwise)"
)


def _resolve_key_builder(
key_builder: CacheKeyBuilder | None, sort_query: object
) -> CacheKeyBuilder:
"""Pick the key builder for ``key_builder`` and ``sort_query``.

``sort_query=None`` means it was not passed: the default key builder
sorts, and a custom one is used as is.

Raises:
CacheXError: If ``sort_query`` is not a ``bool`` or ``None``, if it
is passed with a custom ``key_builder`` (the flag would silently
do nothing), or if ``key_builder`` is an ``async`` callable.
"""
if sort_query is not None and not isinstance(sort_query, bool):
msg = f"sort_query must be a bool, got {type(sort_query).__name__}"
raise CacheXError(msg)
if key_builder is not None:
if sort_query is not None:
raise CacheXError(_SORT_QUERY_WITH_KEY_BUILDER_MSG)
_validate_key_builder(key_builder)
return key_builder
return _unsorted_query_key_builder if sort_query is False else default_key_builder


_ASYNC_KEY_BUILDER_MSG = (
"key_builder must be a sync function returning str; async key builders "
"are not supported (the key is built without awaiting)"
)


def _validate_key_builder(builder: Callable[..., object]) -> None:
"""Reject a key builder whose call returns a coroutine.

``functools.partial`` layers are unwrapped first, so a partial of an async
callable object is caught as well. A builder this cannot see through
(say a sync wrapper returning a coroutine) is caught by ``_build_key``.

Raises:
CacheXError: If calling ``builder`` would return a coroutine.
"""
target = builder
while isinstance(target, partial):
target = target.func
if _is_coroutine_callable(target):
raise CacheXError(_ASYNC_KEY_BUILDER_MSG)


def _build_key(builder: CacheKeyBuilder, request: Request) -> str:
"""Call ``builder`` and check that it returned a ``str``.

A returned coroutine is closed, so it does not also trigger a "never
awaited" ``RuntimeWarning``. ``fail_open`` does not cover this: it is a
programming error in the route, not a backend failure.

Raises:
CacheXError: If ``builder`` returns anything but a ``str``.
"""
key: object = builder(request)
if isinstance(key, str):
return key
if inspect.iscoroutine(key):
key.close()
raise CacheXError(_ASYNC_KEY_BUILDER_MSG)
msg = f"key_builder must return a str, got {type(key).__name__}"
raise CacheXError(msg)
Loading
Loading