From d3daf87be389f254ce5f8500f2107ac0ddcc2ae1 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Fri, 25 Sep 2026 11:02:02 +0000 Subject: [PATCH] fix(cache): render non-Response results the way FastAPI does get_response() built the response with response_class(content=result), which skipped the response model, the route's status_code and anything set on an injected response: Response. Serialize through the response model (or jsonable_encoder), apply the route/sub-response status, blank the body for statuses that allow none, and merge the sub-response headers. Closes #99 --- CHANGELOG.md | 9 +++ docs/HTTP_CACHING.md | 6 ++ fastapi_cachex/cache.py | 56 +++++++++++++++- tests/test_cache_rendering.py | 119 ++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 tests/test_cache_rendering.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cdfb8ed..64e1253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,15 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. ### Fixed +- A `@cache` handler that returns plain data instead of a `Response` is + rendered the way FastAPI renders it. The result goes through the route's + response model (validation, field filtering and the `response_model_*` + options) or `jsonable_encoder`, so a Pydantic model, `datetime` or `UUID` no + longer fails to encode. The route's `status_code` applies (a `204` drops the + body), and the status and headers set on an injected `response: Response` + are kept, on cache hits as well. + ([#99](https://github.com/allen0099/FastAPI-CacheX/issues/99)) + - A sync (`def`) handler under `@cache` runs in the threadpool again. The cache wrapper is `async`, so FastAPI stopped offloading the handler and `@cache` called it on the event loop, where blocking I/O stalled every other diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 48ca926..3f8b3a3 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -74,6 +74,12 @@ good entry. `206 Partial Content` is excluded as well, since its body is only meaningful for the `Range` request that produced it. `Set-Cookie` is never stored or replayed. +A handler that returns plain data instead of a `Response` gets the same +treatment it would without `@cache`: the value is validated and filtered by the +route's response model (declared or inferred from the return annotation, with +the `response_model_*` options), the route's `status_code` applies, and the +status and headers set on an injected `response: Response` parameter are kept. + ## Cache keys Cache keys are generated in the following format to avoid collisions: diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 3f86f58..681f513 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -21,6 +21,9 @@ from fastapi import Request from fastapi import Response +from fastapi.encoders import jsonable_encoder +from fastapi.utils import is_body_allowed_for_status_code +from pydantic import TypeAdapter from starlette.concurrency import run_in_threadpool from starlette.status import HTTP_200_OK from starlette.status import HTTP_206_PARTIAL_CONTENT @@ -304,6 +307,38 @@ async def _render( return response, body, None if body is None else _etag_for(body) +# Attribute a route's response-model TypeAdapter is kept under, so it is built +# once per route rather than on every cache miss. +_ADAPTER_ATTR = "_cachex_response_adapter" + + +def _serialize_result(route: "APIRoute", result: object) -> object: + """JSON-compatible content for a handler's non-Response return value. + + With a response model (declared, or inferred from the return annotation) + the result is validated against it and dumped with the route's + ``response_model_*`` options, so fields the model leaves out are dropped. + Otherwise it goes through ``jsonable_encoder``, as FastAPI does. + """ + if route.response_model is None: + return jsonable_encoder(result) + adapter: TypeAdapter[Any] | None = getattr(route, _ADAPTER_ATTR, None) + if adapter is None: + adapter = TypeAdapter(route.response_model) + setattr(route, _ADAPTER_ATTR, adapter) + validated = adapter.validate_python(result, from_attributes=True) + return adapter.dump_python( + validated, + mode="json", + include=route.response_model_include, + exclude=route.response_model_exclude, + by_alias=route.response_model_by_alias, + exclude_unset=route.response_model_exclude_unset, + exclude_defaults=route.response_model_exclude_defaults, + exclude_none=route.response_model_exclude_none, + ) + + def _is_coroutine_callable(func: HandlerCallable) -> bool: """Report whether calling `func` returns a coroutine. @@ -366,8 +401,25 @@ async def get_response( ), ) - # Convert non-Response result to Response using appropriate response_class - return response_class(content=result) + # Build the response the way FastAPI would have without the cache wrapper: + # serialize through the response model, apply the route's status code and + # carry over what the handler set on an injected `response: Response`. + sub_response = next( + (value for value in kwargs.values() if isinstance(value, Response)), None + ) + status_code = route.status_code + if sub_response is not None and sub_response.status_code: + status_code = sub_response.status_code + response_args: dict[str, Any] = {} + if status_code is not None: + response_args["status_code"] = status_code + + response = response_class(_serialize_result(route, result), **response_args) + if not is_body_allowed_for_status_code(response.status_code): + response.body = b"" + if sub_response is not None: + response.headers.raw.extend(sub_response.headers.raw) + return response def cache( diff --git a/tests/test_cache_rendering.py b/tests/test_cache_rendering.py new file mode 100644 index 0000000..4699266 --- /dev/null +++ b/tests/test_cache_rendering.py @@ -0,0 +1,119 @@ +"""@cache builds non-Response results the way FastAPI would (#99).""" + +from datetime import datetime +from uuid import UUID + +from fastapi import FastAPI +from fastapi import Response +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from fastapi_cachex.cache import cache + +EVENT_ID = UUID("12345678-1234-5678-1234-567812345678") +EVENT_AT = datetime(2026, 9, 25, 12, 0) # noqa: DTZ001 - naive encodes the same both ways + + +class Event(BaseModel): + id: UUID + at: datetime + + +class UserIn(BaseModel): + name: str + password: str + + +class UserOut(BaseModel): + name: str + nickname: str | None = None + + +def test_pydantic_result_with_non_json_types_is_encoded() -> None: + app = FastAPI() + + @app.get("/event") + @cache(ttl=60) + async def event() -> Event: + return Event(id=EVENT_ID, at=EVENT_AT) + + @app.get("/raw") + @cache(ttl=60) + async def raw(): # unannotated, so there is no response model + return {"id": EVENT_ID, "at": EVENT_AT} + + expected = {"id": str(EVENT_ID), "at": "2026-09-25T12:00:00"} + with TestClient(app) as client: + for path in ("/event", "/raw"): + first = client.get(path) + assert first.status_code == 200 + assert first.json() == expected + # The second request is a cache hit and must match the miss. + assert client.get(path).json() == expected + + +def test_response_model_filters_fields() -> None: + app = FastAPI() + + @app.get("/user", response_model=UserOut, response_model_exclude_none=True) + @cache(ttl=60) + async def user() -> UserIn: + return UserIn(name="alice", password="hunter2") + + with TestClient(app) as client: + response = client.get("/user") + + assert response.status_code == 200 + assert response.json() == {"name": "alice"} + + +def test_route_status_code_is_used() -> None: + app = FastAPI() + + @app.get("/accepted", status_code=202) + @cache(ttl=60) + async def accepted() -> dict[str, str]: + return {"queued": "yes"} + + with TestClient(app) as client: + miss = client.get("/accepted") + hit = client.get("/accepted") + + assert miss.status_code == 202 + assert hit.status_code == 202 + assert hit.json() == {"queued": "yes"} + + +def test_no_content_status_drops_the_body() -> None: + app = FastAPI() + + @app.get("/empty", status_code=204) + @cache(ttl=60) + async def empty() -> None: + return None + + with TestClient(app) as client: + response = client.get("/empty") + + assert response.status_code == 204 + assert response.content == b"" + + +def test_injected_response_headers_and_status_are_kept() -> None: + app = FastAPI() + + @app.get("/tagged") + @cache(ttl=60) + async def tagged(response: Response) -> dict[str, str]: + response.headers["X-Tag"] = "blue" + response.status_code = 203 + return {"tag": "blue"} + + with TestClient(app) as client: + miss = client.get("/tagged") + hit = client.get("/tagged") + + for response in (miss, hit): + assert response.status_code == 203 + assert response.headers["x-tag"] == "blue" + assert response.json() == {"tag": "blue"}