fix: store revalidation-only responses so etag revalidation can engage#5515
Open
jeswr wants to merge 2 commits into
Open
fix: store revalidation-only responses so etag revalidation can engage#5515jeswr wants to merge 2 commits into
jeswr wants to merge 2 commits into
Conversation
Responses with a validator (ETag or Last-Modified) but zero freshness
lifetime - `cache-control: no-cache` or `max-age=0` without any other
freshness source - were never stored: determineStaleAt() returned
undefined, so the conditional-request flow never engaged and every
request was answered by a full 200 from the origin. This is the
canonical "cache but always validate" pattern for API servers, and RFC
9111 (sections 3 and 5.2.2.4) explicitly allows storing these responses
as long as each reuse is revalidated.
Repro (undici main, Node 22): origin serving
`cache-control: no-cache` + `etag` (no Last-Modified):
before: 2 requests -> 2 full 200s, no if-none-match sent
after: 2 requests -> 1 full 200 + 1 conditional revalidation
answered with a 304, cached body reused
Same for `cache-control: max-age=0` + `etag`. The already-working
no-cache + Last-Modified case (stored via the heuristic-freshness
branch) is unchanged and covered by a new regression test.
The fix stores such responses with immediate-stale semantics:
- determineStaleAt() returns 0 (instead of undefined) for max-age=0 /
s-maxage=0 / unqualified no-cache when the response has a usable
validator;
- the "response is already stale" rejection in onResponseStart() is
relaxed for these revalidation-only entries;
- determineDeleteAt() retains them for a bounded 24h window (the usual
buffer is proportional to the freshness lifetime, which is zero here);
every successful revalidation re-stores the entry, sliding the window.
The read side needs no changes: stored no-cache entries are already
forced through revalidation by needsRevalidation(), and max-age=0
entries are always stale, so isStale() triggers the same conditional
flow.
Also makes the previously failing (optional) mnot cache-tests
conformance test cc-resp-no-cache-revalidate pass in all environments
(220 -> 221 passed, 58 -> 57 failed-optional, 0 required failures).
Supersedes nodejs#4624, relates to discussion nodejs#4620.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5515 +/- ##
=======================================
Coverage 93.44% 93.44%
=======================================
Files 110 110
Lines 37328 37363 +35
=======================================
+ Hits 34881 34914 +33
- Misses 2447 2449 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Collapse the multi-sentence comments introduced by this PR to the terse, single-line style used elsewhere in lib/interceptor, keeping the RFC 9111 references. Addresses proactive review feedback on comment terseness. No logic change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4aa3164 to
038c4f6
Compare
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.
This relates to...
Discussion #4620 and PR #4624 by @steunix (this PR supersedes #4624 — full credit to @steunix for identifying the problem and taking the first pass at it; see "Relationship to #4624" below for why a fresh approach was needed).
Rationale
Responses with a validator (
ETagorLast-Modified) but zero freshness lifetime —Cache-Control: no-cacheormax-age=0with no other freshness source — are never stored by the cache interceptor:determineStaleAt()returnsundefined, so no entry is created, noIf-None-Matchis ever sent, and every request gets a full 200 from the origin.ETag+no-cacheis the canonical "cache, but always validate" pattern for API servers, and RFC 9111 §3 / §5.2.2.4 explicitly allows storing these responses as long as each reuse is revalidated — undici currently gets zero 304 benefit from them.Repro against
main(Node 22), origin servingcache-control: no-cache+etag "..."(noLast-Modified):Same for
cache-control: max-age=0+etag. The already-workingno-cache+Last-Modifiedcase (stored via the heuristic-freshness branch) is unchanged, and now covered by a regression test.Changes
Store such responses with immediate-stale semantics (three coordinated pieces in
lib/handler/cache-handler.js):determineStaleAt()returns0instead ofundefinedformax-age=0/s-maxage=0/ unqualifiedno-cachewhen the response has a usable validator (isEtagUsableetag or aLast-Modifiedheader). The newno-cachebranch sits below theLast-Modifiedheuristic branch so it cannot shadow the existing behavior.onResponseStart()is relaxed for these revalidation-only entries (zero freshness lifetime + validator) so they can actually be written to the store.determineDeleteAt()retains them for a bounded 24h window: the usual revalidation buffer is proportional to the freshness lifetime, which is zero here, so the entry would otherwise be evicted immediately and never be available to revalidate. Every successful revalidation re-stores the entry, sliding the window; store-level limits (LRU / maxCount / maxSize) still apply.The read side needs no changes: stored unqualified-
no-cacheentries are already forced through revalidation on every use byneedsRevalidation(), andmax-age=0entries are always stale, soisStale()triggers the same conditional-request flow.Relationship to #4624
#4624 identified exactly this problem and made
determineStaleAt()return0forno-cache/max-age=0. Testing that diff againstmainshowed two issues (which is why this is a fresh PR rather than a review comment):staleAt = 0,now >= absoluteStaleAtis true, soonResponseStart()bails with "response is already stale" before anything is written — and even past that,determineDeleteAt()computes a delete time ofstaleAt + freshnessLifetime (= 0) + ≤1s padding, evicting the entry immediately (pieces 2 and 3 above address these);no-cache → 0branch sat above theLast-Modifiedheuristic branch, the currently-workingno-cache+Last-Modifiedcase stopped being cached (request pattern went fromfull, conditionaltofull, full).Features
N/A
Bug Fixes
Cache-Control: no-cache(unqualified) and a validator are now stored and revalidated with a conditional request on each reuse.Cache-Control: max-age=0(ors-maxage=0for shared caches) and a validator are now stored and revalidated before reuse.Breaking Changes and Deprecations
N/A
Tests
test/interceptors/cache.js(no-cache + etag, max-age=0 + etag, and the no-cache + etag + last-modified regression guard). The first two fail onmain, all three pass with this change.test/interceptors/cache*.js,test/cache-interceptor/*): 124/124 pass.node test/cache-interceptor/cache-tests.mjs): 0 required failures before and after; the previously failing optional testcc-resp-no-cache-revalidate("An optimal HTTP cache stores a response withCache-Control: no-cache, but revalidates it upon use") now passes in all 4 environments (221 passed / 57 failed-optional, vs 220 / 58 onmain). No test regressed.cc-resp-no-cache/cc-resp-no-cache-case-insensitiveskip-list entries intest/cache-interceptor/cache-tests.mjsappear stale — both pass on unmodifiedmainwhen un-skipped.)Sibling PRs from the same RFC 9111 review (each self-contained against
main): #5510 (fix/cache-request-max-age-zero, the request-sidemax-age=0falsy check) and #5512 (fix/cache-if-modified-since-value, validator sent on revalidation) touch the adjacent request-directive/revalidation logic; also #5511, #5513, #5514.This change is agent-assisted (Claude Fable 5) working with @jeswr.
Status