Your Vary Test Passed. Production Still Dropped Accept

I had a content-negotiation test that passed.

Then I asked production the same question.

Production gave me the right body and the wrong evidence.

That is an uncomfortable category of bug because the application appears to work.

A client asking for HTML receives HTML.

A client asking for Markdown receives Markdown.

An unsupported client receives 406 Not Acceptable.

The feature demo is green.

The cache contract is not.

One URL, two representations

The site serves two representations from the same canonical URL.

Browser-shaped requests receive text/html.

Agent-shaped requests can prefer text/markdown.

The request header decides which representation wins:

Accept: text/markdown, text/html;q=0.8

That is what Accept is for.

RFC 9110 defines it as a way for a user agent to express preferences among response media types.

But content negotiation is not a substring search for text/markdown.

Media ranges can include wildcards, parameters, weights, and explicit rejection.

These two requests do not mean the same thing:

Accept: text/markdown, text/html;q=0.8
Accept: text/markdown;q=0, text/html

The first prefers Markdown.

The second forbids it.

Specificity matters too.

An exact media type can control one representation while a wildcard controls another.

Quoted parameter values can contain commas and semicolons that are data rather than delimiters.

So I wrote a small parser around the representations the site actually supports instead of pretending the header was a friendly CSV.

It evaluates each supported representation against its most specific matching range.

Then it compares quality, specificity, parameter specificity, and client order.

No header defaults to HTML.

No acceptable supported representation returns null, which the proxy turns into 406.

This is enough behavior to deserve tests.

The local contract was green

The parser tests covered the obvious choices and the irritating ones:

  • Markdown preferred over lower-weight HTML;
  • explicit q=0 rejection;
  • wildcard fallback;
  • media parameters before the weight;
  • quoted commas and semicolons;
  • unsupported media types.

The proxy tests covered the route behavior:

  • rewrite a Markdown-preferring homepage request;
  • let an HTML request continue to the canonical route;
  • refuse to let a reserved-looking query string bypass negotiation;
  • return a recoverable 406 for an unsupported representation.

Every response branch also asserted Vary: Accept.

At the exact revision I checked, the focused command ran eight tests and all eight passed.

That is real evidence.

It is evidence about the JavaScript response objects those tests inspect.

It is not yet evidence about the HTTP response a deployed client receives.

That distinction is the whole story.

Vary is not decorative metadata

When a response changes because of a request header, a later cache needs to know that header participated in selection.

For this route, the relevant declaration is:

Vary: Accept

RFC 9110 explains that Vary names the request fields that might have influenced response selection.

It also states the practical consequence: those fields expand the cache key needed to reuse a stored response.

RFC 9111 makes the cache rule explicit.

If a stored response nominates a request header through Vary, a cache cannot reuse that response without revalidation unless the nominated header matches the original request.

The representation body answers, “What did I send this time?”

Vary answers, “What must you compare before sending it again?”

A correct body does not compensate for a missing cache key.

The final response disagreed

I sent three real GET requests to the deployed homepage.

The HTML request returned:

HTTP/2 200
content-type: text/html; charset=utf-8
x-vercel-cache: HIT
age: 18235
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch

The body type was correct.

The response was a cache hit.

But Accept was absent from Vary.

The Markdown request returned:

HTTP/2 200
content-type: text/markdown; charset=utf-8
x-vercel-cache: MISS
vary: Accept

The unsupported request returned:

HTTP/2 406
content-type: text/plain; charset=utf-8
vary: Accept

Two branches preserved the declaration.

The cached HTML branch did not.

The application test and the deployment response were both telling the truth about different layers.

Do not promote one probe into a root cause

The response proves an observable mismatch.

It does not prove which internal layer caused it.

The HTML path continued through the framework's normal response pipeline and reached a cached static representation.

The Markdown path used a rewrite.

The 406 path returned a response directly.

Those facts narrow the boundary.

They do not tell me whether a framework phase, static-response materialization, deployment transform, or cache artifact omitted the header.

“Vercel stripped it” would be a satisfying sentence.

It would also outrun the evidence I collected.

The honest diagnosis is smaller:

The application-level HTML branch includes Accept in Vary.
The deployed cached HTML response observed by a client does not.
The exact omission layer remains unisolated.

That is enough to reject the feature's protocol-level completion claim.

It is not enough to name the culprit.

Test the reader, not only the writer

The first tests asked whether the code wrote the intended header into a response object.

The missing test needed to ask whether the deployed reader could observe it.

For this feature, the acceptance check is a response matrix written as assertions:

  • HTML request: status 200, HTML content type, and Vary contains Accept;
  • Markdown request: status 200, Markdown content type, and Vary contains Accept;
  • unsupported request: status 406, plain-text content type, and Vary contains Accept.

Then repeat the requests in conditions that exercise the cache.

A cold response and a cached response are different fixtures.

If the platform reports cache state, record it.

If the route uses rewrites, test the rewritten and pass-through branches separately.

And use GET when the property belongs to the delivered representation.

A HEAD probe can be useful, but it is not automatically the same execution path as the response whose body clients consume.

Make the deployment check capable of failing

An ad hoc check deserves the same suspicion as production code.

Before trusting a header assertion, feed it a synthetic response without Vary: Accept and confirm it fails.

Then feed it the real response.

Otherwise a quoting error, a permissive regular expression, or a swallowed pipeline status can print a reassuring green line while inspecting nothing.

The check should parse header field names case-insensitively and comma-separated values deliberately.

This response is valid:

Vary: RSC, Accept-Encoding, Accept

So is a differently cased field name.

The property is membership in the Vary field value, not equality with one pretty string.

A passing unit test is still valuable

The lesson is not that local tests are fake.

The parser tests prevented several real negotiation mistakes.

The proxy tests proved that every source-level branch attempted to declare its variation.

Without those tests, the deployed mismatch would be harder to localize because both selection and delivery would remain suspect.

The mistake would be asking those tests to certify a layer they never execute.

Each gate should state its reader:

Parser test → reads representation-selection behavior.
Proxy test → reads framework response objects.
Deployment probe → reads the client-visible HTTP response.
Cached probe → reads the reusable production representation.

The arrows matter more than the check marks.

The practical rule

When one URL serves multiple representations, implement real Accept semantics for the representations you support.

Return 406 when none of them is acceptable.

Add Accept to Vary on every negotiated response branch.

Preserve any existing Vary members.

Test the parser.

Test the framework response.

Then issue real GET requests against the deployed URL for every representation and refusal path.

Repeat the probe against cached responses.

Record what the client can observe.

If production disagrees with the unit test, report the boundary before inventing the cause.

Your code can write the right header.

The cache can still receive a different one.

The protocol ends at the final response, not at the last line of your proxy.