Skip to content

fix(cache)!: store and replay repeated response headers line by line - #411

Merged
allen0099 merged 1 commit into
masterfrom
fix/105-repeated-headers
Sep 30, 2026
Merged

allen0099 merged 1 commit into
masterfrom
fix/105-repeated-headers

Conversation

@allen0099

Copy link
Copy Markdown
Owner

Closes #105, following the revised design in the issue.

What changes

  • CacheEntry.headers type. It is now a tuple of (name, value) pairs in the order sent, instead of dict[str, str] | None; no headers is ().
    • The constructor still accepts a dict, any iterable of pairs, or None, and converts it.
    • It raises TypeError for anything but (str, str) pairs.
  • What @cache stores and replays. It stores every header line. A hit and all three 304 paths (stored entry, no_cache, bypass/private) replay them in order.
    • Several Link lines, or a custom header read with getlist(), come back as sent.
    • Values are not joined, so a value holding a comma stays one line.
  • Headers a hit sets itself. It filters the stored lines through the uncacheable set, then sets its own Content-Type, Content-Length, ETag, Cache-Control and Age. An entry built outside @cache can therefore not duplicate framing headers or override the validators.
  • Codec (Redis, Memcached). It writes [[name, value], ...] and still decodes the 0.3.x object. Any other shape is a miss.
  • add_vary(). It puts the missing names on a new Vary line. Starlette's add_vary_header rewrote the first line and dropped the others. As a result, @cache(vary=...) and the session middleware lost a handler's second Vary line; that was already the case on master.

Migration

MIGRATING_0_4.md#cache-entry-headers now says which setups are affected in a rolling deploy.

  • With the default key builder, or one that calls build_cache_key(), a rolling deploy needs nothing: the key format changed in the same release, so 0.3.x and 0.4.0 never read each other's HTTP entries.
  • A custom key_builder that builds the key itself gives the same key in both versions. 0.3.9 answers 500 on an entry 0.4.0 wrote with headers. For such a route, keep 0.3.x off the shared backend during the rollout, or move the builder to build_cache_key() first.

No version marker is needed for this.

Docs

  • CACHE_FLOW, HTTP_CACHING and MIGRATING_0_4 are updated in English and zh-TW.
  • Changelog: changelog.d/105.changed.md.

Tests

  • New tests/test_cache_repeated_headers.py covers:
    • storing and replaying every line in order;
    • 304 Vary lines on all three paths, with and without vary=;
    • a hit from an entry carrying framing and validator headers;
    • codec round-trip, the 0.3.x object, and malformed shapes treated as a miss;
    • CacheEntry conversion and rejection;
    • add_vary when every name is already present.
  • Existing tests are updated from headers is None to (). The codec round-trips now use repeated link lines.
  • Checked that the tests catch each of these mutations:
    • collapsing to a dict on store or on the 304;
    • reverting add_vary;
    • dropping the replay filter;
    • loosening or dropping the pair and type checks;
    • accepting only a dict;
    • appending an empty Vary line.

CacheEntry.headers becomes a tuple of (name, value) pairs in the order
sent instead of dict[str, str] | None, so a header the handler sends
more than once (several Link lines, a custom header read with
getlist()) comes back from the cache as sent, and a 304 repeats every
Vary line. Values are no longer joined, so one holding a comma stays
one line.

The constructor still accepts a dict, any iterable of pairs, or None,
and rejects anything but (str, str) pairs. Redis and Memcached write
the headers as a JSON list of [name, value] and still decode the 0.3.x
object; malformed headers make the entry a miss. A hit sets its own
Content-Type, Content-Length, ETag, Cache-Control and Age, even when
an entry built outside @cache carries them.

add_vary() puts the missing names on a new Vary line: Starlette's
add_vary_header rewrote the first line and dropped the others, which
lost a Vary line whenever @cache(vary=...) or the session middleware
added a name.

BREAKING CHANGE: CacheEntry.headers is a tuple of (name, value) pairs;
code that reads it as a dict must iterate the pairs. 0.3.x cannot
serve an entry 0.4.0 wrote with headers, which matters only for keys
both versions share (a custom key_builder that does not call
build_cache_key()).

Closes #105
@allen0099 allen0099 added this to the 0.4.0 milestone Sep 30, 2026
@allen0099 allen0099 added bug Something isn't working http-cache The @cache decorator, cache keys and Cache-Control handling breaking-change Changes public behaviour or API; needs a minor/major release labels Sep 30, 2026
@allen0099
allen0099 merged commit 8098401 into master Sep 30, 2026
16 checks passed
@allen0099
allen0099 deleted the fix/105-repeated-headers branch September 30, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Changes public behaviour or API; needs a minor/major release bug Something isn't working http-cache The @cache decorator, cache keys and Cache-Control handling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repeated response headers are collapsed when a response is cached

1 participant