From 66527cd9f87553ae0b0ba39da0c49bc6ebb73b19 Mon Sep 17 00:00:00 2001 From: achrafBenHamou Date: Thu, 20 Aug 2026 14:44:10 +0200 Subject: [PATCH 1/2] fix: restore typed decoding in Coder.decode_as_type decode_as_type stopped converting cached values to the endpoint return type when the Pydantic v1 `fields.ModelField` validation was dropped instead of being ported to v2. Cached hits came back as raw JSON primitives (tuples as lists, dataclasses and BaseModels as dicts) and invalid payloads no longer raised ValidationError. Port the validation to Pydantic v2 `TypeAdapter`, reusing the existing per-type cache, and drop the leftover `ModelField` placeholder. Also fix two broken tests: - test_datetime compared timestamps taken at different times, so restore the microsecond truncation that made the comparison meaningful. - test_non_get sent PUT to /cached_put, a GET-only route, and only asserted on the resulting 405; point it back at /uncached_put. --- fastapi_cache/coder.py | 17 ++++++++++------- tests/test_decorator.py | 14 +++++++------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/fastapi_cache/coder.py b/fastapi_cache/coder.py index 644c1821..2444fe77 100644 --- a/fastapi_cache/coder.py +++ b/fastapi_cache/coder.py @@ -15,15 +15,12 @@ import pendulum from fastapi.encoders import jsonable_encoder +from pydantic import TypeAdapter from starlette.responses import JSONResponse from starlette.templating import ( _TemplateResponse as TemplateResponse, # pyright: ignore[reportPrivateUsage] ) - -class ModelField: - pass - _T = TypeVar("_T", bound=type) @@ -67,12 +64,12 @@ def encode(cls, value: Any) -> bytes: def decode(cls, value: bytes) -> Any: raise NotImplementedError - # (Shared) cache for endpoint return types to Pydantic model fields. + # (Shared) cache for endpoint return types to Pydantic type adapters. # Note that subclasses share this cache! If a subclass overrides the - # decode_as_type method and then stores a different kind of field for a + # decode_as_type method and then stores a different kind of adapter for a # given type, do make sure that the subclass provides its own class # attribute for this cache. - _type_field_cache: ClassVar[Dict[Any, ModelField]] = {} + _type_field_cache: ClassVar[Dict[Any, TypeAdapter[Any]]] = {} @overload @classmethod @@ -92,6 +89,12 @@ def decode_as_type(cls, value: bytes, *, type_: Optional[_T]) -> Union[_T, Any]: """ result = cls.decode(value) + if type_ is not None: + try: + type_adapter = cls._type_field_cache[type_] + except KeyError: + type_adapter = cls._type_field_cache[type_] = TypeAdapter(type_) + result = type_adapter.validate_python(result) return result diff --git a/tests/test_decorator.py b/tests/test_decorator.py index abd6390f..3553feb1 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -22,19 +22,19 @@ def test_datetime() -> None: response = client.get("/datetime") assert response.headers.get("X-FastAPI-Cache") == "MISS" now = response.json().get("now") - now_ = pendulum.now() - assert pendulum.parse(now) == now_ + now_ = pendulum.now().replace(microsecond=0) + assert pendulum.parse(now).replace(microsecond=0) == now_ 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).replace(microsecond=0) == now_ time.sleep(3) response = client.get("/datetime") now = response.json().get("now") assert response.headers.get("X-FastAPI-Cache") == "MISS" - now = pendulum.parse(now) + now = pendulum.parse(now).replace(microsecond=0) assert now != now_ - assert now == pendulum.now() + assert now == pendulum.now().replace(microsecond=0) def test_date() -> None: @@ -99,10 +99,10 @@ def test_pydantic_model() -> None: def test_non_get() -> None: with TestClient(app) as client: - response = client.put("/cached_put") + response = client.put("/uncached_put") assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 1} - response = client.put("/cached_put") + response = client.put("/uncached_put") assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 2} From 792841ce6e1e96a22c769a60459e4086a22913d4 Mon Sep 17 00:00:00 2001 From: achrafBenHamou Date: Thu, 20 Aug 2026 14:44:10 +0200 Subject: [PATCH 2/2] feat: add exclude_params to leave arguments out of the cache key Some arguments should not split one logical result into separate cache entries: a database session, an authenticated user, a request id or a tracing nonce. Listing their names in exclude_params drops them from the arguments handed to the key builder, so calls differing only in those values share a single entry. The filtering runs before the key builder, so it applies to the default builder and custom ones alike. Positional arguments are matched to their parameter name by position; anything absorbed by a variadic positional parameter is kept. Names absent from the decorated signature raise a ValueError at decoration time so typos surface at import, except for functions taking **kwargs, where any name can be valid. Adds an /excluded_params route to the in-memory example, tests covering keyword, positional, unknown-name and **kwargs cases, and README docs. --- README.md | 29 +++++++++++++++++ examples/in_memory/main.py | 14 +++++++- fastapi_cache/decorator.py | 67 ++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- tests/test_decorator.py | 59 ++++++++++++++++++++++++++++++++- 5 files changed, 166 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 79aab868..f02c9735 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Parameter | type | default | description `coder` | `Coder` | `JsonCoder` | which coder to use, e.g. `JsonCoder` `key_builder` | `KeyBuilder` callable | `default_key_builder` | which key builder to use `injected_dependency_namespace` | `str` | `__fastapi_cache` | prefix for injected dependency keywords. +`exclude_params` | `Collection[str]` | `None` | names of parameters to leave out of the cache key `cache_status_header` | `str` | `X-FastAPI-Cache` | Name for the header on the response indicating if the request was served from cache; either `HIT` or `MISS`. You can also use the `@cache` decorator on regular functions to cache their result. @@ -120,6 +121,34 @@ Use the `injected_dependency_namespace` argument to `@cache` to change the prefix used if those names would clash anyway. +### Excluding parameters from the cache key + +Some arguments should not make two calls into two separate cache entries: a +database session, an authenticated user, a request id, or a tracing nonce. List +their names in `exclude_params` and they are dropped from the arguments handed +to the key builder, so calls differing only in those values share one entry. + +```python +@app.get("/items/{item_id}") +@cache(expire=60, exclude_params=["db", "trace_id"]) +async def read_item( + item_id: int, + trace_id: str = "", + db: Session = Depends(get_db), +) -> Item: + return get_item(db, item_id) +``` + +The filtering happens before the key builder runs, so it applies to the default +key builder and to custom ones alike. Note that a custom key builder deriving +its key from the request itself (for example from `request.url` or +`request.query_params`) never sees the filtered arguments and has to do its own +exclusion. + +Names that do not appear in the decorated function's signature raise a +`ValueError` at decoration time, so typos surface at import. Functions +accepting `**kwargs` are exempt from that check, as any name can be valid there. + ### Supported data types When using the (default) `JsonCoder`, the cache can store any data type that FastAPI can convert to JSON, including Pydantic models and dataclasses, diff --git a/examples/in_memory/main.py b/examples/in_memory/main.py index f4de1a0b..f54eb16a 100644 --- a/examples/in_memory/main.py +++ b/examples/in_memory/main.py @@ -1,6 +1,6 @@ # pyright: reportGeneralTypeIssues=false from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Optional import pendulum import uvicorn @@ -136,5 +136,17 @@ def namespaced_injection( } +exclude_ret = 0 + + +@app.get("/excluded_params") +@cache(namespace="test", expire=5, exclude_params=["nonce"]) +async def excluded_params(name: str, nonce: str = "") -> Dict[str, Any]: + # calls that only differ in `nonce` all share one cache entry + global exclude_ret + exclude_ret = exclude_ret + 1 + return {"name": name, "nonce": nonce, "value": exclude_ret} + + if __name__ == "__main__": uvicorn.run("main:app", reload=True) diff --git a/fastapi_cache/decorator.py b/fastapi_cache/decorator.py index 7df09e88..23841fb6 100644 --- a/fastapi_cache/decorator.py +++ b/fastapi_cache/decorator.py @@ -3,10 +3,14 @@ from functools import wraps from inspect import Parameter, Signature, isawaitable, iscoroutinefunction from typing import ( + Any, Awaitable, Callable, + Collection, + Dict, List, Optional, + Tuple, Type, TypeVar, Union, @@ -66,6 +70,52 @@ def _locate_param( return param +def _check_excluded_params(sig: Signature, exclude_params: Collection[str]) -> None: + """Verify that the excluded parameter names exist on the decorated function + + Functions accepting arbitrary keyword arguments are exempt, as any name + could be a valid argument for those. + + """ + if any(p.kind is Parameter.VAR_KEYWORD for p in sig.parameters.values()): + return + unknown = sorted(set(exclude_params) - set(sig.parameters)) + if unknown: + raise ValueError( + f"exclude_params contains parameters not present in the signature: " + f"{', '.join(unknown)}" + ) + + +def _exclude_params( + sig: Signature, + exclude_params: Collection[str], + args: Tuple[Any, ...], + kwargs: Dict[str, Any], +) -> Tuple[Tuple[Any, ...], Dict[str, Any]]: + """Drop the named parameters from the arguments used to build the cache key + + Arguments passed positionally are matched to their parameter name by + position; anything absorbed by a variadic positional parameter is kept. + + """ + filtered_kwargs = {k: v for k, v in kwargs.items() if k not in exclude_params} + if not args: + return args, filtered_kwargs + + positional = [ + p.name + for p in sig.parameters.values() + if p.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + ] + filtered_args = tuple( + value + for i, value in enumerate(args) + if i >= len(positional) or positional[i] not in exclude_params + ) + return filtered_args, filtered_kwargs + + def _uncacheable(request: Optional[Request]) -> bool: """Determine if this request should not be cached @@ -90,6 +140,7 @@ def cache( key_builder: Optional[KeyBuilder] = None, namespace: str = "", injected_dependency_namespace: str = "__fastapi_cache", + exclude_params: Optional[Collection[str]] = None, ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Union[R, Response]]]]: """ cache all function @@ -98,6 +149,9 @@ def cache( :param expire: :param coder: :param key_builder: + :param exclude_params: names of parameters to leave out of the cache key, so + that calls differing only in those values share a cache entry. Applied + before the key builder runs, so it works with custom key builders too. :return: """ @@ -122,6 +176,8 @@ def wrapper( request_param = _locate_param(wrapped_signature, injected_request, to_inject) response_param = _locate_param(wrapped_signature, injected_response, to_inject) return_type = get_typed_return_annotation(func) + if exclude_params: + _check_excluded_params(wrapped_signature, exclude_params) @wraps(func) async def inner(*args: P.args, **kwargs: P.kwargs) -> Union[R, Response]: @@ -162,13 +218,20 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R: backend = FastAPICache.get_backend() cache_status_header = FastAPICache.get_cache_status_header() + key_args: Tuple[Any, ...] = args + key_kwargs: Dict[str, Any] = copy_kwargs + if exclude_params: + key_args, key_kwargs = _exclude_params( + wrapped_signature, exclude_params, args, copy_kwargs + ) + cache_key = key_builder( func, f"{prefix}:{namespace}", request=request, response=response, - args=args, - kwargs=copy_kwargs, + args=key_args, + kwargs=key_kwargs, ) if isawaitable(cache_key): cache_key = await cache_key diff --git a/pyproject.toml b/pyproject.toml index a1248490..bf2f16d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fastapi-cache2" -version = "0.2.2" +version = "0.2.3" description = "Cache for FastAPI" authors = ["long2ice "] license = "Apache-2.0" diff --git a/tests/test_decorator.py b/tests/test_decorator.py index 3553feb1..ef0de55b 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -1,5 +1,6 @@ +import asyncio import time -from typing import Any, Generator +from typing import Any, Dict, Generator, List, Tuple import pendulum import pytest @@ -8,6 +9,7 @@ from examples.in_memory.main import app from fastapi_cache import FastAPICache from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache @pytest.fixture(autouse=True) @@ -135,3 +137,58 @@ def test_cache_control() -> None: response = client.get("/cached_put") assert response.json() == {"value": 2} + + +def test_exclude_params() -> None: + """Parameters listed in exclude_params are left out of the cache key.""" + with TestClient(app) as client: + response = client.get("/excluded_params", params={"name": "Jon", "nonce": "a"}) + assert response.headers.get("X-FastAPI-Cache") == "MISS" + assert response.json() == {"name": "Jon", "nonce": "a", "value": 1} + + # a different nonce hits the same cache entry + response = client.get("/excluded_params", params={"name": "Jon", "nonce": "b"}) + assert response.headers.get("X-FastAPI-Cache") == "HIT" + assert response.json() == {"name": "Jon", "nonce": "a", "value": 1} + + # a different name is still a distinct entry + response = client.get("/excluded_params", params={"name": "Ben", "nonce": "b"}) + assert response.headers.get("X-FastAPI-Cache") == "MISS" + assert response.json() == {"name": "Ben", "nonce": "b", "value": 2} + + +def test_exclude_params_positional() -> None: + """Positional arguments are matched to their parameter name by position.""" + calls: List[Tuple[int, int]] = [] + + @cache(namespace="test", expire=5, exclude_params=["b"]) + async def func(a: int, b: int) -> int: + calls.append((a, b)) + return a + + assert asyncio.run(func(1, 2)) == 1 + assert asyncio.run(func(1, 3)) == 1 + assert calls == [(1, 2)] + + assert asyncio.run(func(4, 3)) == 4 + assert calls == [(1, 2), (4, 3)] + + +def test_exclude_params_unknown_name() -> None: + """A typo in exclude_params is reported when the function is decorated.""" + with pytest.raises(ValueError, match="nonexistent"): + + @cache(namespace="test", exclude_params=["nonexistent"]) + async def func(a: int) -> int: + return a + + +def test_exclude_params_var_keyword() -> None: + """Functions taking **kwargs accept any excluded name.""" + + @cache(namespace="test", expire=5, exclude_params=["nonce"]) + async def func(**kwargs: Any) -> Dict[str, Any]: + return kwargs + + assert asyncio.run(func(name="Jon", nonce="a")) == {"name": "Jon", "nonce": "a"} + assert asyncio.run(func(name="Jon", nonce="b")) == {"name": "Jon", "nonce": "a"}