feat(cache): send Age on responses served from a stored entry - #356
Merged
Merged
Conversation
A hit and a 304 answered from the stored ETag sent max-age=<ttl> with no Age, so a downstream cache restarted the freshness clock and could reuse the response for up to twice the ttl. CacheEntry gains an optional stored_at (wall-clock epoch seconds) that @cache sets and the codec stores; Age is now - stored_at clamped to 0..ttl. Entries without stored_at, fresh renders and bypassed requests send no Age. Closes #254
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #254
Problem
A cache hit sent
Cache-Control: max-age=<ttl>and noAge, so a browser or CDN treated every hit as brand new. A response served just before the backend entry expired could then be reused downstream for another full ttl, up to about twice the ttl in total.Change
CacheEntrygains an optionalstored_at: float | None = None: epoch seconds from the wall clock (time.time()), because an entry stored by one process or host is served by another.@cachesets it when it stores a response.stored_at. Documents without it (older releases) decode withNone. A value that is not a finite number also decodes asNone, so a bad field does not turn the entry into a miss.MemoryBackendstores the dataclass as it is, so nothing changes there. Counter entries are unchanged (still a bare integer, nostored_at).Age: int(clamp(now - stored_at, 0, ttl))(RFC 9111 §5.1: a non-negative integer). The upper clamp bounds clock skew between hosts: the backend never keeps an entry longer than ttl, so a larger value can only be skew. The lower clamp covers a storing host whose clock runs ahead.max-agestaysttl. Downstream computes the remaining freshness asmax-age - Age(RFC 9111 §4.2.3). A code comment on_age_headersexplains this.agejoins the headers that are never stored, so a handler's ownAgeheader is not replayed. Replaying it would also have put twoAgefields on a hit._now = time.timehook incache.py, so tests move the clock without sleeping.Per-path decisions in
cache.pyAge?If-None-Match, notno_cache)no_cache=TruewithIf-None-Match(304 or 200)no_cache=TruewithoutIf-None-Matchno_store,private,ttlofNone/0, credential bypass, non-GETstored_at(older release)Tests
New
tests/test_cache_age.py:Age: N(fractional N truncated), withmax-ageunchanged and exactly oneAgefield;Age;Ageon a miss, onno_cacherevalidation, or on any bypass (no-store, private, ttl=0, non-GET, credential), even with a stored entry under the key;Ageon a legacy entry withoutstored_at(hit and 304);Ageis not stored or replayed;stored_at; a legacy document decodes asNone; malformed and non-finite values decode asNone; counters are unchanged;Age).Mutation checks (full suite, each mutation reverted afterwards):
_age_headersreturns{}): 12 failures;stored_atnot set on store: 12 failures;Agedropped from the cached-ETag 304 only: 4 failures;stored_at: 3 failures (codec round-trip, Redis, Memcached);ageremoved from the unstored headers: 1 failure.Docs
docs/HTTP_CACHING.mdhas a new section, "TheAgeheader".docs/CACHE_FLOW.mdcoversAgein the flow diagram, the decision pseudo-code, theCacheEntryexample and the storage formats. The zh-TW translations are updated to match. Changelog fragment:changelog.d/254.fixed.md.