Skip to content

fix(matcher): do not cache responses that freshly assign a sticky variant - #1216

Open
guitavano wants to merge 1 commit into
mainfrom
guitavano/deco-cache-vary-cookies
Open

fix(matcher): do not cache responses that freshly assign a sticky variant#1216
guitavano wants to merge 1 commit into
mainfrom
guitavano/deco-cache-vary-cookies

Conversation

@guitavano

@guitavano guitavano commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

The random A/B matcher (website/matchers/random.ts in apps) declares cacheable = true and is sticky-on-session: it draws a variant once (Math.random() < traffic) and persists it in a deco_matcher_* cookie.

Recent work (#1203, #1204, #1205) made such pages cacheable by mirroring the framework Set-Cookie into an inline <script>document.cookie=…</script> and marking the response cacheable — assuming the CDN would key the cache by that cookie so each variant gets a distinct entry.

That assumption only holds for visitors who already carry the cookie. A cold visitor has no cookie, so all cold visitors share the single empty-cookie cache entry. The first cold visitor's coin flip gets baked into that shared HTML (variant + the cookie-setting script) and is served to everyone within the cache window. The result:

  • The traffic split (e.g. 50/50) collapses — the actual distribution becomes "whatever the few cold visitors happened to draw", weighted by cache windows.
  • The A/B test is statistically invalid.

This isn't a privacy/correctness bug (no user data leaks); it's a soundness bug for any per-user matcher. A random draw and a shared cached response are fundamentally incompatible.

Fix

Treat a framework Set-Cookie on the response as a fresh, non-deterministic assignment and force no-store. Only responses whose variant was read back from an existing request cookie (no Set-Cookie emitted) are cacheable, and their Deco-Cache-Vary-Cookies hint is now derived from the request cookies the visitor already carries.

The invariant: never cache the exact request where a variant cookie is being born.

Net effect

Scenario Before After
Clean page / no matcher cached cached (unchanged)
Returning, already-bucketed visitor cached cached, varied by variant cookie
The request that performs the random draw cached & shared no-store

Cold visitors now take an origin miss (unavoidable to keep the draw independent without edge-side randomization), but returning traffic — the bulk of session pageviews — still hits cache.

The client-cookie mirror mechanism becomes obsolete (a cacheable response never carries a framework Set-Cookie anymore), so runtime/clientCookies.ts and its tests are removed. Cold-visit Set-Cookie headers now flow to the client normally (the response is no-store, so no CDN strips them) — this also restores stickiness for no-JS clients.

Note: apps/website/matchers/random.ts needs no change — cacheable = true is now safe because the framework refuses to cache the assignment request. Its comment is slightly outdated and can be updated separately.

Tests

runtime/middleware.test.ts updated: fresh-assignment → no-store, returning-visitor → public + vary hint from request cookies, plus the existing safety cases. All 10 pass.

🤖 Generated with Claude Code


Summary by cubic

Prevents caching of responses that assign a fresh sticky variant so A/B splits stay correct. Only returning visitors are cached (varied by their existing cookies), and the client-cookie mirror code is removed.

  • Bug Fixes

    • If the response sets a framework cookie (fresh assignment), set Cache-Control to no-store.
    • Cache only when the variant comes from request cookies; emit Deco-Cache-Vary-Cookies from those names.
    • Pages without matchers remain cached as before.
  • Refactors

    • Removed runtime/clientCookies.ts and HTML script injection; middleware no longer rewrites HTML.
    • Updated tests for fresh-assignment no-store and returning-visitor cache behavior.

Written for commit 784f8fa. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved page caching for returning visitors by recognizing framework cookies from incoming requests.
    • Prevented caching whenever framework cookies are newly set or reassigned.
    • Removed client-side cookie synchronization scripts from generated responses.
    • Updated cache variation hints to better preserve personalized page behavior.

…iant

A sticky matcher (e.g. the `random` A/B matcher) draws a variant on the
first request and persists it in a `deco_matcher_*` / `deco_segment`
cookie. The previous approach mirrored that Set-Cookie into an inline
`<script>` and marked the page cacheable, assuming the CDN would key the
cache by the cookie. That assumption only holds for visitors who ALREADY
carry the cookie: every cold visitor shares the empty-cookie cache entry,
so the first cold draw gets baked into the shared response and served to
everyone within the cache window. The traffic split (e.g. 50/50)
collapses and the A/B test is statistically invalid.

Fix: treat a framework `Set-Cookie` on the response as a fresh,
non-deterministic assignment and force `no-store`. Only responses whose
variant was read back from an existing request cookie (no Set-Cookie
emitted) are cacheable, and their `Deco-Cache-Vary-Cookies` hint is now
derived from the request cookies the visitor already carries.

Net effect:
- Clean pages / no matchers: cache first visit as before (unchanged).
- Returning, already-bucketed visitors: served from cache, varied by
  their variant cookie.
- The single request that performs the random draw: no-store, so each
  cold visitor gets an independent draw and the split is preserved.

This makes the client-cookie mirror mechanism obsolete, so
runtime/clientCookies.ts and its tests are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.202.2 update
  • 🎉 for Minor 1.203.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54eaf9ed-a1d9-4f6e-ad66-30f37f1a784e

📥 Commits

Reviewing files that changed from the base of the PR and between 53410fe and 784f8fa.

📒 Files selected for processing (3)
  • runtime/clientCookies.ts
  • runtime/middleware.test.ts
  • runtime/middleware.ts
💤 Files with no reviewable changes (1)
  • runtime/clientCookies.ts

📝 Walkthrough

Walkthrough

Framework cookie cache decisions now derive variation from request cookies, mark fresh framework cookie assignments as no-store, and remove HTML cookie-script injection and response cookie stripping.

Changes

Framework cookie cache behavior

Layer / File(s) Summary
Cache decision contract and rules
runtime/middleware.ts, runtime/middleware.test.ts
applyPageCacheDecision uses request framework cookies for cache variation, forces no-store for framework Set-Cookie, and adds coverage for returning and reassigned visitors.
Middleware integration and response construction
runtime/middleware.ts
Middleware extracts framework cookies from requests and returns the original response body without client cookie script injection or framework cookie stripping.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant middleware
  participant applyPageCacheDecision
  participant Response
  Request->>middleware: incoming framework cookies
  middleware->>applyPageCacheDecision: requestFrameworkCookies
  applyPageCacheDecision->>Response: cache headers
  middleware->>Response: original response body
Loading

Possibly related PRs

Suggested reviewers: vibe-dex, hugo-ccabral

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: newly assigned sticky variants are no longer cached.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch guitavano/deco-cache-vary-cookies

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="runtime/middleware.ts">

<violation number="1" location="runtime/middleware.ts:194">
P2: A response that freshly sets a framework cookie can still retain an existing public `Cache-Control` header when `isPageCacheAllowed` is false, because this check is reached only after the opt-in early return. Applying framework `Set-Cookie` disqualification before that return would preserve the no-cache-on-cookie-birth invariant for responses with pre-existing cache headers.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread runtime/middleware.ts
// variant within the cache window). Only responses whose variant was read
// back from an existing request cookie (no framework Set-Cookie emitted) are
// safe to share from cache.
if (frameworkSetCookieNames(headers).length > 0) {

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.

P2: A response that freshly sets a framework cookie can still retain an existing public Cache-Control header when isPageCacheAllowed is false, because this check is reached only after the opt-in early return. Applying framework Set-Cookie disqualification before that return would preserve the no-cache-on-cookie-birth invariant for responses with pre-existing cache headers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/middleware.ts, line 194:

<comment>A response that freshly sets a framework cookie can still retain an existing public `Cache-Control` header when `isPageCacheAllowed` is false, because this check is reached only after the opt-in early return. Applying framework `Set-Cookie` disqualification before that return would preserve the no-cache-on-cookie-birth invariant for responses with pre-existing cache headers.</comment>

<file context>
@@ -182,12 +183,27 @@ export const applyPageCacheDecision = (
+  // variant within the cache window). Only responses whose variant was read
+  // back from an existing request cookie (no framework Set-Cookie emitted) are
+  // safe to share from cache.
+  if (frameworkSetCookieNames(headers).length > 0) {
+    headers.set("Cache-Control", NO_STORE);
+    return;
</file context>

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