Skip to content

fix: max-age=0 and no-cache must be considered "immediately stale"#4624

Open
steunix wants to merge 1 commit into
nodejs:mainfrom
steunix:patch-1
Open

fix: max-age=0 and no-cache must be considered "immediately stale"#4624
steunix wants to merge 1 commit into
nodejs:mainfrom
steunix:patch-1

Conversation

@steunix

@steunix steunix commented Oct 15, 2025

Copy link
Copy Markdown

This relates to...

#4620 (reply in thread)

Rationale

When server is responding with a max-age=0 or no-cache, Undici does not cache the response and subsequent requests do not include if-none-match or if-modified-since headers.

Changes

The function determineStaleAt does not take max-age=0 and no-cache into account, returning undefined in both cases: the caller will ignore the response in regard to the cache handling

Features

N/A

Bug Fixes

N/A

Breaking Changes and Deprecations

N/A

Status

As per discussion nodejs#4620 (reply in thread)

Returning undefined causes the caller to ignore the result from a caching standpoint: max-age=0 and no-cache should return 0 for "immediately stale".

@metcoder95 metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test for it

@steunix

steunix commented Oct 16, 2025

Copy link
Copy Markdown
Author

Can you add a test for it

Sorry but I don't know the code base enough to even guess at what level to put a test.

@Uzlopak Uzlopak left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets see if the tests are still happy fist

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.92%. Comparing base (dc5802d) to head (cdb292c).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4624      +/-   ##
==========================================
- Coverage   92.93%   92.92%   -0.01%     
==========================================
  Files         106      106              
  Lines       33092    33097       +5     
==========================================
+ Hits        30753    30756       +3     
- Misses       2339     2341       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95

Copy link
Copy Markdown
Member

You can put it within the interceptors tests for cache interceptor, attempting to just replicate the behavior described in the issue

jeswr pushed a commit to jeswr/undici that referenced this pull request Jul 4, 2026
A 304 validation response on the synchronous revalidation path was
swallowed by CacheRevalidationHandler before the CacheHandler could see
it, so the stored entry was never updated per RFC 9111 §4.3.4: headers
carried by the 304 (e.g. a new Cache-Control) were discarded and the
entry's freshness was never recomputed, causing a revalidation request
on every single subsequent use, forever. The served response also kept
the obsolete `Warning: 110` header even though it had just been
successfully validated.

- CacheHandler now handles 304s before the cacheability gates and
  recomputes freshness from the merged (stored + 304) headers, so bare
  304s also refresh the entry; the stored Vary and ETag are preserved.
- CacheRevalidationHandler forwards 304s to a dedicated cache-update
  CacheHandler and passes the validation response's headers to the
  callback, so the interceptor serves the freshened headers with age 0
  and no stale warning.
- The 304 body-reuse path no longer byte-iterates Buffer bodies
  returned by SqliteCacheStore (Buffer.prototype.values() yields single
  bytes).

Un-skips four 304-etag-update-response-* conformance tests;
304-etag-update-response-Cache-Control remains skipped as it needs an
entry-retention floor (the max-age=1 entry is deleted before the test's
3s pause; see nodejs#4624).

Fixes nodejs#5505

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mcollina pushed a commit that referenced this pull request Jul 16, 2026
#5515)

* fix: store revalidation-only responses so etag revalidation can engage

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 #4624, relates to discussion #4620.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: shorten cache interceptor comments

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>

---------

Co-authored-by: Jesse Wright <jw896977@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

4 participants