Answer a cached 304 under the headers its body came with

Merged
JE
5d ago
+1376-69

With the HTTP cache on, Go and Rust answered a 304 Not Modified with the cached body but only the 304's own headers. The cache never stored the 200's headers. A 304 doesn't have to repeat the Link and X-Total-Count of the page it confirms, so a paginated read revalidated that way lost its next page and its count. GetAll stopped after page one, FollowPagination found no next link, and a Page reported no total. hey-cli turns the Go cache on, so hey mcp hits this first.

Kotlin, Swift and TypeScript already store the headers and merge the 304's in. This brings Go and Rust in line with them, and adds a conformance case so all five stay in line.

The rule

  • A cache entry stores the headers its body came with, minus credentials (Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-CSRF-Token) and the connection's own fields, which RFC 9111 §3.1 keeps out of a cache (Connection and the fields it lists, Keep-Alive, Proxy-Connection, TE, Transfer-Encoding, Upgrade).
  • A 304 is answered with the stored headers, and each field the 304 carried replaces the stored field of that name. Content-Length is the exception: it describes the 304's empty body (RFC 9111 §3.2).
  • The caller gets everything the 304 sent (a fresh Set-Cookie included). The entry keeps only what may be stored (RFC 9111 §4.3.4): the updated headers, plus the 304's ETag when it names a new one. So a count or cursor that HEY moved on a 304 is still there on the next bare 304. Only metadata is written; the body on disk stays as it is.
  • A 304 is answered from the entry the request went out conditional on, read whole before sending. Its update is written only while that entry is still the one stored, because a body stored meanwhile is newer than anything the 304 confirms.
  • An entry written before headers were stored counts as a miss, not a conditional request. Revalidating it would answer without its Link. Each such page is fetched in full once after the upgrade.
SDKChange
Go (cache.go, client.go, generated_cache.go)Cache.SetResponse(key, body, etag, header) and Cache.GetHeaders(key), stored as responses/<key>.headers (JSON, name → values). Set still works and stores no headers. Both 304 paths use the stored headers: hand-written singleRequest and the generated client's cachingDoer. Names are read back in canonical form, whatever case another SDK wrote them in. Additive API.
Rust (cache.rs, client.rs)CachedResponse gains headers: HeaderMap. ResponseCache gains revalidate, with a default that reads and re-sets the entry. FileCache and InMemoryCache override it to write only the metadata, conditional on the validator. FileCache reads and writes the same responses/<key>.headers file Go uses, since the two already share a layout.
Kotlin, Swift, TypeScriptNo change. They already store headers and merge the 304's (CachedResponse.headersUpdatedBy / headersUpdated(by:), and TS client.ts).

Rust: breaking. Adding a public field to CachedResponse, which callers can build literally, breaks any ResponseCache implemented outside the crate. This PR carries the breaking label, and MIGRATING.md gets a "Rust: the first release after 0.32.1" section. I didn't bump the version here. That belongs to the release PR, which needs 0.MINOR for Rust. The non-breaking alternative was default-method hooks on ResponseCache. Third-party caches would then lose headers silently, which is this same bug, so I didn't take it.

Tests: each fails without the fix

  • Go: cache_headers_test.go
    • TestPaginationSurvivesARevalidationThatOmitsItsHeaders: GetAll over two pages, then again with every page answered by a bare 304. Both pages come back, and the third read still carries X-Total-Count and no Set-Cookie. Without the fix the second walk stops after page one.
    • TestGeneratedOperationsKeepPaginationHeadersThroughABareRevalidation: generated Folders().GetPage. A bare 304 keeps TotalCount and NextPage. A 304 carrying a new count updates it, and the following bare 304 still has the update. I checked that removing the entry update fails the last step.
    • TestCacheEntriesWithoutHeadersAreRefetched: old-format entries go out unconditional on both paths.
    • TestARevalidationAnswersTheVersionItConfirms: on both paths, a v2 is stored while the v1 revalidation is held open. The answer is v1's body and headers, and the entry stays v2.
    • TestARevalidationKeepsItsMetadataWithoutRewritingTheBody: a 304's new ETag is sent on the next read, its Set-Cookie reaches the caller and not the disk, and the body file keeps its inode.
    • TestCacheStoresHeadersWithoutCredentials: filtering, lowercase names written by Rust read back, and Invalidate drops the file.
  • Rust: tests/cache.rs
    • a_bare_304_keeps_the_page_it_confirms_{in_memory,on_disk} runs the same flow through boxes().list() and next_page, including the update that persists.
    • the_file_cache_keeps_headers_beside_the_body checks filtering, names written by Go, and that an entry without a headers file is a miss.
    • a_304_updates_the_entry_without_rewriting_its_body and a_revalidation_leaves_an_entry_replaced_meanwhile_alone are the Rust counterparts of the two Go tests above. They check the new ETag, Set-Cookie, the body inode, and the conditional update on both stores.
    • Answering with the 304's headers alone, or storing none, fails both flow tests. Filtering the 304 before merging, or dropping FileCache's revalidate, fails the metadata test.
  • Conformance: cache-revalidation.json gains "Cache-enabled paginated read keeps its pagination headers through a bare 304". It's a cached GetCollection whose second read is a bare 304, with responseMeta.nextPage and totalCount asserted. It fails on Go without the fix ("Expected next page "next-cursor", got """). It passes on all five runners with it.
    • The Go runner can now read a hand-written page's pagination state in the HEY layer.
    • The Rust, Kotlin and Swift runners send HEY-layer GetCollection through the generated path on the case's cached client.
    • The TS fixture inventory is regenerated.

Not in this PR

  • Who sends a bare 304. In practice HEY often repeats the headers. geared_pagination sets Link/X-Total-Count in an after_action that runs on 304s too, and TestGeneratedOperationsReadPaginationHeadersFromTheRevalidation relies on that. A 304 can still arrive without them, from any page that isn't geared or from anything between us and HEY. The SDK shouldn't depend on the server repeating them.
  • Cache-Control: no-store. Kotlin and Swift honor no-store, and Go and Rust still don't. That's a separate parity gap, not part of this bug.
  • Hop-by-hop fields. Kotlin, Swift and TS don't strip them from what they store. Their HTTP stacks already drop most of them, so I left those three alone.