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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 54 additions & 2 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down
119 changes: 119 additions & 0 deletions tests/test_cache_rendering.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading