Skip to content

fix(cache): render non-Response results the way FastAPI does - #135

Merged
allen0099 merged 1 commit into
masterfrom
fix/cache-response-rendering
Sep 25, 2026
Merged

allen0099 merged 1 commit into
masterfrom
fix/cache-response-rendering

Conversation

@allen0099

Copy link
Copy Markdown
Owner

Closes #99.

Problem

When a @cache handler returned plain data, get_response() built the response with response_class(content=result) and handed FastAPI a finished Response, so FastAPI skipped its own handling of the return value:

  • A Pydantic model, datetime or UUID failed to encode (500), and response_model filtering never ran.
  • @app.get(..., status_code=202) answered 200.
  • Status and headers set on an injected response: Response were lost.

Change

In get_response(), for non-Response results:

  • Response model: new _serialize_result(). When the route has a response model (declared, or inferred from the return annotation), the result is validated with a pydantic.TypeAdapter, using from_attributes=True so a different model such as UserIn → UserOut works. It is then dumped with mode="json" and the route's response_model_include/exclude/by_alias/exclude_unset/exclude_defaults/exclude_none. Otherwise the result goes through jsonable_encoder.
  • Adapter reuse: APIRoute is unhashable, so the adapter is stored once per route as an attribute instead of being kept in a dict.
  • Status code: the route's status_code applies, overridden by the injected sub-response's status when the handler set one. This matches FastAPI's _build_response_args. A status that allows no body (for example 204) gets an empty body.
  • Headers: the sub-response is the Response instance among the handler's kwargs, and its raw headers are appended.

Cache entries already store the status code and headers, so hits replay them. Set-Cookie is still never stored.

I used public APIs (TypeAdapter, jsonable_encoder, fastapi.utils.is_body_allowed_for_status_code) rather than calling fastapi.routing.serialize_response. Its signature changes between FastAPI releases, and the dependency is unpinned.

Tests

The new tests/test_cache_rendering.py covers:

  • non-JSON types with and without a response model, on both miss and hit;
  • response-model filtering with response_model_exclude_none;
  • the route's status_code on miss and hit;
  • a 204 with an empty body;
  • an injected response's header and status on miss and hit.

All 5 fail against the old get_response().

Checks

  • ruff check / format on fastapi_cachex, tests and scripts
  • mypy strict on the package; mypy on tests and scripts
  • pytest against live Redis and Memcached (CACHEX_REQUIRE_LIVE_SERVERS=1): 725 passed, 100% coverage
  • zensical build --strict

docs/HTTP_CACHING.md gains a paragraph on how plain return values are rendered. The CHANGELOG entry is at the top of ### Fixed, a spot the open PRs #132 and #133 don't touch.

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
@allen0099
allen0099 merged commit 04f1834 into master Sep 25, 2026
10 checks passed
@allen0099
allen0099 deleted the fix/cache-response-rendering branch September 26, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@cache bypasses FastAPI's response serialization for non-Response results

1 participant