Skip to content

fix(caching): reset StoredAt on 304 revalidation so Age restarts (v2.2.1) - #5

Merged
FranRuiz98 merged 1 commit into
masterfrom
claude/happy-nobel-3fedf3
Jul 23, 2026
Merged

fix(caching): reset StoredAt on 304 revalidation so Age restarts (v2.2.1)#5
FranRuiz98 merged 1 commit into
masterfrom
claude/happy-nobel-3fedf3

Conversation

@FranRuiz98

Copy link
Copy Markdown
Owner

Summary

When a stale entry was revalidated and the origin answered 304 Not Modified, the entry's freshness TTL was refreshed but StoredAt kept its original value, so the Age response header kept growing past the revalidation. Observed live with the samples against Kestrel + Redis: /catalog with max-age=10, stale-if-error=60 showed Age: 65 at the moment of a successful 304 revalidation, then Age: 68, 70… on subsequent fresh cache hits instead of restarting near 0.

Per RFC 9111 §4.2.3 the age calculation restarts from the validation response, and §4.3.4 requires the stored response to be updated with the 304's header fields.

Changes

  • New shared RefreshFromNotModified helper in CachingMiddleware replaces the three duplicated 304-refresh blocks (foreground RevalidateAsync, background stale-while-revalidate, and HandleHeadAsync). On a 304 it now:
    • resets StoredAt to the revalidation time, so Age restarts from 0 (§4.2.3);
    • merges the 304's header fields (Date, Cache-Control, ETag, …) into the stored headers (§4.3.4);
    • recomputes ExpiresAt / stale windows / MustRevalidate exactly as before.
  • Secondary fix that falls out of it: MemoryCacheStore computes its eviction TTL as ExpiresAt − StoredAt, so refreshed entries previously got an inflated eviction window (new ExpiresAt − old StoredAt). DistributedCacheStore keys off absolute ExpiresAt and is unaffected; the reset StoredAt round-trips through its JSON serialization (covered by a test).
  • Version bump 2.2.0 → 2.2.1 (pure behavior fix, no API change — say the word if you'd rather ship it as 2.3.0) + README changelog entry.

Tests

New NotModifiedAgeResetTests (6 tests) covering: Age = 0 on the revalidated response, subsequent hits counting age from the revalidation time, §4.3.4 header updates, background (stale-while-revalidate) revalidation, HEAD-triggered revalidation, and the DistributedCacheStore path. All six genuinely fail against the pre-fix code (verified by stashing the middleware change: 6/6 failed).

Freshness logic runs on the injected TimeProvider, so these use FakeTimeProvider — per the v2.1.0 lesson, only physical store-eviction timing needs the real clock, which isn't what's under test here. The distributed test seeds the fake clock with real now so MemoryDistributedCache's real-clock absolute expiration doesn't evict the entry.

Verification

  • dotnet build Stampede.Http.slnx --no-incremental: 0 warnings, 0 errors
  • dotnet test: 349/349 passed (343 existing + 6 new)

🤖 Generated with Claude Code

…2.1)

When a stale entry was revalidated and the origin answered 304 Not
Modified, the three refresh sites (foreground, background
stale-while-revalidate, and HEAD) rebuilt the entry with a new
ExpiresAt but kept the original StoredAt, so the Age response header
kept growing past the revalidation (max-age=10 reporting Age: 65, 68,
70... on subsequent fresh hits, observed live against Kestrel + Redis).

Per RFC 9111 §4.2.3 the age calculation restarts from the validation
response, and §4.3.4 requires the stored response's header fields to be
updated with those carried on the 304. The three duplicated refresh
blocks are collapsed into a shared RefreshFromNotModified helper that
resets StoredAt to the revalidation time, merges the 304's header
fields (Date, Cache-Control, ETag, ...) into the stored headers, and
recomputes freshness metadata as before. This also corrects the memory
store's eviction window for refreshed entries, which was inflated by
the stale StoredAt (new ExpiresAt - old StoredAt).

All six regression tests fail against the pre-fix code and cover the
foreground, background, and HEAD paths on both MemoryCacheStore and
DistributedCacheStore (JSON round-trip included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FranRuiz98
FranRuiz98 merged commit 07f0cd3 into master Jul 23, 2026
2 checks passed
@FranRuiz98
FranRuiz98 deleted the claude/happy-nobel-3fedf3 branch July 23, 2026 20:02
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.

1 participant