Skip to content

Feat/exclude params - #574

Open
achrafBenHamou wants to merge 2 commits into
long2ice:mainfrom
achrafBenHamou:feat/exclude_params
Open

Feat/exclude params#574
achrafBenHamou wants to merge 2 commits into
long2ice:mainfrom
achrafBenHamou:feat/exclude_params

Conversation

@achrafBenHamou

Copy link
Copy Markdown

Add exclude_params to leave selected arguments out of the cache key

The problem

default_key_builder hashes f"{func.__module__}:{func.__name__}:{args}:{kwargs}", i.e. the repr() of every argument. That is the right default, but it means any argument that is not part of the identity of the result silently breaks caching:

@app.get("/items/{item_id}")
@cache(expire=60)
async def read_item(item_id: int, db: Session = Depends(get_db)) -> Item:
    ...

Session has no __repr__, so its default one contains the object's memory address. Every request produces a different address, therefore a different key: the endpoint is a permanent MISS, and the cache quietly fills with entries nobody will ever read. The same happens with an injected current_user, an httpx/aiobotocore client, or any per-request object. Query parameters that shouldn't split the cache — a request id, a tracing nonce, a cache-buster — have the same effect, only deterministically.

Today the only way out is to write a custom key_builder. But a custom builder receives the whole args/kwargs, so it has to re-implement the default hashing and then hard-code the names to skip — per project, and re-done for every endpoint that needs a different set. That is a lot of boilerplate for "ignore this one argument".

The change

One new optional keyword argument on @cache:

@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:
    ...

The named parameters are dropped from the arguments passed to the key builder, so calls differing only in those values share one entry.

Design notes:

  • Filtering happens before the key builder runs, not inside default_key_builder. So it works with custom key builders too, and no builder has to know about the feature. The one case it cannot cover is a builder that derives its key from the Request itself (request.url, request.query_params) — that is documented in the README section.
  • Positional arguments are handled, matched to their parameter name by position from the wrapped signature; anything absorbed by *args is kept, since it has no name to exclude by.
  • Typos fail at decoration time. A name absent from the signature raises ValueError when the decorator is applied, so a misspelling surfaces at import rather than as a cache that silently never hits. Functions taking **kwargs are exempt from that check, as any name can legitimately be an argument there.
  • Fully opt-in and zero-cost when unused. With exclude_params=None (the default) the new code paths are skipped entirely — if exclude_params: guards both the validation and the filtering. No existing key changes, so no cache invalidation for anyone upgrading.

Scope

  • fastapi_cache/decorator.py: the exclude_params argument plus two small module-level helpers (_check_excluded_params, _exclude_params), in the same style as the existing _locate_param / _uncacheable helpers.
  • No new dependencies, no changes to backends, coders or key builders.
  • Typed throughout, make lint clean (mypy --strict / pyright strict on fastapi_cache).
  • Tests: four cases — keyword arguments through a real endpoint, positional arguments, the unknown-name ValueError, and a **kwargs function.
  • Docs: parameter table row plus a README section with the example above and the custom-key-builder caveat.
  • An /excluded_params route added to the in-memory example, which is what the endpoint test exercises.

Happy to adjust the naming (exclude_params vs. something like key_exclude), to make the unknown-name check a warning instead of an error, or to split the docs out, if you'd prefer any of those differently.

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.
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.
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.

1 participant