Skip to content

feat(cache): send Age on responses served from a stored entry - #356

Merged
allen0099 merged 1 commit into
masterfrom
feat/254-age-header
Sep 29, 2026
Merged

allen0099 merged 1 commit into
masterfrom
feat/254-age-header

Conversation

@allen0099

Copy link
Copy Markdown
Owner

Closes #254

Problem

A cache hit sent Cache-Control: max-age=<ttl> and no Age, 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

  • CacheEntry gains an optional stored_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. @cache sets it when it stores a response.
  • The Redis/Memcached codec writes stored_at. Documents without it (older releases) decode with None. A value that is not a finite number also decodes as None, so a bad field does not turn the entry into a miss. MemoryBackend stores the dataclass as it is, so nothing changes there. Counter entries are unchanged (still a bare integer, no stored_at).
  • Responses served from a stored entry send 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-age stays ttl. Downstream computes the remaining freshness as max-age - Age (RFC 9111 §4.2.3). A code comment on _age_headers explains this.
  • age joins the headers that are never stored, so a handler's own Age header is not replayed. Replaying it would also have put two Age fields on a hit.
  • The time comes from a module-level _now = time.time hook in cache.py, so tests move the clock without sleeping.

Per-path decisions in cache.py

Path Age? Why
TTL hit (200 from the stored entry) yes Served from storage; this is the case the issue is about.
304 from the stored entry's ETag (If-None-Match, not no_cache) yes Answered from the stored entry without running the handler. A cache that refreshes its copy with this 304 takes the headers with it (RFC 9111 §4.3.4), so it needs the entry's real age.
no_cache=True with If-None-Match (304 or 200) no The handler re-renders first and the ETag is compared with the fresh response. Nothing comes from storage.
no_cache=True without If-None-Match no Always a fresh render.
Miss / fresh render (including a re-store) no The response was just produced (age 0 by definition).
no_store, private, ttl of None/0, credential bypass, non-GET no The backend is not read. Their 304s compare against a freshly rendered response.
Entry without stored_at (older release) no The age is unknown, and a made-up value would be wrong.

Tests

New tests/test_cache_age.py:

  • a hit at t+N sends Age: N (fractional N truncated), with max-age unchanged and exactly one Age field;
  • an age greater than the ttl is clamped to the ttl, and a negative (skewed) one to 0;
  • a 304 from the cached ETag carries Age;
  • no Age on a miss, on no_cache revalidation, or on any bypass (no-store, private, ttl=0, non-GET, credential), even with a stored entry under the key;
  • no Age on a legacy entry without stored_at (hit and 304);
  • a handler's Age is not stored or replayed;
  • the codec round-trips stored_at; a legacy document decodes as None; malformed and non-finite values decode as None; counters are unchanged;
  • end to end on the memory, Redis and Memcached backends (hit and 304 both carry Age).

Mutation checks (full suite, each mutation reverted afterwards):

  • header removed (_age_headers returns {}): 12 failures;
  • ttl clamp removed: 2 failures;
  • zero clamp removed: 1 failure;
  • stored_at not set on store: 12 failures;
  • Age dropped from the cached-ETag 304 only: 4 failures;
  • codec no longer encodes stored_at: 3 failures (codec round-trip, Redis, Memcached);
  • age removed from the unstored headers: 1 failure.

Docs

docs/HTTP_CACHING.md has a new section, "The Age header". docs/CACHE_FLOW.md covers Age in the flow diagram, the decision pseudo-code, the CacheEntry example and the storage formats. The zh-TW translations are updated to match. Changelog fragment: changelog.d/254.fixed.md.

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
@allen0099
allen0099 merged commit c893c6c into master Sep 29, 2026
12 checks passed
@allen0099
allen0099 deleted the feat/254-age-header branch September 29, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@cache: send an Age header on hits so downstream freshness does not reach 2×ttl

1 participant