diff --git a/README.md b/README.md index 4d32a0a..9d1db49 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ async def get_product(product_id: int): return await db.get_product(product_id) ``` -`cache_evict()` and `cache_put()` handle invalidation and write-through with matching keys, and `CacheBackendDep` exposes imperative `get`/`set`/`delete`/`has`/`delete_group` for conditional logic. Cached responses carry `X-Redis-Cache` (HIT/MISS), `Cache-Control`, and `ETag` headers with 304 Not Modified support. +`cache_evict()` and `cache_put()` handle invalidation and write-through with matching keys, and `CacheBackendDep` exposes imperative `get`/`set`/`delete`/`has`/`delete_group` for conditional logic. Cached responses carry `X-Redis-Cache` (`HIT`/`MISS`/`BYPASS`), `Cache-Control` and `ETag` headers, with `304 Not Modified` for both `If-None-Match` and `If-Modified-Since`, and replay the response's own header fields on a hit. See the [Caching Guide](docs/guide/caching.md) for the full patterns, `CacheBackend` usage, Pydantic model caching, feature comparison, and best practices. diff --git a/docs/api/configuration.md b/docs/api/configuration.md index 5662072..b0a4c70 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -58,7 +58,7 @@ Returns the full prefix for a given pattern name. ```python settings = get_settings() -settings.pattern_prefix("cache") # "redis:fastapi:cache" +settings.pattern_prefix("cache") # "redis:fastapi:cache" ``` ## `get_settings()` diff --git a/docs/api/reference.md b/docs/api/reference.md index 52e8f82..1373b1b 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -113,7 +113,9 @@ async def get_items(): ... ``` -On a **cache hit** the endpoint is skipped (response served from Redis). On a **miss** the response is captured and stored. Adds `X-Redis-Cache` (HIT/MISS), `Cache-Control`, and `ETag` headers with 304 Not Modified support. +On a **cache hit** the endpoint is skipped (response served from Redis). On a **miss** the response is captured and stored. Adds `X-Redis-Cache` (`HIT`/`MISS`/`BYPASS`), `Cache-Control` and `ETag` headers, with `304 Not Modified` for `If-None-Match` and `If-Modified-Since`. + +An entry stores the response's own header fields and replays them on a hit, so `Link`, `Content-Disposition`, `Content-Language` and your own headers survive. Only text representations are stored, and `Date` and `Set-Cookie` are deliberately withheld — see [Cache scope](../guide/architecture.md#cache-scope) and [Headers a hit carries](../guide/architecture.md#headers-a-hit-carries). | Parameter | Type | Default | Description | |-----------|------|---------|-------------| diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index f978d51..639b8b1 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -273,6 +273,240 @@ Cache *reads* and short-circuiting happen entirely in the DI layer via --- +## Cache scope + +`cache()` stores **text representations** — JSON, but also plain text, HTML, +CSV, XML and JavaScript. The word JSON describes the *envelope*, not what you +may return: an entry is a JSON document whose `body` is a text field, stored +alongside the response's own header fields — so the media type it was served +as, and the rest of its metadata, are replayed on a hit. See +[Headers a hit carries](#headers-a-hit-carries). + +Unstructured payloads are covered. A FastAPI endpoint that returns a bare +string or a number sends it as JSON — `return "hello"` goes over the wire as +`"hello"` with `Content-Type: application/json` — so scalars need no special +handling; they fall under the `application/json` row below. + +The content type decides whether we store, and the library checks it against +an allowlist, **refusing anything it does not recognise** rather than storing +a representation it cannot reproduce: + +| Stored | Refused | +|------------------------------------------------------|------------------------------------------------------------------------------------------------| +| `application/json` | `application/octet-stream`, `application/pdf`, `application/msgpack`, `application/x-protobuf` | +| Anything `application/…+json` or `application/…+xml` | `image/*`, `audio/*`, `video/*` — including `image/svg+xml` | +| `application/xml`, `application/javascript` | `multipart/*` | +| Any `text/*` | A response with no `Content-Type` at all | +| `charset=utf-8`, `charset=us-ascii`, or no charset | Any other charset — `iso-8859-1`, `utf-16` | + +A refusal **never changes what the caller receives**. The response is served +whole, with its own headers and status, and only the Redis write is skipped. +You can see it two ways: the response carries `X-Redis-Cache: BYPASS`, and the +library logs the reason once per route: + +```text +WARNING /thumbnails/{id} was served but not cached: content type 'image/png' + is not a text representation. cache() stores serializable text + representations only; see 'Cache scope' in the caching guide. +``` + +Once per route and reason, not once per request — a refused route will not +flood your logs. + +### Binary belongs somewhere else + +`cache()` refuses binary **by choice, not by necessity**. An entry is a JSON +envelope with a text `body`, and base64 could carry arbitrary bytes through it +— an earlier version of this library did exactly that. So no claim below rests +on the format being unable to hold the bytes. The reasons are about HTTP +semantics and about where binary belongs, and they hold whatever your storage +costs: + +- **A CDN serves those bytes closer to the user, and serves them correctly.** + It answers from a node near the caller and implements `Range` and + conditional requests properly — the two things this library refuses rather + than half-supports. Binary is where `Range` matters most: seeking in video, + paging a large PDF. When the endpoint sets no `ETag` of its own the stored + validator is a weak one, and a weak validator cannot serve a range at all. +- **Binary is usually immutable and content-addressed** — asset digests, + thumbnail hashes. A long `max-age` at the edge then needs no invalidation, + which is the one advantage Redis has over a CDN, and the one you would not + be using. +- **Memory is a sizing question, not a wall.** Ten thousand 200 KB thumbnails + is 2 GB, and on open-source Redis that is 2 GB of RAM. On Redis Software or + Redis Cloud, [Flex](https://redis.io/docs/latest/operate/rs/databases/flash/) + tiers warm values onto locally attached NVMe: the RAM limit floors at 10% of + total memory, keeping at least 20% of values in RAM is recommended, key names + stay in RAM whatever happens to their values, and cold reads cost + milliseconds rather than microseconds. If you run on Flex, read this bullet + as capacity planning rather than as an objection. + +`MAX_CACHEABLE_BODY_SIZE` is unrelated to all three: it bounds what your ASGI +worker buffers in process memory while the middleware captures the body, which +no storage tier affects. + +`cache()` earns its keep on the opposite shape: small, costly-to-compute, +often-requested payloads that change, where the saving is the computation +rather than the bytes. + +### Caching a streamed response defeats the streaming + +The capture middleware has to see a whole body before it can store it. A +`StreamingResponse` on a cached route is therefore drained in full before the +client receives its first byte, and the peak buffer is one body per request +in flight. + +Do not put `cache()` on a route that streams for a reason. If the point of +streaming is time-to-first-byte or a body too large to hold in memory, caching +it takes both away. + +Bodies over `MAX_CACHEABLE_BODY_SIZE` (10 MiB) are passed through and marked +`BYPASS`, so an oversized response is a refusal rather than a memory problem. + +### `Vary` is not honoured + +The cache key comes from the request path and its sorted query parameters — +never from a request header. `default_key_builder` cannot see `Accept`, +`Accept-Encoding`, `Authorization` or `Range`, so a `Vary` header on the +response does not affect which entry is read. + +It is still **stored and replayed**. Dropping it would strip the origin's +instruction from every cache downstream of you as well, so a CDN in front +would treat your single stored variant as the only one there is. A response +carrying `Vary: *` is refused outright: RFC 9111 §4.1 says such a response +may never be reused, and the lookup ignores `Vary`, so refusing the store is +the only place that rule can be honoured. + +One URL that negotiates on a request header therefore has **one** entry, and +the first variant stored is the one everyone gets. If a route serves WebP to +clients that accept it and JPEG to the rest, or switches language on +`Accept-Language`, pass a `key_builder` that folds the deciding header into +the key: + +```python +def key_with_language(request, eviction_group="", prefix=""): + base = default_key_builder(request, eviction_group=eviction_group, prefix=prefix) + lang = request.headers.get("accept-language", "*") + return f"{base}:lang={lang}" + +@app.get("/articles/{slug}", dependencies=[Depends(cache(ttl=300, key_builder=key_with_language))]) +async def article(slug: str): ... +``` + +Fold in only the header you negotiate on. A key that includes `Accept-Encoding` +or the full `Accept` splits the entry across every browser variant and the hit +rate collapses. + +### Headers a hit carries + +An entry stores the body, the validator, and **every header field the endpoint +sent** apart from five groups. A hit is rebuilt from that block, so the +representation metadata your endpoint set — `Link`, `Content-Disposition`, +`Content-Language`, `X-Total-Count`, anything of your own — arrives on the hit +exactly as it did on the miss, repeated fields and their order included. + +What an entry deliberately leaves out: + +| Group | Fields | Why | +|-------|--------|-----| +| Owned by this library | `Cache-Control`, `ETag`, `X-Redis-Cache` | Re-emitted on every hit from the entry's own TTL and validator | +| Connection-specific ([RFC 9110 §7.6.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.6.1)) | `Connection` and the fields it names, `Keep-Alive`, `Proxy-Connection`, `TE`, `Transfer-Encoding`, `Upgrade` | A recipient must remove them before forwarding | +| Proxy-specific ([RFC 9111 §3.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1)) | `Proxy-Authenticate`, `Proxy-Authentication-Info`, `Proxy-Authorization` | A `MUST NOT` unless the proxy's identity is in the key, which it is not | +| Framing | `Content-Length` | Recomputed from the replayed body | +| Policy | `Date`, `Set-Cookie` | See the two warnings below | + +A `304` carries less still: per +[RFC 9110 §15.4.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5) +only `ETag`, `Cache-Control`, `Vary`, `Content-Location` and `Expires` are +replayed, since the rest describes a body the response does not contain. + +`Cache-Control` is worth calling out: on a cached route this library owns the +field outright, on the miss as well as the hit. An endpoint that sets +`Cache-Control: public, max-age=600` on a route wrapped in `cache()` has that +value **replaced** by the library's own, so both responses carry one +consistent policy. Use the `private=True` argument rather than the header. + +Bodies have `MAX_CACHEABLE_BODY_SIZE`; header blocks have +`MAX_CACHEABLE_HEADER_SIZE` (8 KiB). A response whose metadata exceeds it is +served and marked `BYPASS`, like any other refusal. + +!!! warning "A cached route cannot set cookies" + + `Set-Cookie` is not stored, which is the safe direction — a shared entry + that replayed one caller's session cookie to the next caller would be a + session leak. The cost is that a cached route **silently stops setting + cookies** after the first request: the caller who takes the miss gets the + cookie, everyone served from the entry does not. + + Do not put `cache()` on a route that establishes a session, sets a CSRF + token, or otherwise depends on `Set-Cookie`. + +To supply one of the withheld fields on every response, set it in +**middleware** rather than in the endpoint. Middleware runs outside the cache, +so it is applied to a hit as well as to a miss, and its value is recomputed per +response instead of being stored. That is the remedy for the cookie limitation +below, and the right home for anything per-request — a request id, a trace +header, a fresh signature. + +!!! note "Extension responses are not marked" + + A response the middleware cannot buffer — `http.response.pathsend`, + `zerocopysend`, `trailers`, `debug` — is forwarded whole and never stored, + but it carries **no** `X-Redis-Cache` header at all rather than `BYPASS`, + since nothing about it was cached and it must not claim a `MISS`. It is + the one served-but-not-stored case you cannot spot from the response + alone. + +!!! warning "A per-caller header leaks like a per-caller body" + + Because an entry now stores the headers your endpoint set, a header whose + value depends on **who asked** is replayed to everyone the entry serves. + A route returning `X-Account-Tier` or `X-User-Id` hands the first + caller's value to every later caller, exactly as a per-caller body would. + + The remedy is the same one: fold the caller into the key with a custom + `key_builder`, as under + [Authenticated routes are not keyed per user](caching.md#rfc-9111-conformance). If a + header is diagnostic rather than part of the representation — a request id, + a trace id — set it in middleware instead, where it is recomputed per + response and never stored. + +### Compression middleware must wrap the cache + +The capture middleware stores the body it sees. If a compression middleware +sits *inside* it, what it sees is already compressed, `Content-Encoding` is +set, and every response is refused — caching is off for every client that +sends `Accept-Encoding: gzip`, which is every browser, with nothing to show +it but one log line. + +Starlette applies the **last** registered middleware outermost, so register +compression **after** `caching()`: + +```python +app = FastAPI() +FastAPIRedis(app).lifespan().caching() +app.add_middleware(GZipMiddleware, minimum_size=1000) # outside the cache +``` + +```python +# Wrong - the cache sees gzip bytes and refuses every response +app.add_middleware(GZipMiddleware, minimum_size=1000) +FastAPIRedis(app).lifespan().caching() +``` + +With the correct order the cache stores the identity representation and the +compression middleware compresses both the miss and the hit on the way out. +The same applies to any middleware that rewrites the body — encrypt, sign, +minify: it belongs outside `caching()`, or the entry stores its output +instead of your endpoint's. + +An endpoint that returns an already-compressed body of its own — setting +`Content-Encoding` by hand — is refused whatever the ordering. RFC 9110 §8.4 +makes `Content-Encoding` the instruction for decoding the body, and an entry +that stored the bytes without it would replay something no client can read. + +--- + ## Storage model - strings vs hashes Every cached entry is stored as a standalone Redis diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 0e788e7..ad6cf63 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -128,7 +128,8 @@ async def update_profile(body: Profile, user: User = Depends(get_current_user)): ### Cache keys -Keys follow the pattern `{prefix}:{{eviction_group}}:{path}:{sorted_query_params}`. +Keys follow the pattern +`{prefix}:{{eviction_group}}:{path}:{sorted_query_params}`. Slashes become colons; query parameters are sorted alphabetically. When an eviction group is provided it is wrapped in Redis @@ -193,20 +194,47 @@ single node. For typical HTTP response caching this is not a problem large, consider splitting it into multiple smaller eviction groups to distribute load across the cluster. +#### Upgrading across a stored-shape change + +Entries carry no format marker, and `CacheBackend` keys by the same prefix as +`cache()`. So when a release changes the shape of a stored entry, clear the +cache prefix as part of the upgrade rather than letting two shapes meet. + +A rolling deploy cannot do that safely: while old and new pods both serve +traffic they share one keyspace, and flushing at any point in the rollout +leaves the old pods free to repopulate the old shape for the new ones to read. +Upgrade across a shape change with a maintenance window — stop the old +release, clear the prefix, start the new one. The release notes call out the +releases where this applies. + +!!! tip "Invalidating on every deploy" + + To get a cold cache on **every** deploy — because your handlers changed + what they return, say — put a build identifier in the prefix: + + ```bash + REDIS_PREFIX=redis:fastapi:build-a3f91c + ``` + + Keys then move wholesale each release, old ones expire on their TTL, and + nothing needs invalidating by hand. This also covers the shape-change + case, since a new build never reads the previous build's keys. + ### HTTP cache headers Every `cache()` response includes these headers automatically: | Header | Value | |--------|-------| -| `X-Redis-Cache` | `HIT` or `MISS` | +| `X-Redis-Cache` | `HIT`, `MISS`, or `BYPASS` when the response was served but not stored (see [Cache scope](architecture.md#cache-scope)) | | [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) | `max-age=` when TTL > 0, or `no-cache` when TTL = 0 (always revalidate via ETag). Adds `private` prefix when `private=True`. | -| [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) | Weak ETag of the cached body | +| [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) | The endpoint's own `ETag` when it set one, strong validators included; otherwise a weak tag derived from the body | **Request directives** - the following `Cache-Control` directives sent by the client are respected: - [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) with a matching ETag returns [**304 Not Modified**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304). +- [`If-Modified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) returns **304** when the endpoint set a `Last-Modified` and the client's copy is not older. `If-None-Match` takes precedence when both are sent, as [RFC 9110 §13.2.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-13.2.2) requires. - `Cache-Control: no-cache` forces a cache refresh. - `Cache-Control: no-store` bypasses caching entirely. - `Cache-Control: max-age=N` - a cached entry older than *N* seconds is @@ -226,6 +254,113 @@ async def my_profile(user: User = Depends(get_current_user)): return user.profile ``` +### Cache scope + +`cache()` stores **text representations** only — JSON, plain text, HTML, CSV, +XML, JavaScript. It refuses anything else, including all binary, and a refusal +never changes what the caller receives: the response is served whole and marked +`X-Redis-Cache: BYPASS`, with the reason logged once per route. An entry also +replays the header fields your endpoint set, apart from the ones this library +owns and the ones it withholds by policy — `Date` and `Set-Cookie` among them. + +For the full allowlist, every refusal reason, the headers a hit carries, and +the middleware ordering that compression requires, see +[Cache scope](architecture.md#cache-scope) in the architecture guide. + +### RFC 9111 conformance + +[RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html) governs HTTP caching, +and a Redis entry shared between requests is a **shared cache** under it. Where +this library follows the specification and where it deliberately does not: + +| Section | Requirement | This library | +|---------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [§3](https://www.rfc-editor.org/rfc/rfc9111.html#section-3) | Store only a cacheable method and a status the cache can reproduce | **Follows.** `cache()` reads and stores `GET` only, and stores only a `200`. An entry carries no status, so a stored `201` would come back as a `200`; refusing it keeps the status honest. `cache_put()` stores any `2xx`, because its body is installed as the representation for a *later* `GET` and the writing response is never replayed. | +| [§3.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1) | "Caches MUST include all received response header fields … when storing a response" | **Follows in part.** An entry stores every received header field, including unrecognized ones, apart from five groups: the fields this library re-emits, the connection-specific and proxy-specific fields §3.1 itself excludes, the recomputed `Content-Length`, and `Date` and `Set-Cookie` by decision. Trailer fields are discarded, which §3.1 permits, and are never merged into the header block. The two remaining deviations are deliberate: replaying `Date` would double-count the entry's age (see the warning below), and replaying `Set-Cookie` from a shared entry would leak a session. See [Headers a hit carries](architecture.md#headers-a-hit-carries). | +| [§3.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.2) | Update stored header fields on validation | **Not applicable.** This is an origin-side cache. It never revalidates against an upstream. | +| [§3.3](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.3) | "a cache MUST NOT store incomplete or partial-content responses if it does not support the Range and Content-Range header fields", and MUST NOT send a partial response without marking it `206` | **Follows.** Three guards: a response carrying `Content-Range` is refused, a request carrying `Range` is refused, and any status other than `200` is refused. Without them a `206` would be stored under the full-resource key and replayed to the next client as a truncated `200`. | +| [§3.4](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.4) | Combining partial content | **Not applicable.** No partial entry is ever stored. | +| [§3.5](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.5) | "A shared cache MUST NOT use a cached response to a request with an Authorization header field" unless the response permits shared storage | **Deviates.** Read the warning below before caching an authenticated route. | +| [§4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4) | Reuse only on a matching URI and method, while fresh | **Follows.** The key is the path plus sorted query, only `GET` reads it, and the Redis TTL is the freshness lifetime. | +| [§4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4) | "a cache MUST generate an Age header field" | **Deviates deliberately.** `Cache-Control: max-age` is emitted already reduced by the entry's age, so a downstream cache works out the same remaining freshness an `Age` header would give it. Sending both would subtract the age twice and expire the response early. **Read the warning below before changing this.** | +| [§4.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1) | Match every request header nominated by `Vary` | **Deviates in part.** The key never includes a request header, so `Vary` is not honoured on lookup. It *is* stored and replayed, so a cache downstream still receives the origin's instruction, and a response carrying `Vary: *` — which may never be reused — is refused rather than stored. See [`Vary` is not honoured](architecture.md#vary-is-not-honoured). | +| [§4.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2) | Freshness | **Follows.** Redis owns it. An expired entry is a missing key, so a stale entry cannot be read. | +| [§4.3](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3) | Validation | **Follows.** `If-None-Match` against the stored `ETag` returns `304`, and `If-Modified-Since` against a stored `Last-Modified` does too — with `If-None-Match` taking precedence when both are present, as §13.2.2 requires. An endpoint that sets its own `ETag` keeps it, strong validator included; when it sets none we generate a weak tag from the body, and a weak validator cannot serve a range request — one more reason `Range` is refused. | +| [§4.4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4) | "A cache MUST invalidate the target URI … when it receives a non-error status code in response to an unsafe request method" | **Deviates.** A `POST`, `PUT`, `PATCH` or `DELETE` does not drop the entry by itself. Put `cache_evict()` or `cache_put()` on the writing route — see [Combining patterns](#combining-patterns). | +| [§5.2.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1) | Request directives | **Follows in part.** Honours `no-store`, `no-cache` and `max-age`. Ignores `min-fresh`, `max-stale`, `only-if-cached` and `no-transform`. | +| [§5.2.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2) | Response directives | **Follows in part.** Refuses to store a response that sets `no-store` or `private`, since a Redis entry is shared. Emits `private` when `private=True`. | + +!!! warning "Authenticated routes are not keyed per user" + + A `cache()` entry is **shared**. The key does not include the caller, so a + route behind `Authorization` serves the first caller's body to everyone + until the TTL expires. RFC 9111 §3.5 forbids exactly this, and the library + does not stop you — `private=True` changes the `Cache-Control` header it + emits, not the key it writes. + + Before caching a route whose body depends on who is asking, fold the + caller into the key: + + ```python + def key_per_user(request, eviction_group="", prefix=""): + base = default_key_builder( + request, eviction_group=eviction_group, prefix=prefix + ) + return f"{base}:u:{request.state.user_id}" + + @app.get( + "/me/profile", + dependencies=[ + Depends(cache(ttl=60, private=True, key_builder=key_per_user)) + ], + ) + async def my_profile(user: User = Depends(get_current_user)): ... + ``` + + If the body is identical for every caller — an auth check that gates + access without changing the content — the shared entry is correct and + needs no change. + +!!! warning "Do not add an Age header on its own" + + On a hit the library emits `Cache-Control: max-age=` and **no** `Age` header, while the ASGI server stamps a fresh + `Date` on every response. A hit therefore looks like a response generated + just now that happens to have less life left: + + ```text + hit 1 cache-control: max-age=120 date: 13:43:17 (redis ttl 120) + hit 2 cache-control: max-age=117 date: 13:43:20 (redis ttl 117) + hit 3 cache-control: max-age=114 date: 13:43:23 (redis ttl 114) + ``` + + Those three facts are **one decision, not three**. A downstream cache + works out `current_age` from the `Age` header and from `Date`, then serves + the response while `max-age > current_age`. Because `Date` is always now + and `Age` is absent, `current_age` starts at zero, so the reduced + `max-age` *is* the whole remaining lifetime. The arithmetic comes out + right — but only because all three parts agree. + + Change one part on its own and it stops coming out right. Take an entry + with a 300-second TTL and 120 seconds left, so its age is 180: + + - **Add an `Age` header while `max-age` stays reduced** and the age counts + twice. The hit carries `max-age=120` and `Age: 180`; a downstream cache + computes `current_age = 180`, finds `120 > 180` false, and treats every + hit as **stale on arrival**. Downstream caching collapses silently — the + responses still look correct in a browser. + - **Store and replay the entry's original `Date`** and the same + double-count happens with no `Age` header at all, because the cache then + derives the age from `Date` instead of from zero. + + To conform to RFC 9111 §4, change the whole encoding together: store the + configured TTL in the entry, emit that constant value as `max-age`, and + emit `Age` as `stored_ttl - remaining_ttl`. A hit then carries + `max-age=300` with `Age: 180`, which leaves the same 120 seconds, and + replaying `Date` becomes safe as well. + + Half of that change is worse than none of it. + ### Testing The DI factories integrate with FastAPI's `dependency_overrides`, so diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 251a78d..f136e97 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -166,6 +166,7 @@ When enabled, `AsyncRedisDep` yields an `AsyncRedisCluster`. ## Key Prefix All pattern data is prefixed with `redis:fastapi` by default, producing keys like `redis:fastapi:cache:...`. +Putting a build identifier here gives every deploy a cold cache — see [Upgrading across a stored-shape change](caching.md#upgrading-across-a-stored-shape-change). ```bash export REDIS_PREFIX=myapp:redis diff --git a/src/redis_fastapi/cache.py b/src/redis_fastapi/cache.py index 7d9e6b6..879b610 100644 --- a/src/redis_fastapi/cache.py +++ b/src/redis_fastapi/cache.py @@ -24,6 +24,7 @@ async def get_items(): import logging from collections.abc import AsyncGenerator from dataclasses import dataclass, field +from email.utils import parsedate_to_datetime from inspect import isawaitable from typing import TYPE_CHECKING, Any, cast @@ -184,6 +185,456 @@ def _cache_control_value(max_age: int, private: bool) -> str: return base +# --------------------------------------------------------------------------- +# Cache scope - which representations may be stored +# --------------------------------------------------------------------------- + + +# A cache entry is a JSON document carrying the body as a text field, so a +# stored representation has to survive a UTF-8 round trip. Refer to the +# architecture section of the guide for details +CACHEABLE_MEDIA_TYPES: frozenset[str] = frozenset( + { + "application/json", + "application/xml", + "application/javascript", + } +) + +# Structured-syntax suffixes (RFC 6838 section 4.2.8) that are text by definition. +CACHEABLE_MEDIA_SUFFIXES: tuple[str, ...] = ("+json", "+xml") + +# Character sets a stored body may declare. Anything else - latin-1, say - +# would decode to bytes other than the ones that were written. +CACHEABLE_CHARSETS: frozenset[str] = frozenset({"utf-8", "utf8", "us-ascii", "ascii"}) + +# --------------------------------------------------------------------------- +# Entry format - which header fields an entry carries +# --------------------------------------------------------------------------- + +# Header field bytes are latin-1, not UTF-8. RFC 9110 section 5.5 constrains +# field values to US-ASCII and tells a recipient to "treat other allowed octets +# in field content (i.e., obs-text) as opaque data", and latin-1 is the codec +# that keeps that promise: it maps 0x00-0xFF one-to-one onto U+0000-U+00FF, so +# any header byte survives a decode/encode round trip unchanged. Starlette +# encodes and decodes raw headers the same way, so entries read back byte-for- +# byte identical to what the endpoint sent. +# https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5 +HEADER_ENCODING: str = "latin-1" + +# RFC 9111 section 3.1 requires a cache to store every received response +# header field, including unrecognized ones, so that new fields keep working +# without the cache having to learn them. That is why this is a denylist: an +# entry stores whatever the endpoint sent apart from the groups below, each of +# which is either re-emitted from scratch or must not be replayed at all. + +# Re-emitted on every hit from the entry's own TTL and validator. Storing +# them would put a second copy beside the live one. +OWNED_HEADERS: frozenset[bytes] = frozenset( + {b"cache-control", b"etag", CACHE_STATUS_HEADER.lower().encode()} +) + +# RFC 9110 section 7.6.1: connection-specific fields, which a recipient must +# remove before forwarding. ``Connection`` also names further fields, read +# per response in :func:`_excluded_from_storage`. +HOP_BY_HOP_HEADERS: frozenset[bytes] = frozenset( + { + b"connection", + b"keep-alive", + b"proxy-connection", + b"te", + b"transfer-encoding", + b"upgrade", + } +) + +# RFC 9111 section 3.1: proxy-specific fields MUST NOT be stored unless the +# cache puts the proxy's identity in the key, which this one does not. +PROXY_HEADERS: frozenset[bytes] = frozenset( + {b"proxy-authenticate", b"proxy-authentication-info", b"proxy-authorization"} +) + +# Framing, recomputed from the replayed body. A stored length that no longer +# matches would corrupt the response. +FRAMING_HEADERS: frozenset[bytes] = frozenset({b"content-length"}) + +# Excluded by decision rather than by specification: +# +# * ``Date`` - a hit is stamped fresh by the server while ``max-age`` counts +# down. Replaying a stored ``Date`` makes a downstream cache derive the +# age twice and treat every hit as stale on arrival; see the "Do not add an +# Age header on its own" warning in the caching guide. +# * ``Set-Cookie`` - an entry is shared. Replaying one caller's cookie to the +# next caller would be a session leak, so a cached route sets no cookies. +POLICY_EXCLUDED_HEADERS: frozenset[bytes] = frozenset({b"date", b"set-cookie"}) + +# Ceiling on the serialised header block. A route with heavy metadata can +# otherwise store more bytes of headers than of body, and the block is +# rebuilt into a response on every hit. +MAX_CACHEABLE_HEADER_SIZE: int = 8 * 1024 + +# RFC 9110 section 15.4.5: a 304 carries validators and cache metadata, not +# the representation metadata a 200 would. Replaying the whole stored block +# on a 304 would send a Content-Type for a response that has no content. +NOT_MODIFIED_HEADERS: frozenset[bytes] = frozenset( + {b"etag", b"cache-control", b"vary", b"content-location", b"expires"} +) + +# ``(route, reason)`` pairs already logged, so a refused route warns once +# rather than on every request. +_WARNED_REFUSALS: set[tuple[str, str]] = set() + + +def _find_header(headers: list[tuple[bytes, bytes]], name: bytes) -> bytes | None: + """Return the first value for raw ASGI header *name* (lowercase), if present. + + Args: + headers: Raw ASGI header pairs from ``http.response.start``. + name: Lowercase header name to look for. + + Returns: + The raw header value, or ``None`` when the header is absent. + """ + for key, value in headers: + if key.lower() == name: + return value + return None + + +def _split_content_type(raw: bytes | None) -> tuple[str, str | None]: + """Split a ``Content-Type`` value into its media type and charset. + + Both are lowercased. A missing header yields ``("", None)``, which no + allowlist entry matches, so an untyped response is refused rather than + guessed at. + + Args: + raw: Raw ``Content-Type`` header value, or ``None``. + + Returns: + A ``(media_type, charset)`` pair; *charset* is ``None`` when the + header declares none. + """ + if raw is None: + return "", None + media_type, _, params = raw.decode(HEADER_ENCODING).partition(";") + charset: str | None = None + for param in params.split(";"): + key, _, value = param.partition("=") + if key.strip().lower() == "charset": + charset = value.strip().strip('"').lower() + break + return media_type.strip().lower(), charset + + +def _is_cacheable_media_type(media_type: str) -> bool: + """Whether *media_type* names a representation this cache can replay. + + Args: + media_type: Lowercased media type, without parameters. + + Returns: + ``True`` for text and the JSON/XML families, ``False`` otherwise. + """ + if media_type in CACHEABLE_MEDIA_TYPES: + return True + if media_type.startswith("text/"): + return True + return media_type.startswith("application/") and media_type.endswith( + CACHEABLE_MEDIA_SUFFIXES + ) + + +def _merge_headers( + base: list[tuple[bytes, bytes]], + overrides: list[tuple[bytes, bytes]], +) -> list[tuple[bytes, bytes]]: + """Return *base* with every field named in *overrides* replaced, not joined. + + The fields this middleware sets are single-valued. RFC 9110 section 8.8.3 + defines ``ETag = entity-tag`` - one tag, not a list - so appending ours + beside a validator the endpoint already set would emit a field no client + can parse, and a conditional request echoing it would never match. + ``Cache-Control`` appended the same way yields two ``max-age`` directives. + + Args: + base: Raw ASGI headers as the endpoint produced them. + overrides: Headers this middleware owns; each replaces every earlier + occurrence of the same name. + + Returns: + The merged header list, with *overrides* last. + """ + owned = {name.lower() for name, _ in overrides} + return [(k, v) for k, v in base if k.lower() not in owned] + overrides + + +def _excluded_from_storage(response_headers: list[tuple[bytes, bytes]]) -> set[bytes]: + """Return the lowercase field names this entry must not store. + + The fixed groups are joined by whatever ``Connection`` names in this + particular response, which RFC 9110 section 7.6.1 makes connection + specific for that message only. + + Args: + response_headers: Raw ASGI response headers. + + Returns: + Lowercase header names to leave out of the entry. + """ + excluded = set( + OWNED_HEADERS + | HOP_BY_HOP_HEADERS + | PROXY_HEADERS + | FRAMING_HEADERS + | POLICY_EXCLUDED_HEADERS + ) + for name, value in response_headers: + if name.lower() == b"connection": + excluded.update( + token.strip().lower() for token in value.split(b",") if token.strip() + ) + return excluded + + +def _storable_headers( + response_headers: list[tuple[bytes, bytes]], +) -> list[list[str]]: + """Return the header fields to store, in the order they were received. + + Pairs rather than a mapping: a response may carry several ``Link`` or + ``Set-Cookie`` fields, and both their repetition and their order are part + of what it means. + + Args: + response_headers: Raw ASGI response headers. + + Returns: + ``[name, value]`` pairs, lowercased names, JSON-serialisable. + """ + excluded = _excluded_from_storage(response_headers) + return [ + [name.decode(HEADER_ENCODING).lower(), value.decode(HEADER_ENCODING)] + for name, value in response_headers + if name.lower() not in excluded + ] + + +def _entry_headers(entry: dict[str, Any]) -> list[tuple[str, str]]: + """Return an entry's stored header pairs, in the order they were received. + + Args: + entry: The decoded cache entry. + + Returns: + ``(name, value)`` pairs. + + Raises: + KeyError: If the entry carries no header block. The caller treats + that as a miss rather than serving a shape it cannot read. + """ + return [(name, value) for name, value in entry["headers"]] + + +def _is_not_modified( + request: Request, etag: str, stored: list[tuple[str, str]] +) -> bool: + """Whether this conditional request can be answered with a ``304``. + + ``If-None-Match`` decides on its own when present: RFC 9110 + section 13.2.2 requires a recipient to ignore ``If-Modified-Since`` when + the request carries an entity-tag precondition. ``If-Modified-Since`` is + evaluated only against a ``Last-Modified`` the entry actually stored. + + Args: + request: The incoming request. + etag: The entry's stored validator. + stored: The entry's stored header pairs. + + Returns: + ``True`` when the client's copy is still current. + """ + if_none_match = request.headers.get("if-none-match") + if if_none_match is not None: + return if_none_match == etag + + since = request.headers.get("if-modified-since") + if since is None: + return False + last_modified = next( + (value for name, value in stored if name == "last-modified"), None + ) + if last_modified is None: + return False + try: + return parsedate_to_datetime(last_modified) <= parsedate_to_datetime(since) + except (TypeError, ValueError): + # RFC 9110 section 13.1.3: a date the recipient cannot parse is not a + # precondition, so fall through and send the body. + return False + + +def _hit_headers( + stored: list[tuple[str, str]], + etag: str, + cc_value: str, + *, + not_modified: bool, +) -> list[tuple[bytes, bytes]]: + """Build the raw header list for a hit, preserving repeated fields. + + Args: + stored: Header pairs from the entry. + etag: The stored validator. + cc_value: ``Cache-Control`` for the entry's remaining TTL. + not_modified: Whether this is a ``304``, which carries only the + fields in :data:`NOT_MODIFIED_HEADERS`. + + Returns: + Raw ASGI header pairs, with the fields this library owns last. + """ + pairs = [ + (name.encode(HEADER_ENCODING), value.encode(HEADER_ENCODING)) + for name, value in stored + if not not_modified or name.encode(HEADER_ENCODING) in NOT_MODIFIED_HEADERS + ] + return _merge_headers( + pairs, + [ + (CACHE_STATUS_HEADER.lower().encode(), b"HIT"), + (b"etag", etag.encode(HEADER_ENCODING)), + (b"cache-control", cc_value.encode(HEADER_ENCODING)), + ], + ) + + +def _route_label(request: Request) -> str: + """Return a stable, log-safe name for the route that served *request*.""" + route = request.scope.get("route") + return str(getattr(route, "path", None) or request.url.path) + + +def _warn_refusal_once(request: Request, reason: str) -> None: + """Log why a response was not stored, once per route and reason. + + Args: + request: The request whose response was refused. + reason: The refusal reason from :func:`_storage_refusal`. + """ + marker = (_route_label(request), reason) + if marker in _WARNED_REFUSALS: + return + _WARNED_REFUSALS.add(marker) + logger.warning( + "%s was served but not cached: %s. cache() stores serializable text " + "representations only; see 'Cache scope' in the architecture guide.", + marker[0], + reason, + ) + + +def _storage_refusal( + request: Request, + pending: CachePending, + response_status: int, + response_headers: list[tuple[bytes, bytes]], +) -> str | None: + """Return why this response must not be stored, or ``None`` to store it. + + Every branch is a rule from RFC 9111 or a limit of the entry format, and + "Cache scope" in the architecture guide documents them one for one, so a + refusal in the log can be looked up. + + Args: + request: The request being served. + pending: The pending cache operation, which says whether this is a + read-path fill or a write-through. + response_status: Status code from ``http.response.start``. + response_headers: Raw ASGI response headers. + + Returns: + A short reason string, or ``None`` when the response may be stored. + """ + # RFC 9111 section 3: store only a status code the cache can replay. The + # entry holds no status, so the hit path always answers 200 - which makes + # 200 the only status a read-path fill may store. 206 is the one that + # bites: the key carries no Range, so a stored partial body would be + # replayed to the next client as a complete 200. + # + # Write-through is the exception. There the stored body is deliberately + # installed as the representation for a *later GET*, so the status of the + # PUT or POST that produced it never reaches a client; any 2xx will do. + if pending.write_through: + if not 200 <= response_status < 300: + return f"status {response_status} is not 2xx" + elif response_status != 200: + return f"status {response_status} is not 200" + + # RFC 9111 section 3.3: a cache that implements neither Range nor + # Content-Range MUST NOT store partial content, and MUST NOT answer a + # request from a partial entry. + if _find_header(response_headers, b"content-range") is not None: + return "response carries Content-Range" + if "range" in request.headers: + return "request carried Range" + + # RFC 9111 sections 3 and 5.2.2.7: a Redis entry is a shared cache, so the + # response's own no-store and private directives bind us. Every + # Cache-Control line is read, not just the first: a directive that forbids + # storage must not be missed because something appended a second header. + cc_lines = [ + value.decode(HEADER_ENCODING) + for key, value in response_headers + if key.lower() == b"cache-control" + ] + if cc_lines: + cc = _parse_cache_control(",".join(cc_lines)) + if "no-store" in cc: + return "response set Cache-Control: no-store" + if "private" in cc: + return "response set Cache-Control: private" + + media_type, charset = _split_content_type( + _find_header(response_headers, b"content-type") + ) + if not _is_cacheable_media_type(media_type): + return f"content type '{media_type or 'none'}' is not a text representation" + if charset is not None and charset not in CACHEABLE_CHARSETS: + return f"charset '{charset}' is not UTF-8" + + # RFC 9111 section 4.1: a stored response whose Vary is "*" may never be + # reused for a later request, so storing one only builds entries that must + # not be served. The lookup ignores Vary, which makes refusing the store + # the only place this can be honoured. + raw_vary = _find_header(response_headers, b"vary") + if raw_vary is not None and "*" in [ + v.strip() for v in raw_vary.decode(HEADER_ENCODING).split(",") + ]: + return "response set Vary: *" + + # RFC 9110 section 8.4: Content-Encoding states what decoding has to be + # applied to obtain the data in the media type Content-Type names. An + # encoded body is refused by name rather than left to fail the UTF-8 + # decode later, which would report a compressed response as a bytes + # problem and tell the operator the wrong thing. + raw_encoding = _find_header(response_headers, b"content-encoding") + if raw_encoding is not None: + codings = [ + c.strip().lower() for c in raw_encoding.decode(HEADER_ENCODING).split(",") + ] + applied = [c for c in codings if c and c != "identity"] + if applied: + return f"response carries Content-Encoding: {', '.join(applied)}" + + block_size = len(json.dumps(_storable_headers(response_headers))) + if block_size > MAX_CACHEABLE_HEADER_SIZE: + return ( + f"header block is {block_size} bytes, over the " + f"{MAX_CACHEABLE_HEADER_SIZE}-byte limit" + ) + return None + + # --------------------------------------------------------------------------- # CacheHitException - short-circuit on cache hit # --------------------------------------------------------------------------- @@ -300,39 +751,40 @@ def _build_hit_response( ) -> Response: """Deserialize a cache entry and return a ready-to-send ``Response``. - Returns a ``304 Not Modified`` when the client's ``If-None-Match`` - matches the stored ETag, otherwise a full ``200`` response. + The response is rebuilt from the header fields the entry stored, so a + hit carries the representation metadata the endpoint set rather than a + reconstruction of it. Returns a ``304 Not Modified`` when the client's + ``If-None-Match`` matches the stored ETag, or when its + ``If-Modified-Since`` is not older than a stored ``Last-Modified``. Raises: json.JSONDecodeError: If *cached_data* is not valid JSON. - KeyError: If the entry is missing required keys. + KeyError: If the entry is missing required keys, or was written by a + version this reader does not know. """ entry = json.loads(cached_data) body_bytes = ( entry["body"].encode() if isinstance(entry["body"], str) else entry["body"] ) etag: str = entry["etag"] + stored = _entry_headers(entry) cc_value = _cache_control_value(remaining_ttl, private) - if request.headers.get("if-none-match") == etag: - return Response( - status_code=HTTP_304_NOT_MODIFIED, - headers={ - CACHE_STATUS_HEADER: "HIT", - "ETag": etag, - "Cache-Control": cc_value, - }, + if _is_not_modified(request, etag, stored): + not_modified = Response(status_code=HTTP_304_NOT_MODIFIED) + not_modified.raw_headers = _hit_headers( + stored, etag, cc_value, not_modified=True ) - - return Response( - content=body_bytes, - media_type="application/json", - headers={ - CACHE_STATUS_HEADER: "HIT", - "ETag": etag, - "Cache-Control": cc_value, - }, - ) + return not_modified + + # raw_headers is assigned rather than passed as a mapping because a + # mapping cannot express a repeated field, and Link, Set-Cookie and Vary + # may all legitimately appear more than once. + response = Response(content=body_bytes) + response.raw_headers = _hit_headers(stored, etag, cc_value, not_modified=False) + [ + (b"content-length", str(len(body_bytes)).encode(HEADER_ENCODING)) + ] + return response def cache( @@ -372,7 +824,7 @@ def cache( _default_ttl_in_use = True _ttl: int = ttl if ttl is not None else _settings.default_ttl _prefix: str = ( - cache_prefix if cache_prefix is not None else _settings.pattern_prefix("cache") + _settings.pattern_prefix("cache") if cache_prefix is None else cache_prefix ) _key_builder: KeyBuilder = key_builder or default_key_builder @@ -518,7 +970,7 @@ def cache_evict( An async generator dependency suitable for use with ``Depends()``. """ _settings = get_settings() - _prefix: str = prefix if prefix is not None else _settings.pattern_prefix("cache") + _prefix: str = _settings.pattern_prefix("cache") if prefix is None else prefix _key_builder: KeyBuilder | None = key_builder # Flow: yield to endpoint → on success evict key or group @@ -591,7 +1043,7 @@ def cache_put( global _default_ttl_in_use _default_ttl_in_use = True _ttl: int = ttl if ttl is not None else _settings.default_ttl - _prefix: str = prefix if prefix is not None else _settings.pattern_prefix("cache") + _prefix: str = _settings.pattern_prefix("cache") if prefix is None else prefix _key_builder: KeyBuilder = key_builder or default_key_builder # Flow: resolve key → mark pending as write-through → yield to endpoint @@ -660,7 +1112,10 @@ async def _flush_oversized_response( { "type": "http.response.start", "status": response_status, - "headers": response_headers, + "headers": _merge_headers( + response_headers, + [(CACHE_STATUS_HEADER.lower().encode(), b"BYPASS")], + ), } ) if response_body: @@ -678,24 +1133,54 @@ async def _flush_oversized_response( async def _store_cache_entry( pending: CachePending, body_bytes: bytes, + body_text: str, + response_headers: list[tuple[bytes, bytes]], app: Any, ) -> list[tuple[bytes, bytes]]: - """Write a cache entry to Redis and return extra response headers. + """Write a cache entry to Redis and return the headers this cache owns. + + The entry carries the body, the validator, and every header field the + endpoint sent apart from the groups named in :func:`_excluded_from_storage` + - the fields this library re-emits, the connection-specific and proxy + fields RFC 9111 section 3.1 excludes, the recomputed framing, and ``Date`` + and ``Set-Cookie`` by decision. It carries no format marker: the keyspace + it is written to is the marker. + + Args: + pending: The pending cache operation set by the dependency. + body_bytes: The complete response body, used for the ETag. + body_text: The same body decoded as UTF-8, stored in the entry. + response_headers: Raw ASGI headers as the endpoint produced them, + read for the fields the entry preserves. + app: The FastAPI application, used to recover a client if the + dependency did not carry one. Returns: - A list of ``(name, value)`` header pairs to append to the - outgoing response (``X-Redis-Cache``, ``ETag``, ``Cache-Control``). + A list of ``(name, value)`` header pairs that replace any the + endpoint set (``X-Redis-Cache``, ``ETag``, ``Cache-Control``). """ - etag = f'W/"{hashlib.blake2b(body_bytes, digest_size=16).hexdigest()}"' + # An endpoint that set its own validator keeps it. Replaying the origin's + # tag preserves a strong validator, which a hash of the body cannot be, + # and it keeps the ETag a client sees on the miss identical to the one it + # gets on the hit - otherwise the first conditional request after a miss + # can never match. + raw_etag = _find_header(response_headers, b"etag") + etag = ( + raw_etag.decode(HEADER_ENCODING) + if raw_etag is not None + else f'W/"{hashlib.blake2b(body_bytes, digest_size=16).hexdigest()}"' + ) + cc_value = _cache_control_value(pending.ttl, pending.private) extra_headers: list[tuple[bytes, bytes]] = [ (CACHE_STATUS_HEADER.lower().encode(), b"MISS"), (b"etag", etag.encode()), (b"cache-control", cc_value.encode()), ] - entry = { - "body": body_bytes.decode(errors="replace"), + entry: dict[str, Any] = { + "body": body_text, "etag": etag, + "headers": _storable_headers(response_headers), } try: redis = pending.redis @@ -766,7 +1251,13 @@ async def capture_send(message: Message) -> None: return # Any other message type (pathsend, zerocopysend, trailers, debug, …): - # we can't buffer/cache it, so flush the buffered start and pass it through. + # we can't buffer/cache it, so flush the buffered start and pass it + # through unmarked: nothing was stored, so it must not claim a MISS, + # and tests/integration/test_cache_extension_messages.py pins the + # absence of a cache status header here. This is the one refusal + # with no BYPASS marker - see "Extension responses" in the guide. + # Trailer fields reach the client but never the entry, which is what + # RFC 9111 section 3.1 permits a cache to do with them. if message["type"] != "http.response.body": if not passthrough: await send( @@ -804,21 +1295,40 @@ async def capture_send(message: Message) -> None: if message.get("more_body", False): return - # 5. Final chunk: write to Redis (on 2xx) then send the full response + # 5. Final chunk: store the response when the rules allow, then + # send it either way. A refused response is served normally and + # marked BYPASS, so nothing is ever withheld over a refusal. body_bytes = bytes(response_body) extra_headers: list[tuple[bytes, bytes]] = [] - if pending is not None and 200 <= response_status < 300: - extra_headers = await _store_cache_entry( - pending, - body_bytes, - request.app, + if pending is not None: + reason = _storage_refusal( + request, pending, response_status, response_headers ) + if reason is None: + try: + body_text = body_bytes.decode() + except UnicodeDecodeError: + # The allowlist admitted the media type, but these + # bytes are not the UTF-8 it promised. Refuse rather + # than store a body that cannot survive the round trip. + reason = "body is not valid UTF-8" + else: + extra_headers = await _store_cache_entry( + pending, + body_bytes, + body_text, + response_headers, + request.app, + ) + if reason is not None: + _warn_refusal_once(request, reason) + extra_headers = [(CACHE_STATUS_HEADER.lower().encode(), b"BYPASS")] await send( { "type": "http.response.start", "status": response_status, - "headers": response_headers + extra_headers, + "headers": _merge_headers(response_headers, extra_headers), } ) await send({"type": "http.response.body", "body": body_bytes}) diff --git a/src/redis_fastapi/cache_backend.py b/src/redis_fastapi/cache_backend.py index 271bd8e..d547fbc 100644 --- a/src/redis_fastapi/cache_backend.py +++ b/src/redis_fastapi/cache_backend.py @@ -71,6 +71,8 @@ def __init__( self._eviction_group = eviction_group self._coder: type[Coder] = coder or JsonCoder settings = get_settings() + # The same prefix cache() keys by, so delete_group() clears backend + # values and cache() entries alike. self._prefix = settings.pattern_prefix("cache") # ------------------------------------------------------------------ diff --git a/tests/integration/test_cache_headers.py b/tests/integration/test_cache_headers.py new file mode 100644 index 0000000..8e38c54 --- /dev/null +++ b/tests/integration/test_cache_headers.py @@ -0,0 +1,439 @@ +"""Integration tests for header fidelity across a cache hit, against real Redis. + +``tests/integration/test_cache_payloads.py`` answers *whether* a response may +be stored. This module answers what a stored response comes back **as**: which +header fields survive the round trip, which are deliberately withheld, and what +a client that depends on one of them actually receives on the second request. + +Each test names the use case it protects. They exist because every one of +these failed the same way before the header block was stored — correct on the +first request, quietly degraded on every request after it, with no error +anywhere to notice. +""" + +from __future__ import annotations + +import json +from collections.abc import Generator + +import pytest +import redis as sync_redis +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.gzip import GZipMiddleware +from starlette.responses import Response + +from redis_fastapi.cache import MAX_CACHEABLE_HEADER_SIZE, cache +from redis_fastapi.setup import FastAPIRedis +from tests.conftest import requires_redis + +LAST_MODIFIED = "Wed, 10 Sep 2026 12:00:00 GMT" + + +@pytest.fixture() +def flushed(real_redis: sync_redis.Redis) -> Generator[sync_redis.Redis, None, None]: + real_redis.flushdb() + yield real_redis + real_redis.flushdb() + + +def _app(headers: dict[str, str], *, body: str = '[{"id": 1}]') -> FastAPI: + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get("/items", dependencies=[Depends(cache(ttl=300))]) + async def items() -> Response: + return Response( + content=body, media_type="application/json", headers=dict(headers) + ) + + return app + + +# =================================================================== +# The use cases a four-field entry could not serve +# =================================================================== + + +@requires_redis +@pytest.mark.integration +@pytest.mark.parametrize( + ("use_case", "field", "value"), + [ + ("pagination by Link (RFC 8288)", "Link", '; rel="next"'), + ("total counts for a data grid", "X-Total-Count", "4211"), + ( + "file download under a filename", + "Content-Disposition", + 'attachment; filename="report.csv"', + ), + ("localized representation", "Content-Language", "de-DE"), + ("date-based validation", "Last-Modified", LAST_MODIFIED), + ("response integrity (RFC 9530)", "Content-Digest", "sha-256=:abc:"), + ("message signature (RFC 9421)", "Signature-Input", 'sig1=("@status")'), + ("representation URI", "Content-Location", "/items?page=1"), + ("application-specific metadata", "X-Deprecation-Notice", "use /v2/items"), + ], +) +def test_endpoint_header_survives_the_hit( + use_case: str, field: str, value: str, flushed: sync_redis.Redis +) -> None: + """A header the endpoint computed must reach the client on the hit too.""" + with TestClient(_app({field: value})) as client: + miss = client.get("/items") + hit = client.get("/items") + + assert miss.headers["X-Redis-Cache"] == "MISS" + assert hit.headers["X-Redis-Cache"] == "HIT", use_case + assert miss.headers[field] == value + assert hit.headers.get(field) == value, use_case + + +@requires_redis +@pytest.mark.integration +def test_repeated_fields_keep_their_repetition_and_order( + flushed: sync_redis.Redis, +) -> None: + """Several ``Link`` headers is legal and meaningful; a mapping loses them.""" + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get("/paged", dependencies=[Depends(cache(ttl=300))]) + async def paged() -> Response: + response = Response(content="[]", media_type="application/json") + response.headers.append("Link", '; rel="next"') + response.headers.append("Link", '; rel="last"') + return response + + with TestClient(app) as client: + miss = client.get("/paged") + hit = client.get("/paged") + + assert hit.headers["X-Redis-Cache"] == "HIT" + assert miss.headers.get_list("link") == [ + '; rel="next"', + '; rel="last"', + ] + assert hit.headers.get_list("link") == miss.headers.get_list("link") + + +# =================================================================== +# Conditional requests +# =================================================================== + + +@requires_redis +@pytest.mark.integration +@pytest.mark.parametrize( + ("since", "expected", "why"), + [ + (LAST_MODIFIED, 304, "same instant - the client's copy is current"), + ("Thu, 11 Sep 2026 12:00:00 GMT", 304, "client's copy is newer"), + ("Tue, 09 Sep 2026 12:00:00 GMT", 200, "client's copy predates the entry"), + ], +) +def test_if_modified_since_against_a_stored_last_modified( + since: str, expected: int, why: str, flushed: sync_redis.Redis +) -> None: + with TestClient(_app({"Last-Modified": LAST_MODIFIED})) as client: + client.get("/items") + conditional = client.get("/items", headers={"If-Modified-Since": since}) + + assert conditional.status_code == expected, why + + +@requires_redis +@pytest.mark.integration +def test_not_modified_carries_cache_metadata_only(flushed: sync_redis.Redis) -> None: + """RFC 9110 section 15.4.5: a 304 describes no representation. + + The entry holds a Content-Type, a Link and a Content-Language. Replaying + the whole block would describe a body this response does not contain. + """ + app = _app( + { + "Link": '; rel="next"', + "Content-Language": "de-DE", + "Vary": "Accept-Language", + "Content-Location": "/items?page=1", + } + ) + with TestClient(app) as client: + miss = client.get("/items") + validated = client.get( + "/items", headers={"If-None-Match": miss.headers["etag"]} + ) + + assert validated.status_code == 304 + assert validated.content == b"" + # Carried: validator, cache metadata, Vary, Content-Location. + assert validated.headers["etag"] == miss.headers["etag"] + assert validated.headers["vary"] == "Accept-Language" + assert validated.headers["content-location"] == "/items?page=1" + assert "cache-control" in validated.headers + # Withheld: anything describing the absent body. + assert validated.headers.get("content-type") is None + assert validated.headers.get("content-language") is None + assert validated.headers.get("link") is None + + +# =================================================================== +# Fields an entry deliberately withholds +# =================================================================== + + +@requires_redis +@pytest.mark.integration +def test_endpoint_cookies_are_never_stored_or_replayed( + flushed: sync_redis.Redis, +) -> None: + """A shared entry must not hand one caller's session to the next. + + The miss sets the cookie because that response is the endpoint's own. The + hit does not, which is why a cached route cannot establish a session. + """ + with TestClient(_app({"Set-Cookie": "session=abc123; Path=/"})) as client: + miss = client.get("/items") + hit = client.get("/items") + + assert miss.headers.get("set-cookie") == "session=abc123; Path=/" + assert hit.headers.get("set-cookie") is None + + entry = flushed.get(flushed.keys("*")[0]) + assert "abc123" not in entry, "no cookie value may reach Redis" + + +@requires_redis +@pytest.mark.integration +def test_middleware_cookies_still_reach_every_caller( + flushed: sync_redis.Redis, +) -> None: + """The documented remedy for the cookie limitation. + + Middleware runs outside the cache, so it is applied to the hit as well. + A cookie set there is not stored and not shared - it is recomputed per + response, which is exactly what a session cookie needs. + """ + app = FastAPI() + + class IssueCookie(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): # type: ignore[no-untyped-def] + response = await call_next(request) + response.set_cookie("session", f"per-caller-{request.url.path}") + return response + + app.add_middleware(IssueCookie) + FastAPIRedis(app).lifespan().caching() + + @app.get("/items", dependencies=[Depends(cache(ttl=300))]) + async def items() -> dict: + return {"ok": True} + + with TestClient(app) as client: + miss = client.get("/items") + hit = client.get("/items") + + assert hit.headers["X-Redis-Cache"] == "HIT" + # Starlette quotes a value containing a slash; compare the two responses + # rather than a literal. + assert "per-caller-/items" in miss.headers["set-cookie"] + assert hit.headers["set-cookie"] == miss.headers["set-cookie"] + + +@requires_redis +@pytest.mark.integration +def test_date_is_never_stored_or_replayed(flushed: sync_redis.Redis) -> None: + """Replaying a stored Date would make a downstream cache count age twice. + + In production the ASGI server stamps a fresh ``Date`` on every response, + including a hit; ``TestClient`` does not, which is why this asserts the + exclusion rather than the stamping. An endpoint that sets ``Date`` by hand + has it on its own response and never on a response served from the entry. + """ + stale = "Mon, 01 Jan 2024 00:00:00 GMT" + with TestClient(_app({"Date": stale})) as client: + miss = client.get("/items") + hit = client.get("/items") + + assert hit.headers["X-Redis-Cache"] == "HIT" + assert miss.headers.get("date") == stale + assert hit.headers.get("date") is None + assert stale not in flushed.get(flushed.keys("*")[0]) + + +@requires_redis +@pytest.mark.integration +def test_connection_specific_fields_are_stripped(flushed: sync_redis.Redis) -> None: + """RFC 9110 section 7.6.1 / RFC 9111 section 3.1 exceptions.""" + app = _app( + { + "Connection": "X-Hop-Only", + "X-Hop-Only": "internal", + "Keep-Alive": "timeout=5", + "Proxy-Authenticate": "Basic", + "X-Kept": "public", + } + ) + with TestClient(app) as client: + client.get("/items") + hit = client.get("/items") + + entry = flushed.get(flushed.keys("*")[0]).lower() + for stripped in ("x-hop-only", "keep-alive", "proxy-authenticate"): + assert stripped not in entry, stripped + assert "x-kept" in entry + assert hit.headers.get("x-kept") == "public" + + +@requires_redis +@pytest.mark.integration +def test_oversized_header_block_is_served_but_not_stored( + flushed: sync_redis.Redis, +) -> None: + """Metadata is capped the way bodies are, and refused the same way.""" + app = _app({"X-Huge": "v" * (MAX_CACHEABLE_HEADER_SIZE + 1)}) + with TestClient(app) as client: + first = client.get("/items") + second = client.get("/items") + + assert first.headers["X-Redis-Cache"] == "BYPASS" + assert second.headers["X-Redis-Cache"] == "BYPASS" + assert first.status_code == second.status_code == 200 + assert first.json() == [{"id": 1}], "the response is delivered whole" + assert flushed.keys("*") == [] + + +# =================================================================== +# Middleware ordering +# =================================================================== + + +@requires_redis +@pytest.mark.integration +def test_compression_registered_after_caching_caches_normally( + flushed: sync_redis.Redis, +) -> None: + """Starlette applies the last-registered middleware outermost. + + Registered after ``caching()``, compression wraps the cache: the entry + holds the identity representation and both the miss and the hit are + compressed on the way out. + """ + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + app.add_middleware(GZipMiddleware, minimum_size=1) + + @app.get("/items", dependencies=[Depends(cache(ttl=300))]) + async def items() -> dict: + return {"ok": True} + + with TestClient(app) as client: + miss = client.get("/items", headers={"Accept-Encoding": "gzip"}) + hit = client.get("/items", headers={"Accept-Encoding": "gzip"}) + + assert miss.headers["X-Redis-Cache"] == "MISS" + assert hit.headers["X-Redis-Cache"] == "HIT" + assert hit.headers.get("content-encoding") == "gzip" + assert hit.json() == {"ok": True} + assert len(flushed.keys("*")) == 1 + + +@requires_redis +@pytest.mark.integration +def test_compression_registered_before_caching_disables_the_cache( + flushed: sync_redis.Redis, +) -> None: + """The trap this ordering causes, pinned so the guide cannot go stale. + + Registered before ``caching()``, compression sits *inside* the cache, so + the middleware sees gzip bytes with a Content-Encoding it refuses. Every + response is a BYPASS for every client that accepts gzip - which is every + browser. + """ + app = FastAPI() + app.add_middleware(GZipMiddleware, minimum_size=1) + FastAPIRedis(app).lifespan().caching() + + @app.get("/items", dependencies=[Depends(cache(ttl=300))]) + async def items() -> dict: + return {"ok": True} + + with TestClient(app) as client: + first = client.get("/items", headers={"Accept-Encoding": "gzip"}) + second = client.get("/items", headers={"Accept-Encoding": "gzip"}) + + assert first.headers["X-Redis-Cache"] == "BYPASS" + assert second.headers["X-Redis-Cache"] == "BYPASS" + assert second.json() == {"ok": True}, "the response is still correct" + assert flushed.keys("*") == [], "nothing was cached" + + +@requires_redis +@pytest.mark.integration +def test_entry_without_a_header_block_degrades_to_a_miss( + flushed: sync_redis.Redis, +) -> None: + """A malformed entry must not be half-read. + + With no format marker in the value, a missing header block is the only + signal that an entry cannot be replayed - so it has to produce a miss + rather than an exception or a mislabelled body. + """ + calls = [0] + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get("/items", dependencies=[Depends(cache(ttl=300))]) + async def items() -> dict: + calls[0] += 1 + return {"from": "endpoint"} + + with TestClient(app) as client: + client.get("/items") + key = flushed.keys("*")[0] + flushed.set(key, json.dumps({"body": '{"from":"broken"}', "etag": 'W/"b"'})) + + resp = client.get("/items") + + assert resp.status_code == 200 + assert resp.headers["X-Redis-Cache"] == "MISS" + assert resp.json() == {"from": "endpoint"} + assert calls[0] == 2, "the endpoint ran again instead of serving a bad entry" + + +@requires_redis +@pytest.mark.integration +async def test_backend_delete_group_still_clears_decorator_entries( + flushed: sync_redis.Redis, +) -> None: + """``CacheBackend`` and ``cache()`` share one prefix. + + ``delete_group()`` therefore clears both - the guarantee stated under + "Keys interoperate with cache()" in the caching guide. + """ + import redis.asyncio as aioredis + + from redis_fastapi.cache_backend import CacheBackend + + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get("/grouped", dependencies=[Depends(cache(ttl=300, eviction_group="items"))]) + async def grouped() -> dict: + return {"ok": True} + + with TestClient(app) as client: + assert client.get("/grouped").headers["X-Redis-Cache"] == "MISS" + assert client.get("/grouped").headers["X-Redis-Cache"] == "HIT" + assert len(flushed.keys("*")) == 1 + + aclient = aioredis.Redis() + try: + removed = await CacheBackend(aclient, eviction_group="items").delete_group() + finally: + await aclient.aclose() + + assert removed == 1, "the decorator's entry was in the group" + assert flushed.keys("*") == [] + assert client.get("/grouped").headers["X-Redis-Cache"] == "MISS" diff --git a/tests/integration/test_cache_payloads.py b/tests/integration/test_cache_payloads.py new file mode 100644 index 0000000..185165f --- /dev/null +++ b/tests/integration/test_cache_payloads.py @@ -0,0 +1,522 @@ +"""Integration tests for which payloads ``cache()`` stores, and which it refuses. + +One table drives the whole matrix. Every row is a complete request/response +shape - media type, charset, body bytes, response headers, request headers, +status and response class - paired with the one thing that matters: whether +the entry may be stored. + +Both outcomes are asserted end to end against real Redis: + +* **Stored** - the second request is a ``HIT``, the endpoint ran once, and the + body *and* content type come back exactly as they went in. +* **Refused** - both requests are a ``BYPASS``, the endpoint ran twice, Redis + holds nothing, and the client still receives the untouched response. + +The second half is the point. A refusal must never change what the caller +gets; it only means the library stored nothing. See "Cache scope" in +``docs/guide/architecture.md`` and "RFC 9111 conformance" in +``docs/guide/caching.md``. +""" + +from __future__ import annotations + +import gzip +import json +from collections.abc import Generator +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +import redis as sync_redis +from fastapi import Depends, FastAPI, Request +from fastapi.testclient import TestClient +from starlette.responses import FileResponse, Response, StreamingResponse + +from redis_fastapi.cache import cache +from redis_fastapi.setup import FastAPIRedis +from tests.conftest import requires_redis + +# --------------------------------------------------------------------------- +# The matrix +# --------------------------------------------------------------------------- + +JSON_BODY = b'{"value": 1}' +UNICODE_BODY = '{"text": "日本語 \U0001f389 مرحبا"}'.encode() +PNG_BODY = b"\x89PNG\r\n\x1a\n\x00\x01\x02\xff\xfe" +LATIN1_BODY = "caf\xe9".encode("latin-1") + + +@dataclass(frozen=True) +class Case: + """One payload shape and whether the cache may store it.""" + + name: str + body: bytes + media_type: str | None + stored: bool + why: str + status: int = 200 + shape: str = "response" # response | streaming | file + extra_headers: dict[str, str] = field(default_factory=dict) + request_headers: dict[str, str] = field(default_factory=dict) + + +CASES: list[Case] = [ + # -- text representations: stored ------------------------------------ + Case("json", JSON_BODY, "application/json", True, "the default case"), + Case("json-unicode", UNICODE_BODY, "application/json", True, "non-ASCII UTF-8"), + Case("json-empty", b"", "application/json", True, "empty body is still a body"), + Case( + "problem-json", + b'{"title": "x"}', + "application/problem+json", + True, + "+json suffix", + ), + Case("ld-json", b'{"@id": "x"}', "application/ld+json", True, "+json suffix"), + Case("xml", b"1", "application/xml", True, "allowlisted"), + Case("atom-xml", b"", "application/atom+xml", True, "+xml suffix"), + Case("javascript", b"var a = 1;", "application/javascript", True, "allowlisted"), + Case("text-plain", b"hello", "text/plain", True, "text/* family"), + Case("text-html", b"

hi

", "text/html", True, "text/* family"), + Case("text-csv", b"a,b\n1,2\n", "text/csv", True, "text/* family"), + Case("charset-utf8", b"hello", "text/plain; charset=utf-8", True, "declared UTF-8"), + Case( + "charset-ascii", + b"hello", + "text/plain; charset=us-ascii", + True, + "ASCII is UTF-8", + ), + Case( + "streaming-text", b"chunk1chunk2", "text/plain", True, "buffered, then stored" + ), + Case("file-text", b"file contents\n", "text/plain", True, "a file of text is text"), + Case( + "cache-control-public", + JSON_BODY, + "application/json", + True, + "public does not forbid storage", + extra_headers={"Cache-Control": "public, max-age=60"}, + ), + # -- binary: refused ------------------------------------------------- + Case("octet-stream", PNG_BODY, "application/octet-stream", False, "binary"), + Case("image-png", PNG_BODY, "image/png", False, "binary"), + Case("application-pdf", b"%PDF-1.4\x00\xff", "application/pdf", False, "binary"), + Case("msgpack", b"\x82\xa1a\x01", "application/msgpack", False, "binary"), + Case("protobuf", b"\x08\x96\x01", "application/x-protobuf", False, "binary"), + Case("streaming-binary", PNG_BODY, "image/png", False, "binary", shape="streaming"), + Case("file-binary", PNG_BODY, "image/png", False, "binary", shape="file"), + # -- text-ish but not UTF-8: refused --------------------------------- + Case( + "charset-latin1", + LATIN1_BODY, + "text/plain; charset=iso-8859-1", + False, + "not UTF-8", + ), + Case( + "charset-utf16", + "hi".encode("utf-16"), + "text/plain; charset=utf-16", + False, + "not UTF-8", + ), + Case( + "json-invalid-utf8", + b'\xff\xfe{"a": 1}', + "application/json", + False, + "allowlisted type, but the bytes are not the UTF-8 it promised", + ), + # -- representation metadata: preserved ------------------------------ + Case( + "endpoint-etag", + JSON_BODY, + "application/json", + True, + "an endpoint's own validator is replayed, not replaced by our hash", + extra_headers={"ETag": '"strong-v7"'}, + ), + Case( + "vary-header", + JSON_BODY, + "application/json", + True, + "Vary is stored and replayed so intermediaries still see it", + extra_headers={"Vary": "Accept-Language"}, + ), + Case( + "content-encoding-identity", + JSON_BODY, + "application/json", + True, + "identity is the absence of an encoding", + extra_headers={"Content-Encoding": "identity"}, + ), + Case( + "pagination-headers", + JSON_BODY, + "application/json", + True, + "Link and X-Total-Count are what a paginating client follows", + extra_headers={ + "Link": '; rel="next"', + "X-Total-Count": "4211", + }, + ), + Case( + "download-headers", + b"a,b\n1,2\n", + "text/csv", + True, + "a cached export must still download under its filename", + extra_headers={ + "Content-Disposition": 'attachment; filename="report.csv"', + "Content-Language": "de-DE", + }, + ), + Case( + "validator-headers", + JSON_BODY, + "application/json", + True, + "Last-Modified and Content-Location are representation metadata", + extra_headers={ + "Last-Modified": "Wed, 10 Sep 2026 12:00:00 GMT", + "Content-Location": "/payload?page=1", + }, + ), + # -- representation metadata that forbids storage: refused ----------- + Case( + "content-encoding-gzip", + gzip.compress(JSON_BODY), + "application/json", + False, + "RFC 9110 section 8.4 - the entry cannot carry the decoding step", + extra_headers={"Content-Encoding": "gzip"}, + ), + Case( + "vary-star", + JSON_BODY, + "application/json", + False, + "RFC 9111 section 4.1 - a Vary: * response may never be reused", + extra_headers={"Vary": "*"}, + ), + # -- no declared representation: refused ----------------------------- + Case("no-content-type", b"raw", None, False, "nothing says how to replay it"), + # -- RFC 9111 storage rules: refused --------------------------------- + Case("status-201", JSON_BODY, "application/json", False, "not 200", status=201), + Case( + "response-no-store", + JSON_BODY, + "application/json", + False, + "RFC 9111 section 3", + extra_headers={"Cache-Control": "no-store"}, + ), + Case( + "response-private", + JSON_BODY, + "application/json", + False, + "RFC 9111 section 5.2.2.7 - a Redis entry is a shared cache", + extra_headers={"Cache-Control": "private, max-age=60"}, + ), + Case( + "request-range-full-200", + b"0123456789", + "text/plain", + False, + "RFC 9111 section 3.3 - the key carries no Range", + request_headers={"Range": "bytes=0-3"}, + ), + Case( + "file-range-206", + b"0123456789" * 8, + "text/plain", + False, + "RFC 9111 section 3.3 - a 206 must never be replayed as a 200", + shape="file", + request_headers={"Range": "bytes=0-15"}, + ), +] + + +def _build_app(case: Case, tmp_path: Path, calls: list[int]) -> FastAPI: + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + if case.shape == "file": + target = tmp_path / f"{case.name}.bin" + target.write_bytes(case.body) + + @app.get("/payload", dependencies=[Depends(cache(ttl=300))]) + async def payload() -> Response: + calls[0] += 1 + if case.shape == "file": + return FileResponse( + target, + media_type=case.media_type, + headers=dict(case.extra_headers), + status_code=case.status, + ) + if case.shape == "streaming": + return StreamingResponse( + iter([case.body]), + media_type=case.media_type, + headers=dict(case.extra_headers), + status_code=case.status, + ) + return Response( + content=case.body, + media_type=case.media_type, + headers=dict(case.extra_headers), + status_code=case.status, + ) + + return app + + +@pytest.fixture() +def flushed(real_redis: sync_redis.Redis) -> Generator[sync_redis.Redis, None, None]: + real_redis.flushdb() + yield real_redis + real_redis.flushdb() + + +@requires_redis +@pytest.mark.integration +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.name) +def test_payload_matrix(case: Case, tmp_path: Path, flushed: sync_redis.Redis) -> None: + calls = [0] + app = _build_app(case, tmp_path, calls) + + with TestClient(app) as client: + r1 = client.get("/payload", headers=case.request_headers) + r2 = client.get("/payload", headers=case.request_headers) + + keys = flushed.keys("*") + + if case.stored: + assert r1.headers.get("X-Redis-Cache") == "MISS", case.why + assert r2.headers.get("X-Redis-Cache") == "HIT", case.why + assert calls[0] == 1, "the hit must not re-run the endpoint" + + # The representation must survive the round trip whole: same bytes, + # same content type. Replaying text/csv as application/json is the + # bug this matrix exists to catch. + assert r2.content == r1.content == case.body + + # Every field the endpoint sent comes back on the hit, apart from the + # groups an entry deliberately leaves out. Comparing the whole set + # rather than a few named fields is what makes the *next* dropped + # header fail here instead of in production. + # + # Skipped: stamped per response (date, server), recomputed from the + # replayed body (content-length), deliberately different (the cache + # status), or time-dependent (cache-control counts down). + skipped = {"date", "server", "content-length", "x-redis-cache", "cache-control"} + miss_fields = { + (k.lower(), v) for k, v in r1.headers.items() if k.lower() not in skipped + } + hit_fields = { + (k.lower(), v) for k, v in r2.headers.items() if k.lower() not in skipped + } + assert miss_fields == hit_fields + + assert len(keys) == 1, f"expected exactly one entry, got {keys}" + entry = json.loads(flushed.get(keys[0])) + assert entry["body"] == case.body.decode() + assert "v" not in entry, "entries carry no format marker" + stored = dict(entry["headers"]) + assert stored["content-type"] == r1.headers["content-type"] + assert "date" not in stored, "Date must not be stored - it double-counts age" + assert "content-length" not in stored, "framing is recomputed" + else: + assert r1.headers.get("X-Redis-Cache") == "BYPASS", case.why + assert r2.headers.get("X-Redis-Cache") == "BYPASS", case.why + assert calls[0] == 2, "a refused response must be produced every time" + assert keys == [], f"nothing may be stored, found {keys}" + + # A refusal changes nothing the caller can see. + assert r1.status_code == r2.status_code + assert r1.content == r2.content + assert r1.headers.get("content-type") == r2.headers.get("content-type") + if case.media_type is not None: + assert r1.headers.get("content-type") is not None + + +@requires_redis +@pytest.mark.integration +def test_oversized_payload_refused(flushed: sync_redis.Redis) -> None: + """A body over ``MAX_CACHEABLE_BODY_SIZE`` is delivered but not stored. + + The oversized path flushes the buffered start before it knows the final + size, so this is the one refusal whose ``BYPASS`` marker is added by + ``_flush_oversized_response`` rather than by the storage decision. + """ + from redis_fastapi.cache import MAX_CACHEABLE_BODY_SIZE + + payload = "x" * (MAX_CACHEABLE_BODY_SIZE + 1) + calls = [0] + + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get("/huge", dependencies=[Depends(cache(ttl=300))]) + async def huge() -> dict: + calls[0] += 1 + return {"data": payload} + + with TestClient(app) as client: + r1 = client.get("/huge") + r2 = client.get("/huge") + + assert r1.status_code == r2.status_code == 200 + assert r1.json()["data"] == payload, "the body must be delivered whole" + assert r2.json()["data"] == payload + assert r1.headers.get("X-Redis-Cache") == "BYPASS" + assert r2.headers.get("X-Redis-Cache") == "BYPASS" + assert calls[0] == 2 + assert flushed.keys("*") == [] + + +@requires_redis +@pytest.mark.integration +@pytest.mark.parametrize("put_status", [200, 201]) +def test_write_through_stores_any_2xx( + put_status: int, flushed: sync_redis.Redis +) -> None: + """``cache_put()`` may store a 201, because the 201 never gets replayed. + + The body is installed as the representation for a later GET, which answers + 200 with it. The read path refuses a 201 for the opposite reason: there, + the stored response *is* the one the next client receives. + """ + from redis_fastapi.cache import cache_put, default_key_builder + + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + get_calls = [0] + + @app.get( + "/widgets/{widget_id}", + dependencies=[Depends(cache(ttl=300, eviction_group="widgets"))], + ) + async def read_widget(widget_id: str) -> dict: + get_calls[0] += 1 + return {"id": widget_id, "source": "endpoint"} + + @app.put( + "/widgets/{widget_id}", + status_code=put_status, + dependencies=[ + Depends( + cache_put( + eviction_group="widgets", + key_builder=default_key_builder, + ttl=300, + ) + ) + ], + ) + async def write_widget(widget_id: str) -> dict: + return {"id": widget_id, "source": "write-through"} + + with TestClient(app) as client: + put = client.put("/widgets/w1") + assert put.status_code == put_status + + got = client.get("/widgets/w1") + + # The write-through body was installed, so the GET is a hit that never + # reached the endpoint - and it answers 200 regardless of the PUT status. + assert got.status_code == 200 + assert got.headers.get("X-Redis-Cache") == "HIT" + assert got.json()["source"] == "write-through" + assert get_calls[0] == 0 + assert len(flushed.keys("*")) == 1 + + +@requires_redis +@pytest.mark.integration +def test_documented_per_user_key_builder_recipe(flushed: sync_redis.Redis) -> None: + """The per-user ``key_builder`` recipe from the caching guide works. + + This is the documented remedy for RFC 9111 section 3.5 - a shared entry + would otherwise serve the first caller's body to everyone. The guide + shows this code, so it is tested rather than merely asserted. + """ + from redis_fastapi.cache import default_key_builder + + def key_per_user(request, eviction_group="", prefix=""): # type: ignore[no-untyped-def] + base = default_key_builder( + request, eviction_group=eviction_group, prefix=prefix + ) + return f"{base}:u:{request.headers.get('x-user', 'anon')}" + + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get( + "/me/profile", + dependencies=[Depends(cache(ttl=300, private=True, key_builder=key_per_user))], + ) + async def profile(request: Request) -> dict: + return {"user": request.headers.get("x-user")} + + with TestClient(app) as client: + alice1 = client.get("/me/profile", headers={"X-User": "alice"}) + bob1 = client.get("/me/profile", headers={"X-User": "bob"}) + alice2 = client.get("/me/profile", headers={"X-User": "alice"}) + + assert alice1.headers["X-Redis-Cache"] == "MISS" + # Bob must not be served Alice's entry. + assert bob1.headers["X-Redis-Cache"] == "MISS" + assert bob1.json() == {"user": "bob"} + assert alice2.headers["X-Redis-Cache"] == "HIT" + assert alice2.json() == {"user": "alice"} + assert len(flushed.keys("*")) == 2, "one entry per caller" + + +@requires_redis +@pytest.mark.integration +def test_documented_negotiated_key_builder_recipe(flushed: sync_redis.Redis) -> None: + """The ``Accept-Language`` ``key_builder`` recipe from the guide works. + + ``Vary`` is ignored, so folding the negotiated header into the key is the + documented way to keep one URL's variants apart. + """ + from redis_fastapi.cache import default_key_builder + + def key_with_language(request, eviction_group="", prefix=""): # type: ignore[no-untyped-def] + base = default_key_builder( + request, eviction_group=eviction_group, prefix=prefix + ) + lang = request.headers.get("accept-language", "*") + return f"{base}:lang={lang}" + + app = FastAPI() + FastAPIRedis(app).lifespan().caching() + + @app.get( + "/articles/{slug}", + dependencies=[Depends(cache(ttl=300, key_builder=key_with_language))], + ) + async def article(slug: str, request: Request) -> dict: + return {"lang": request.headers.get("accept-language")} + + with TestClient(app) as client: + en = client.get("/articles/x", headers={"Accept-Language": "en"}) + de = client.get("/articles/x", headers={"Accept-Language": "de"}) + en2 = client.get("/articles/x", headers={"Accept-Language": "en"}) + + assert en.headers["X-Redis-Cache"] == "MISS" + assert de.headers["X-Redis-Cache"] == "MISS" + assert de.json() == {"lang": "de"}, "the German reader must not get English" + assert en2.headers["X-Redis-Cache"] == "HIT" + assert en2.json() == {"lang": "en"} + assert len(flushed.keys("*")) == 2 diff --git a/tests/integration/test_lifespan.py b/tests/integration/test_lifespan.py index f203143..863bd29 100644 --- a/tests/integration/test_lifespan.py +++ b/tests/integration/test_lifespan.py @@ -5,6 +5,8 @@ from __future__ import annotations +import asyncio +import time from unittest.mock import patch import pytest @@ -50,6 +52,70 @@ async def ping() -> dict: ps = _get_pool_state(app) assert ps.async_pool is None + async def test_client_aclose_releases_no_pooled_connection( + self, real_redis: sync_redis.Redis + ) -> None: + """``clear()`` has no connection to close; the pool owns them all. + + Measured on the server with ``INFO clients``, because only the server + knows what is still open - and asserted while the event loop is still + running, since loop teardown closes every socket on it and would mask + the difference. + + The cached client is built with ``connection_pool=`` passed in, so + ``auto_close_connection_pool`` is ``False`` and ``Redis.aclose()`` + disconnects nothing. ``ConnectionPool.aclose()`` is what frees the + connections, and the lifespan already calls it. That is why + ``_PoolState.clear()`` stays synchronous and closes no client. + """ + fanout = 5 + + def connected() -> int: + return int(real_redis.info("clients")["connected_clients"]) + + def settle(target: int) -> int: + # The server reaps closed sockets asynchronously. + for _ in range(40): + if connected() <= target: + break + time.sleep(0.05) + return connected() + + baseline = connected() + + ps = _PoolState() + ps.async_pool = _PoolState.build_async_pool() + client = ps.get_async_client() + assert client.auto_close_connection_pool is False + + try: + # Concurrent commands force the pool to open several connections. + await asyncio.gather(*(client.ping() for _ in range(fanout))) + peak = connected() + assert peak >= baseline + fanout, ( + f"expected {fanout} new connections, baseline {baseline}, now {peak}" + ) + + # Closing the *client* frees none of them. + await client.aclose() + assert connected() == peak, ( + "Redis.aclose() disconnected pooled connections; the premise " + "that clear() need not close the client no longer holds" + ) + + # Closing the *pool* frees all of them. + ps.clear() + assert ps._async_client is None + await ps.async_pool.aclose() + assert settle(baseline) <= baseline, ( + f"pool.aclose() left connections open: baseline {baseline}, " + f"peak {peak}, now {connected()}" + ) + finally: + if ps.async_pool is not None: + await ps.async_pool.aclose() + ps.async_pool = None + def test_deps_use_lifespan_pools(self) -> None: """AsyncRedisDep should use the lifespan-managed pool.""" app = FastAPI() diff --git a/tests/unit/test_adversarial.py b/tests/unit/test_adversarial.py index 020227f..fba2318 100644 --- a/tests/unit/test_adversarial.py +++ b/tests/unit/test_adversarial.py @@ -59,7 +59,7 @@ def _make_request(path: str, query: str = "") -> StarletteRequest: @pytest.mark.unit class TestNon2xxNotCached: - """4xx/5xx responses must NOT be cached.""" + """Only a 200 may be stored on the read path (RFC 9111 section 3).""" def test_404_not_cached( self, fake_async_redis: fakeredis.aioredis.FakeRedis @@ -120,10 +120,17 @@ async def bad() -> dict: c.get("/bad") assert counts[0] == 2 - def test_201_is_cached( + def test_201_not_cached_on_read_path( self, fake_async_redis: fakeredis.aioredis.FakeRedis ) -> None: - """2xx responses other than 200 should still be cached.""" + """A 2xx that is not 200 must not be stored by ``cache()``. + + The entry carries no status code, so the hit path can only ever answer + 200. Storing a 201 would therefore replay it as a 200 - the status + would change between the first request and the second. Refusing is + what RFC 9111 section 3 requires of a cache that cannot reproduce the + status it stored. + """ app = FastAPI() FastAPIRedis(app).caching() counts = [0] @@ -137,9 +144,14 @@ async def created() -> dict: with TestClient(app) as c: r1 = c.get("/created") assert r1.status_code == 201 + assert r1.headers.get("X-Redis-Cache") == "BYPASS" + r2 = c.get("/created") - assert r2.headers.get("X-Redis-Cache") == "HIT" - assert counts[0] == 1 + # Served by the endpoint again, and still a 201 rather than a + # cache hit downgraded to 200. + assert r2.status_code == 201 + assert r2.headers.get("X-Redis-Cache") == "BYPASS" + assert counts[0] == 2 # =================================================================== @@ -242,22 +254,78 @@ class TestStreamingResponse: def test_streaming_response_delivered( self, fake_async_redis: fakeredis.aioredis.FakeRedis ) -> None: + """A multi-chunk body round-trips, content type and all. + + The second request asserts the *hit* path, which is where the stored + representation is rebuilt. Checking only the miss would miss a + content type that the entry never carried. + """ app = FastAPI() FastAPIRedis(app).caching() + calls = [0] async def generate(): yield b"chunk1" yield b"chunk2" + @app.get("/stream", dependencies=[Depends(cache(ttl=300))]) + async def stream() -> StreamingResponse: + calls[0] += 1 + return StreamingResponse(generate(), media_type="text/plain") + + app.dependency_overrides[get_async_redis] = _make_fake_dep(fake_async_redis) + with TestClient(app) as c: + r1 = c.get("/stream") + assert r1.status_code == 200 + assert r1.text == "chunk1chunk2" + assert r1.headers["X-Redis-Cache"] == "MISS" + assert r1.headers["content-type"] == "text/plain; charset=utf-8" + + r2 = c.get("/stream") + assert r2.status_code == 200 + assert r2.headers["X-Redis-Cache"] == "HIT" + # The representation must come back as it went in - not as JSON. + assert r2.headers["content-type"] == r1.headers["content-type"] + assert r2.content == r1.content + assert calls[0] == 1, "hit must not re-run the endpoint" + + def test_streaming_is_buffered_not_streamed( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """Caching a streamed body defeats the streaming. + + The middleware has to see the whole body before it can store it, so + every chunk is pulled from the generator before the client receives + the first byte. This is a documented limit of ``cache()``, not a + bug - the test pins it so it cannot change unnoticed. + """ + app = FastAPI() + FastAPIRedis(app).caching() + order: list[str] = [] + + async def generate(): + for i in range(4): + order.append(f"produced-{i}") + yield b"x" * 8 + @app.get("/stream", dependencies=[Depends(cache(ttl=300))]) async def stream() -> StreamingResponse: return StreamingResponse(generate(), media_type="text/plain") app.dependency_overrides[get_async_redis] = _make_fake_dep(fake_async_redis) with TestClient(app) as c: - r = c.get("/stream") - assert r.status_code == 200 - assert r.text == "chunk1chunk2" + with c.stream("GET", "/stream") as r: + for chunk in r.iter_bytes(): + if chunk: + order.append("client-received") + + first_receipt = order.index("client-received") + produced = [i for i, e in enumerate(order) if e.startswith("produced-")] + assert produced, "generator never ran" + assert max(produced) < first_receipt, ( + "expected the whole body to be buffered before the client saw " + f"anything; got {order}" + ) # =================================================================== @@ -269,26 +337,74 @@ async def stream() -> StreamingResponse: class TestOversizedResponse: """Responses exceeding MAX_CACHEABLE_BODY_SIZE must still be delivered.""" - def test_oversized_response_not_cached_but_delivered( + async def test_oversized_response_not_cached_but_delivered( self, fake_async_redis: fakeredis.aioredis.FakeRedis ) -> None: + """An oversized body is delivered whole and stored nowhere. + + The old version of this test asserted only ``status_code == 200`` on + both requests, which a fully cached response would also satisfy. It + now checks the three things that actually distinguish the two: the + body is intact, Redis holds no key, and the endpoint ran twice. + """ app = FastAPI() FastAPIRedis(app).caching() + calls = [0] big_data = "x" * (MAX_CACHEABLE_BODY_SIZE + 1) @app.get("/big", dependencies=[Depends(cache(ttl=300))]) async def big() -> dict: + calls[0] += 1 return {"data": big_data} app.dependency_overrides[get_async_redis] = _make_fake_dep(fake_async_redis) with TestClient(app) as c: - r = c.get("/big") - assert r.status_code == 200 - # Should NOT be cached (too large) + r1 = c.get("/big") + assert r1.status_code == 200 + assert r1.json()["data"] == big_data, "body must be delivered whole" + assert r1.headers.get("X-Redis-Cache") == "BYPASS" + r2 = c.get("/big") - # Both should succeed without X-Redis-Cache: HIT assert r2.status_code == 200 + assert r2.headers.get("X-Redis-Cache") == "BYPASS" + assert r2.json()["data"] == big_data + + assert calls[0] == 2, "neither request may be served from cache" + assert await fake_async_redis.keys("*") == [], "nothing may be stored" + + async def test_oversized_multi_chunk_flushes_buffered_body( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """A body that crosses the limit mid-stream is still delivered whole. + + The single-chunk case trips the guard with an empty buffer. This one + accumulates under the limit first, so the middleware has to flush what + it already holds as a partial chunk before forwarding the rest. + """ + app = FastAPI() + FastAPIRedis(app).caching() + + chunk = b"y" * (1024 * 1024) + chunk_count = (MAX_CACHEABLE_BODY_SIZE // len(chunk)) + 1 + + async def generate(): + for _ in range(chunk_count): + yield chunk + + @app.get("/drip", dependencies=[Depends(cache(ttl=300))]) + async def drip() -> StreamingResponse: + return StreamingResponse(generate(), media_type="text/plain") + + app.dependency_overrides[get_async_redis] = _make_fake_dep(fake_async_redis) + with TestClient(app) as c: + r = c.get("/drip") + + assert r.status_code == 200 + assert len(r.content) == len(chunk) * chunk_count, "body must arrive whole" + assert r.content == chunk * chunk_count + assert r.headers.get("X-Redis-Cache") == "BYPASS" + assert await fake_async_redis.keys("*") == [] # =================================================================== diff --git a/tests/unit/test_cache_scope.py b/tests/unit/test_cache_scope.py new file mode 100644 index 0000000..a40b07c --- /dev/null +++ b/tests/unit/test_cache_scope.py @@ -0,0 +1,877 @@ +"""Unit tests for cache scope: which representations may be stored, and why. + +The integration matrix in ``tests/integration/test_cache_payloads.py`` covers +the end-to-end behaviour against real Redis. This file pins the pieces that +matrix cannot reach: the header parsers on their own, the refusal reasons +themselves, warn-once logging, and entries written before the content type was +part of the format. +""" + +from __future__ import annotations + +import json +import logging + +import fakeredis.aioredis +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from starlette.requests import Request +from starlette.responses import Response + +from redis_fastapi.cache import ( + _WARNED_REFUSALS, + MAX_CACHEABLE_HEADER_SIZE, + CachePending, + _excluded_from_storage, + _is_cacheable_media_type, + _merge_headers, + _split_content_type, + _storable_headers, + _storage_refusal, + _warn_refusal_once, + cache, +) +from redis_fastapi.config import get_settings +from redis_fastapi.deps import get_async_redis +from redis_fastapi.setup import FastAPIRedis + + +def _request(path: str = "/x", headers: dict[str, str] | None = None) -> Request: + raw = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()] + return Request( + { + "type": "http", + "method": "GET", + "path": path, + "headers": raw, + "query_string": b"", + } + ) + + +def _fake_dep(fake: fakeredis.aioredis.FakeRedis): + async def _dep() -> fakeredis.aioredis.FakeRedis: + return fake + + return _dep + + +# =================================================================== +# Content-Type parsing +# =================================================================== + + +@pytest.mark.unit +class TestSplitContentType: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, ("", None)), + (b"application/json", ("application/json", None)), + (b"text/plain; charset=utf-8", ("text/plain", "utf-8")), + (b"TEXT/PLAIN; CHARSET=UTF-8", ("text/plain", "utf-8")), + (b'text/plain; charset="utf-8"', ("text/plain", "utf-8")), + (b"text/plain ; charset = utf-8 ", ("text/plain", "utf-8")), + (b"text/html; charset=iso-8859-1", ("text/html", "iso-8859-1")), + (b"multipart/form-data; boundary=xyz", ("multipart/form-data", None)), + ], + ) + def test_split(self, raw: bytes | None, expected: tuple[str, str | None]) -> None: + assert _split_content_type(raw) == expected + + +@pytest.mark.unit +class TestIsCacheableMediaType: + @pytest.mark.parametrize( + "media_type", + [ + "application/json", + "application/xml", + "application/javascript", + "application/problem+json", + "application/atom+xml", + "text/plain", + "text/html", + "text/csv", + "text/anything-at-all", + ], + ) + def test_allowed(self, media_type: str) -> None: + assert _is_cacheable_media_type(media_type) is True + + @pytest.mark.parametrize( + "media_type", + [ + "", + "image/png", + "image/svg+xml", + "application/octet-stream", + "application/pdf", + "application/msgpack", + "application/x-protobuf", + "audio/mpeg", + "video/mp4", + "multipart/form-data", + ], + ) + def test_refused(self, media_type: str) -> None: + assert _is_cacheable_media_type(media_type) is False + + def test_image_svg_xml_is_refused_despite_suffix(self) -> None: + """``+xml`` only admits the ``application/`` tree. + + ``image/svg+xml`` is text in practice, but admitting it would mean + admitting an ``image/*`` type, and the next reader of the allowlist + would reasonably read that as "images are cacheable". + """ + assert _is_cacheable_media_type("image/svg+xml") is False + + +# =================================================================== +# Refusal reasons +# =================================================================== + + +@pytest.mark.unit +class TestStorageRefusal: + @staticmethod + def _pending(*, write_through: bool = False) -> CachePending: + return CachePending(key="k", ttl=60, write_through=write_through) + + def test_json_200_is_stored(self) -> None: + assert ( + _storage_refusal( + _request(), + self._pending(), + 200, + [(b"content-type", b"application/json")], + ) + is None + ) + + @pytest.mark.parametrize("status", [201, 202, 203, 204, 206, 301, 404, 500]) + def test_non_200_refused_on_read_path(self, status: int) -> None: + reason = _storage_refusal( + _request(), + self._pending(), + status, + [(b"content-type", b"application/json")], + ) + assert reason is not None + assert str(status) in reason + + @pytest.mark.parametrize("status", [200, 201, 202]) + def test_any_2xx_stored_on_write_through(self, status: int) -> None: + """Write-through installs the body for a later GET. + + The status of the PUT that produced it never reaches a client, so it + does not constrain storage the way a read-path fill does. + """ + assert ( + _storage_refusal( + _request(), + self._pending(write_through=True), + status, + [(b"content-type", b"application/json")], + ) + is None + ) + + @pytest.mark.parametrize("status", [304, 400, 500]) + def test_non_2xx_refused_on_write_through(self, status: int) -> None: + reason = _storage_refusal( + _request(), + self._pending(write_through=True), + status, + [(b"content-type", b"application/json")], + ) + assert reason is not None and "2xx" in reason + + def test_content_range_refused(self) -> None: + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"text/plain"), + (b"content-range", b"bytes 0-9/100"), + ], + ) + assert reason == "response carries Content-Range" + + def test_request_range_refused(self) -> None: + reason = _storage_refusal( + _request(headers={"Range": "bytes=0-9"}), + self._pending(), + 200, + [(b"content-type", b"text/plain")], + ) + assert reason == "request carried Range" + + @pytest.mark.parametrize( + ("header", "expected"), + [ + (b"no-store", "response set Cache-Control: no-store"), + (b"private", "response set Cache-Control: private"), + (b"private, max-age=60", "response set Cache-Control: private"), + (b"public, no-store", "response set Cache-Control: no-store"), + ], + ) + def test_response_cache_control_refused(self, header: bytes, expected: str) -> None: + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [(b"content-type", b"application/json"), (b"cache-control", header)], + ) + assert reason == expected + + @pytest.mark.parametrize( + "header", [b"public, max-age=60", b"no-cache", b"must-revalidate"] + ) + def test_permissive_cache_control_allowed(self, header: bytes) -> None: + """Only ``no-store`` and ``private`` forbid storage (RFC 9111 §3).""" + assert ( + _storage_refusal( + _request(), + self._pending(), + 200, + [(b"content-type", b"application/json"), (b"cache-control", header)], + ) + is None + ) + + def test_missing_content_type_refused(self) -> None: + reason = _storage_refusal(_request(), self._pending(), 200, []) + assert reason is not None and "none" in reason + + def test_binary_content_type_refused(self) -> None: + reason = _storage_refusal( + _request(), self._pending(), 200, [(b"content-type", b"image/png")] + ) + assert reason is not None and "image/png" in reason + + def test_non_utf8_charset_refused(self) -> None: + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [(b"content-type", b"text/plain; charset=iso-8859-1")], + ) + assert reason is not None and "iso-8859-1" in reason + + +# =================================================================== +# Representation metadata: what the entry preserves +# =================================================================== + + +@pytest.mark.unit +class TestEncodedAndUnreusableResponses: + """Refusals that come from the response's own metadata.""" + + @staticmethod + def _pending() -> CachePending: + return CachePending(key="k", ttl=60) + + @pytest.mark.parametrize( + ("header", "expected"), + [ + (b"gzip", "response carries Content-Encoding: gzip"), + (b"br", "response carries Content-Encoding: br"), + (b"gzip, br", "response carries Content-Encoding: gzip, br"), + ], + ) + def test_encoded_body_refused_by_name(self, header: bytes, expected: str) -> None: + """An encoded body is named as such, not reported as bad UTF-8. + + A gzip stream fails the UTF-8 decode anyway, so the response was + already refused - but the reason told the operator the bytes were + broken rather than that the response was compressed. + """ + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [(b"content-type", b"application/json"), (b"content-encoding", header)], + ) + assert reason == expected + + def test_identity_encoding_is_not_an_encoding(self) -> None: + assert ( + _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"application/json"), + (b"content-encoding", b"identity"), + ], + ) + is None + ) + + def test_vary_star_refused(self) -> None: + """RFC 9111 section 4.1: a ``Vary: *`` response may never be reused.""" + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [(b"content-type", b"application/json"), (b"vary", b"*")], + ) + assert reason == "response set Vary: *" + + def test_ordinary_vary_is_stored(self) -> None: + assert ( + _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"application/json"), + (b"vary", b"Accept-Language"), + ], + ) + is None + ) + + +@pytest.mark.unit +class TestOwnedHeadersReplaceRatherThanAppend: + """``ETag`` and ``Cache-Control`` are single-valued fields.""" + + def test_merge_replaces_every_earlier_occurrence(self) -> None: + merged = _merge_headers( + [ + (b"etag", b'"endpoint"'), + (b"cache-control", b"public, max-age=600"), + (b"x-keep", b"kept"), + (b"ETag", b'"second"'), + ], + [(b"etag", b'W/"ours"'), (b"cache-control", b"max-age=60")], + ) + assert merged == [ + (b"x-keep", b"kept"), + (b"etag", b'W/"ours"'), + (b"cache-control", b"max-age=60"), + ] + + def test_endpoint_etag_survives_and_revalidates( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """An endpoint's own validator is replayed, not replaced by a hash. + + RFC 9110 section 8.8.3 defines ``ETag = entity-tag`` - a single tag. + Appending ours beside the endpoint's produced a field holding two, + and a client echoing it back got a full 200 instead of a 304. + """ + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/tagged", dependencies=[Depends(cache(ttl=300))]) + async def tagged() -> Response: + return Response( + content='{"v": 1}', + media_type="application/json", + headers={"ETag": '"strong-v7"'}, + ) + + app.dependency_overrides[get_async_redis] = _fake_dep(fake_async_redis) + get_settings.cache_clear() + try: + with TestClient(app) as c: + miss = c.get("/tagged") + hit = c.get("/tagged") + revalidated = c.get( + "/tagged", headers={"If-None-Match": miss.headers["etag"]} + ) + assert miss.headers["etag"] == '"strong-v7"' + assert hit.headers["etag"] == '"strong-v7"' + assert hit.headers["X-Redis-Cache"] == "HIT" + assert revalidated.status_code == 304 + finally: + get_settings.cache_clear() + + def test_cache_control_is_not_duplicated( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """We own ``Cache-Control`` on a cached route, on the miss as well. + + Appending produced ``public, max-age=600, max-age=600`` on the miss + and a bare ``max-age`` on the hit - two different policies for one + entry. + """ + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/policy", dependencies=[Depends(cache(ttl=600))]) + async def policy() -> Response: + return Response( + content='{"v": 1}', + media_type="application/json", + headers={"Cache-Control": "public, max-age=600"}, + ) + + app.dependency_overrides[get_async_redis] = _fake_dep(fake_async_redis) + get_settings.cache_clear() + try: + with TestClient(app) as c: + miss = c.get("/policy") + hit = c.get("/policy") + assert miss.headers["cache-control"].count("max-age") == 1 + assert miss.headers["cache-control"] == hit.headers["cache-control"] + finally: + get_settings.cache_clear() + + def test_vary_is_stored_and_replayed( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """``Vary`` reaches the client on a hit, so intermediaries still see it. + + The lookup ignores it, but dropping it from the response would make + one stored variant look like the only variant to a downstream cache + as well as to us. + """ + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/negotiated", dependencies=[Depends(cache(ttl=300))]) + async def negotiated() -> Response: + return Response( + content='{"v": 1}', + media_type="application/json", + headers={"Vary": "Accept-Language"}, + ) + + app.dependency_overrides[get_async_redis] = _fake_dep(fake_async_redis) + get_settings.cache_clear() + try: + with TestClient(app) as c: + miss = c.get("/negotiated") + hit = c.get("/negotiated") + validated = c.get( + "/negotiated", headers={"If-None-Match": hit.headers["etag"]} + ) + assert miss.headers["vary"] == "Accept-Language" + assert hit.headers["vary"] == "Accept-Language" + # RFC 9110 section 15.4.5 lists Vary among the fields a 304 carries. + assert validated.status_code == 304 + assert validated.headers["vary"] == "Accept-Language" + finally: + get_settings.cache_clear() + + +# =================================================================== +# Warn-once +# =================================================================== + + +@pytest.mark.unit +class TestWarnOnce: + def test_same_route_and_reason_warns_once( + self, caplog: pytest.LogCaptureFixture + ) -> None: + _WARNED_REFUSALS.clear() + request = _request("/dupe") + with caplog.at_level(logging.WARNING, logger="redis_fastapi.cache"): + for _ in range(5): + _warn_refusal_once(request, "because") + assert len(caplog.records) == 1 + assert "/dupe" in caplog.records[0].getMessage() + assert "because" in caplog.records[0].getMessage() + + def test_distinct_reasons_each_warn(self, caplog: pytest.LogCaptureFixture) -> None: + _WARNED_REFUSALS.clear() + request = _request("/multi") + with caplog.at_level(logging.WARNING, logger="redis_fastapi.cache"): + _warn_refusal_once(request, "reason one") + _warn_refusal_once(request, "reason two") + assert len(caplog.records) == 2 + + def test_refused_route_logs_once_across_requests( + self, + fake_async_redis: fakeredis.aioredis.FakeRedis, + caplog: pytest.LogCaptureFixture, + ) -> None: + _WARNED_REFUSALS.clear() + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/png", dependencies=[Depends(cache(ttl=300))]) + async def png() -> Response: + return Response(content=b"\x89PNG", media_type="image/png") + + app.dependency_overrides[get_async_redis] = _fake_dep(fake_async_redis) + get_settings.cache_clear() + try: + with caplog.at_level(logging.WARNING, logger="redis_fastapi.cache"): + with TestClient(app) as c: + for _ in range(4): + assert c.get("/png").headers["X-Redis-Cache"] == "BYPASS" + refusals = [r for r in caplog.records if "not cached" in r.getMessage()] + assert len(refusals) == 1 + assert "/png" in refusals[0].getMessage() + finally: + get_settings.cache_clear() + + +# =================================================================== +# Entry format: forwards and backwards +# =================================================================== + + +@pytest.mark.unit +class TestEntryFormat: + async def test_stored_entry_carries_the_header_block( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/csv", dependencies=[Depends(cache(ttl=300))]) + async def csv() -> Response: + return Response(content="a,b\n", media_type="text/csv") + + app.dependency_overrides[get_async_redis] = _fake_dep(fake_async_redis) + get_settings.cache_clear() + try: + with TestClient(app) as c: + r1 = c.get("/csv") + r2 = c.get("/csv") + assert r2.headers["X-Redis-Cache"] == "HIT" + assert r2.headers["content-type"] == r1.headers["content-type"] + assert r2.headers["content-type"] == "text/csv; charset=utf-8" + + keys = await fake_async_redis.keys("*") + entry = json.loads(await fake_async_redis.get(keys[0])) + assert entry["body"] == "a,b\n" + assert "v" not in entry, "entries carry no format marker" + assert dict(entry["headers"])["content-type"] == "text/csv; charset=utf-8" + assert "encoding" not in entry, "bodies are stored as text, not base64" + finally: + get_settings.cache_clear() + + +@pytest.mark.unit +class TestStorableHeaders: + """Which fields reach the entry, and which are left out.""" + + def test_endpoint_fields_are_stored_in_order(self) -> None: + stored = _storable_headers( + [ + (b"content-type", b"application/json"), + (b"Link", b'; rel="next"'), + (b"X-Total-Count", b"4211"), + ] + ) + assert stored == [ + ["content-type", "application/json"], + ["link", '; rel="next"'], + ["x-total-count", "4211"], + ] + + def test_repeated_fields_survive_as_repeats(self) -> None: + """A mapping would collapse these; the entry keeps both, in order.""" + stored = _storable_headers( + [ + (b"content-type", b"application/json"), + (b"link", b'; rel="next"'), + (b"link", b'; rel="last"'), + ] + ) + assert [v for k, v in stored if k == "link"] == [ + '; rel="next"', + '; rel="last"', + ] + + @pytest.mark.parametrize( + "excluded", + [ + b"cache-control", # this library re-emits it + b"etag", # stored as its own field + b"x-redis-cache", # ours + b"content-length", # recomputed from the replayed body + b"date", # replaying it double-counts the entry's age + b"set-cookie", # a shared entry must not replay a session + b"connection", # RFC 9110 section 7.6.1 + b"transfer-encoding", + b"keep-alive", + b"upgrade", + b"te", + b"proxy-authenticate", # RFC 9111 section 3.1, a MUST NOT + b"proxy-authorization", + b"proxy-authentication-info", + ], + ) + def test_excluded_fields_never_reach_the_entry(self, excluded: bytes) -> None: + stored = _storable_headers( + [(b"content-type", b"application/json"), (excluded, b"whatever")] + ) + assert stored == [["content-type", "application/json"]] + + def test_connection_names_further_fields_to_drop(self) -> None: + """``Connection`` lists fields that are specific to that message.""" + excluded = _excluded_from_storage([(b"connection", b"X-Hop-Only, Keep-Alive")]) + assert b"x-hop-only" in excluded + + stored = _storable_headers( + [ + (b"content-type", b"application/json"), + (b"connection", b"X-Hop-Only"), + (b"x-hop-only", b"internal"), + (b"x-kept", b"public"), + ] + ) + assert stored == [ + ["content-type", "application/json"], + ["x-kept", "public"], + ] + + +@pytest.mark.unit +class TestHitCarriesEndpointHeaders: + """The eight use cases that a four-field entry could not serve.""" + + @staticmethod + def _app(fake: fakeredis.aioredis.FakeRedis, headers: dict[str, str]) -> FastAPI: + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/items", dependencies=[Depends(cache(ttl=300))]) + async def items() -> Response: + return Response( + content='[{"id": 1}]', + media_type="application/json", + headers=headers, + ) + + app.dependency_overrides[get_async_redis] = _fake_dep(fake) + return app + + def test_pagination_and_representation_metadata_replay( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + sent = { + "Link": '; rel="next"', + "X-Total-Count": "4211", + "Content-Language": "de-DE", + "Content-Disposition": 'attachment; filename="items.json"', + "Last-Modified": "Wed, 10 Sep 2026 12:00:00 GMT", + "Content-Location": "/items?page=1", + "Content-Digest": "sha-256=:abc:", + } + get_settings.cache_clear() + try: + with TestClient(self._app(fake_async_redis, sent)) as c: + miss = c.get("/items") + hit = c.get("/items") + assert miss.headers["X-Redis-Cache"] == "MISS" + assert hit.headers["X-Redis-Cache"] == "HIT" + for name, value in sent.items(): + assert hit.headers.get(name) == value, name + finally: + get_settings.cache_clear() + + def test_cookies_are_not_replayed( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """A shared entry must not hand one caller's session to the next.""" + get_settings.cache_clear() + try: + app = self._app(fake_async_redis, {"Set-Cookie": "session=abc123; Path=/"}) + with TestClient(app) as c: + miss = c.get("/items") + hit = c.get("/items") + assert miss.headers.get("set-cookie") == "session=abc123; Path=/" + assert hit.headers.get("set-cookie") is None + finally: + get_settings.cache_clear() + + def test_not_modified_carries_no_representation_metadata( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """RFC 9110 section 15.4.5 limits what a 304 may carry. + + The stored block holds a Content-Type and a Link; replaying the whole + block on a 304 would describe a representation the response does not + contain. + """ + get_settings.cache_clear() + try: + app = self._app( + fake_async_redis, + {"Link": '; rel="next"', "Vary": "Accept-Language"}, + ) + with TestClient(app) as c: + miss = c.get("/items") + validated = c.get( + "/items", headers={"If-None-Match": miss.headers["etag"]} + ) + assert validated.status_code == 304 + # Carried: the validator, the cache metadata, Vary. + assert validated.headers["etag"] == miss.headers["etag"] + assert validated.headers["vary"] == "Accept-Language" + assert "cache-control" in validated.headers + # Not carried: anything describing a body that is not there. + assert validated.headers.get("content-type") is None + assert validated.headers.get("link") is None + finally: + get_settings.cache_clear() + + +@pytest.mark.unit +class TestConditionalRequests: + """Both validators, and their precedence.""" + + @staticmethod + def _app(fake: fakeredis.aioredis.FakeRedis, headers: dict[str, str]) -> FastAPI: + app = FastAPI() + FastAPIRedis(app).caching() + + @app.get("/doc", dependencies=[Depends(cache(ttl=300))]) + async def doc() -> Response: + return Response( + content='{"v": 1}', media_type="application/json", headers=headers + ) + + app.dependency_overrides[get_async_redis] = _fake_dep(fake) + return app + + LAST_MODIFIED = "Wed, 10 Sep 2026 12:00:00 GMT" + + @pytest.mark.parametrize( + ("since", "expected"), + [ + (LAST_MODIFIED, 304), # same instant: unchanged + ("Thu, 11 Sep 2026 12:00:00 GMT", 304), # client is newer + ("Tue, 09 Sep 2026 12:00:00 GMT", 200), # client is older + ("not a date", 200), # unparseable: no precondition + ], + ) + def test_if_modified_since( + self, + since: str, + expected: int, + fake_async_redis: fakeredis.aioredis.FakeRedis, + ) -> None: + get_settings.cache_clear() + try: + app = self._app(fake_async_redis, {"Last-Modified": self.LAST_MODIFIED}) + with TestClient(app) as c: + c.get("/doc") # fill + r = c.get("/doc", headers={"If-Modified-Since": since}) + assert r.status_code == expected + finally: + get_settings.cache_clear() + + def test_if_modified_since_ignored_without_a_stored_last_modified( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + get_settings.cache_clear() + try: + with TestClient(self._app(fake_async_redis, {})) as c: + c.get("/doc") + r = c.get("/doc", headers={"If-Modified-Since": self.LAST_MODIFIED}) + assert r.status_code == 200 + finally: + get_settings.cache_clear() + + def test_if_none_match_takes_precedence( + self, fake_async_redis: fakeredis.aioredis.FakeRedis + ) -> None: + """RFC 9110 section 13.2.2: an entity-tag precondition wins. + + The date here would justify a 304 on its own. Because the ETag does + not match, the body must be sent anyway. + """ + get_settings.cache_clear() + try: + app = self._app(fake_async_redis, {"Last-Modified": self.LAST_MODIFIED}) + with TestClient(app) as c: + c.get("/doc") + r = c.get( + "/doc", + headers={ + "If-None-Match": 'W/"stale"', + "If-Modified-Since": self.LAST_MODIFIED, + }, + ) + assert r.status_code == 200 + finally: + get_settings.cache_clear() + + +@pytest.mark.unit +class TestHeaderBlockCap: + """A route may not store an unbounded amount of metadata.""" + + @staticmethod + def _pending() -> CachePending: + return CachePending(key="k", ttl=60) + + def test_oversized_block_refused(self) -> None: + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"application/json"), + (b"x-huge", b"v" * (MAX_CACHEABLE_HEADER_SIZE + 1)), + ], + ) + assert reason is not None + assert "over the" in reason + + def test_ordinary_block_allowed(self) -> None: + assert ( + _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"application/json"), + (b"link", b'; rel="next"'), + (b"x-total-count", b"4211"), + ], + ) + is None + ) + + +@pytest.mark.unit +class TestDuplicateCacheControlHeaders: + """A forbidding directive on a second header line must still be seen.""" + + @staticmethod + def _pending() -> CachePending: + return CachePending(key="k", ttl=60) + + @pytest.mark.parametrize("forbidding", [b"no-store", b"private"]) + def test_second_line_is_read(self, forbidding: bytes) -> None: + reason = _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"application/json"), + (b"cache-control", b"public, max-age=60"), + (b"cache-control", forbidding), + ], + ) + assert reason == f"response set Cache-Control: {forbidding.decode()}" + + def test_all_permissive_lines_still_stored(self) -> None: + assert ( + _storage_refusal( + _request(), + self._pending(), + 200, + [ + (b"content-type", b"application/json"), + (b"cache-control", b"public"), + (b"cache-control", b"max-age=60"), + ], + ) + is None + ) diff --git a/tests/unit/test_deps.py b/tests/unit/test_deps.py index f83b1dd..c881970 100644 --- a/tests/unit/test_deps.py +++ b/tests/unit/test_deps.py @@ -561,7 +561,13 @@ async def test_backend_and_decorator_resolve_the_same_default( pending = CachePending( key="decorator-key", ttl=settings.default_ttl, redis=fake ) - await _store_cache_entry(pending, b'{"v": 1}', {}) + await _store_cache_entry( + pending, + b'{"v": 1}', + '{"v": 1}', + [(b"content-type", b"application/json")], + {}, + ) decorator_ttl = await fake.ttl("decorator-key") # -- backend path: set() with no ttl --